Compare commits

..
Author SHA1 Message Date
timothyandClaude Opus 4.8 f76f8a939c design(388): mirror full ChicoryTV design system to prod + design-sync reminder hook
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m32s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m33s
Bulk mirror sweep (#388): author/rebuild a prototype mockup for every shipped SPA screen so
the Claude Design project (eb3b6122 / local design-system/) faithfully reflects prod.

- 40 screens authored (mockups: visual/layout parity, no logic) via a fan-out workflow;
  8 stale prototypes rebuilt (Dashboard, ChannelBuilder, Guide, Schedules, Playouts, Settings, …),
  32 net-new (media/sources/system/auth). Retired Epg/ScheduleEditor/ScheduleLibrary.
- New screens.js = single source of truth for the inventory; Shell.jsx nav + app.html screen map
  both build from it (nav groups mirror web/src/app/routes.tsx). app.html is now hash-routed.
- All 42 views verified rendering headlessly across the 3 themes (126/126, 0 real errors).
- design-sync-reminder.sh hook (+ settings.json): mechanical, fail-open, once/session nudge to
  pull-first (PreToolUse Write|Edit on web/src/**.{tsx,css}) and mirror/push-back (Stop) — keeps
  the design system from drifting from prod going forward.
- docs/design-sync.md: document the bulk-sweep structure + the reminder hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:14:11 +02:00
timothy a5ac63dbd9 Merge pull request 'feat(69): Auto-Tune SPA wizard (Configure → Preview → Create) — PR2' (#389) from feat/69-auto-tune-spa into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m51s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m47s
2026-07-16 22:30:52 +00:00
timothyandClaude Opus 4.8 9741ce7d6a docs(queue): dependency-aware selection + milestones-as-bundles grooming conventions
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (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
Backlog grooming (2026-07-17): the kickoff cascade now excludes any issue with an
open blocked-by dependency (Gitea GET /issues/{n}/dependencies; auto-clears when the
blocker closes) so a blocked issue is never picked ahead of its blocker. Adds the
parked->closed convention and the bundle model: related work is grouped via active-arc
milestones or cross-reference comments and detected on pickup via milestone + backlinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:27:42 +02:00
timothy 7ef7ef25e8 Merge pull request 'feat(71): per-playout reshuffle + shuffle-state surfacing' (#387) from feat/71-reshuffle-playout into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (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
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Merge pull request '#387' (feat(71): per-playout reshuffle + shuffle-state surfacing) from feat/71-reshuffle-playout into main

fixes #71
2026-07-16 22:19:03 +00:00
timothyandClaude Opus 4.8 8e82673e6b fix(69): key Auto-Tune selection by axis+value; zero-templates UX (review)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 12m10s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Addresses the cold-review findings on PR #389:
- Medium: selection was keyed by proposal name, coupling two same-named
  proposals (e.g. a show titled "Comedy" and the "Comedy" genre) so toggling
  one flipped both. Now keyed by axis+value (unique); adds a regression test.
- Low: zero channel templates now shows a hint on Configure and the Create
  tooltip explains the missing template instead of a misleading positive label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:16:03 +02:00
timothyandClaude Opus 4.8 33ba3a0492 feat(69): Auto-Tune SPA wizard — Configure → Preview → Create (PR2)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m37s
New `/app/auto-tune` screen driving the PR1 endpoints: pick metadata axes
(TV Shows / TV Genres / Movie Genres) + defaults, preview proposed channels
grouped by axis with per-row/per-group selection and already-exists dedup,
then bulk-create with a Created/Skipped/Failed summary. Additive/non-destructive.

The per-channel DetailPanel from the design iteration is deferred to #383
(new endpoints backlogged) — this ships the 3-step wizard only.

- web/src/api/autoTune.ts (+test), web/src/screens/AutoTuneScreen.tsx (+test)
- route/nav (routes.tsx, ScreenContent.tsx), api barrel, App nav-smoke test
- docs: domain-model (route), blazor-route-parity (net-new SPA screen)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:04:42 +02:00
timothy 181eabdded Merge pull request 'test(163): golden characterization tests for playout building (Classic + Block)' (#382) from feat/163-playout-goldens into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m40s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m3s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-16 22:01:33 +00:00
timothyandClaude Opus 4.8 1131ecacfb test(163): apply review nits — instant-accurate Block TZ guard + determinism notes
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 40s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 7m33s
Follow-up to the cold adversarial review of PR #382 (MERGEABLE-WITH-NITS):
- Block TZ guard: BaseUtcOffset -> GetUtcOffset(Start). BaseUtcOffset is zero
  year-round for DST zones like Europe/London, so it would pass in a summer-dated
  fixture where London != UTC; GetUtcOffset pins the actual build instant and is
  correct regardless of fixture date. (Safe today — mid-Jan fixture — but removes
  the latent fixture-date dependency the reviewer flagged.)
- Document the determinism invariants the fixture relies on: golden captures raw
  builder output (pre-trim AddedItems), Classic is TZ-independent for the captured
  fields (no guard needed), and ResetPlayout's random Seed can't perturb the
  Chronological fixture (RandomStartPoint/ShuffleScheduleItems false, distinct
  release dates).

Re-verified: TZ=UTC both goldens pass; TZ=America/New_York Block skips, Classic
passes; no golden drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:47:54 +02:00
timothyandClaude Opus 4.8 81684411bd fix(71): reseed all playout kinds on reshuffle (erase-history + rebuild); docs + minors
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 4m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m24s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 6m58s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 11m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16m11s
Reset only reseeds Playout.Seed for Classic playouts (PlayoutBuilder); Block/
Sequential/Scripted rebuild deterministically from the existing seed, so
reshuffle was a silent no-op for 3 of the 4 supported kinds (C1). Fix:
ReshufflePlayoutHandler now sends ErasePlayoutHistory (reseed + clear anchors/
history, the only primitive that reseeds all four kinds) before enqueueing
BuildPlayout(Reset).

Also: correct docs/decisions.md's false "Reset already reseeds..." claim,
fix the SPA reshuffle test mock to return 202 (matches the real endpoint),
and gate the seed-help text to the resettable kinds (was showing even for
ExternalJson/None where no Reshuffle button exists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:47:18 +02:00
timothyandClaude Opus 4.8 11d3ba3823 design(69): sync Auto-Tune prototype + handoff (incl. deferred DetailPanel → #383)
Iterated Auto-Tune prototype pulled back from the Claude Design working copy
(project 4eb64b16) into design-system/. The 3-step wizard (Configure → Preview
→ Create) is PR2; the per-channel DetailPanel it also contains is deferred to
the #383 arc (#384/#385/#386) per design-first — new endpoints backlogged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:46:46 +02:00
timothyandClaude Opus 4.8 dc5d889ccb test(163): golden characterization tests for playout building (Classic + Block)
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 44s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m56s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Add ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs — a
golden-file net that snapshots the PlayoutItems each builder produces over a
pinned build window; the scheduling counterpart to the M3U (#11) / XMLTV (#28)
goldens. This is the regression net that de-risks the scheduling-refactor
chain (#380 -> #70/#71/#176).

Coverage this slice:
- Classic (PlaybackOrder.Chronological)
- Block (TimeZoneInfo.Local-guarded to UTC via Assume: runs in CI, skips
  gracefully under a non-UTC TZ; a real TZ seam is #380's scope)

Determinism: the builders read no wall clock (time enters only via the
caller-supplied start), so a pinned start is fully deterministic. Snapshots the
raw UTC Start/Finish, not the *Offset properties (which .ToLocalTime()). Regen
via ETV_UPDATE_PLAYOUT_GOLDENS (deliberately separate from ETV_UPDATE_GOLDENS).
Both goldens proven non-vacuous.

Sequential (YAML) + Scripted goldens tracked as a follow-up in #381.
Docs: docs/testing.md updated (third golden net + env var + Block TZ guard).

fixes #163

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:41:33 +02:00
timothyandClaude Opus 4.8 6a4d81f862 docs(71): reshuffle action + seed surfacing (parity, domain-model, decisions)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:28:04 +02:00
timothyandClaude Opus 4.8 13584f5daf feat(71): SPA display play-order seed + persistence help text
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:25:33 +02:00
timothyandClaude Opus 4.8 7a01ad62bd feat(71): SPA per-playout Reshuffle button + client fn
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:22:33 +02:00
timothyandClaude Opus 4.8 238937fa5b chore(71): regenerate OpenAPI + client for reshuffle endpoint + seed field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:19:46 +02:00
timothyandClaude Opus 4.8 e7072e49a8 feat(71): surface Playout.Seed on playout list + detail DTOs
Task 3 of ersatztv#71 (reshuffle-playout). Appends Seed as the last
positional field on PlayoutNameViewModel, threading it through the
Mapper list projection, GetPlayoutByIdHandler detail projection, and
the four Update*PlayoutHandler VM constructions, then exposing it on
PlayoutListItemResponseModel/PlayoutResponseModel via the controller's
ToResponse/ToListItemResponse mappers.

ChannelControllerTests.MakePlayout was an additional construction site
not listed in the task brief (positional-record break); updated to
append 0 for Seed alongside the brief's ErsatzTV.Tests changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:15:52 +02:00
timothyandClaude Opus 4.8 a9d9fc8afb feat(71): POST /api/v1/playouts/{id}/reshuffle action (202/404/409/422)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:08:48 +02:00
timothyandClaude Opus 4.8 89c7d08cde feat(71): ReshufflePlayout command + handler (enqueue per-playout Reset build)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:04:43 +02:00
timothyandClaude Opus 4.8 1f4341a347 docs(71): implementation plan for per-playout reshuffle + seed surfacing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:54:37 +02:00
timothyandClaude Opus 4.8 9a7ac28f9f docs(71): design spec for per-playout reshuffle + shuffle-state surfacing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:43:29 +02:00
timothy 331a427040 Merge pull request 'feat(69): auto-tuning backend — enumerate library metadata, preview + bulk-create channels (PR1)' (#379) from feat/69-auto-tuning into main
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m44s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
2026-07-16 20:35:10 +00:00
timothyandClaude Opus 4.8 630d78a804 docs(queue): note maintenance/backlog phase so sessions stop re-discovering arc completion
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
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
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
The go-live arc (#237) is closed; add a 'Current phase' section stating the
terminal transition to backlog mode + structural guidance (empty arc/gate/review
tiers are the expected steady state; fall through to the priority-label cascade).
Drift-proof: terminal fact + structure only, no named frontier issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:25:26 +02:00
timothy 938e4e0f50 chore(69): regenerate endpoint-index after rebase on origin/main
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 5m21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m7s
Union of #340 IPTV-settings endpoints + auto-tune endpoints.

Refs #69
2026-07-16 22:20:53 +02:00
timothy dbd4bb43f7 Merge pull request 'feat(analyzers): enable incremental latest-All enforcement' (#375) from feat/15-analyzer-promotion into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m51s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m15s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7m9s
2026-07-16 20:19:07 +00:00
timothy 39b07e178d fix(channels): swallow rollback delete exceptions in auto-tune handler
DeleteSmartCollection rollback in CreateAutoTunedChannelsHandler.CreateOne
was called without exception handling; a transient infra exception during
the best-effort rollback would propagate and abort the whole batch,
contradicting the comment's stated intent. Wrap the send in try/catch so
an orphaned SmartCollection is the accepted degraded outcome instead.
2026-07-16 22:18:52 +02:00
timothy f31476e012 fix(69): auto-tune review fixes — null channels, orphaned SmartCollection, oversized preview names, doc drift
- CreateAutoTunedChannelsRequest.ToCommand(): guard null Channels (was NREing on
  a request body that omits "channels", causing HTTP 500).
- CreateAutoTunedChannelsHandler.CreateOne: when CreateChannelFromLineup returns
  Left (Skipped/Failed), roll back the just-created SmartCollection via
  DeleteSmartCollection so retries don't fail on SmartCollection-name uniqueness.
  Best-effort; the delete result does not change the outcome.
- PreviewAutoTuneChannelsHandler: filter out proposals whose generated name
  exceeds the 50-char Channel.Name limit before number allocation, so numbers
  aren't wasted on proposals that can never be created.
- docs/superpowers/specs/2026-07-16-auto-tuning-design.md: fix field-name drift
  in JSON examples (proposedNumber -> number, error -> reason) to match the
  actual AutoTuneProposal/AutoTuneChannelOutcome DTOs.

Refs #69
2026-07-16 22:18:52 +02:00
timothy 0320735f47 docs(69): auto-tune OpenAPI regen + api/domain/decisions docs
Refs #69
2026-07-16 22:18:52 +02:00
timothyandClaude Opus 4.8 a2c056dd7a docs(release): prepare v26.9.0 promotion [decisions-edit]
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
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
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Consolidate docs/decisions.md (1923 -> 1028) by extracting four cohesive
topic clusters into docs/decisions/ (optimistic-concurrency,
api-auth-security, release-ci-governance, spa-modularization) — content
relocated verbatim (lossless; all rationale + reversals preserved), main
Index rebuilt to reference the topic files plus the remaining in-file
entries, docs/README.md points back at the decisions Index. Add the
v26.9.0 row to the ci-cd.md version table.

refs #340

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:17:23 +02:00
timothy e9e701f621 feat(69): auto-tune preview + bulk-create REST endpoints
Refs #69
2026-07-16 22:16:43 +02:00
timothy 4a7bd0b24c feat(69): auto-tune bulk-create orchestration handler
Refs #69
2026-07-16 22:16:43 +02:00
timothy 0d57cb6c0d feat(69): auto-tune preview query + EF enumeration handler
Refs #69
2026-07-16 22:16:43 +02:00
timothy 456c2c7c84 feat(69): auto-tune channel-number allocator
Refs #69
2026-07-16 22:16:43 +02:00
timothy 9605f9ea65 feat(69): auto-tune axis map (query/name/order/escape)
Refs #69
2026-07-16 22:16:43 +02:00
timothyandClaude Opus 4.8 2352604836 docs(69): PR1 backend implementation plan
Task-by-task TDD plan for the auto-tuning backend: axis map, number
allocator, preview query + EF enumeration, bulk-create orchestration,
REST endpoints, OpenAPI + docs.

Refs #69

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:16:43 +02:00
timothyandClaude Opus 4.8 35ad7df6a4 docs(69): auto-tuning 3-axis MVP design spec
Design for #69 auto-tuning: enumerate library metadata (TV Show / TV
Genre / Movie Genre) -> preview -> select -> bulk-create channels.
Additive/non-destructive, enumerate-via-EF + persist-via-SmartCollection,
server-side query generation, reserve-block numbering.

Refs #69

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:16:43 +02:00
timothy b17a70be1b Merge pull request 'fix(spa): body margin + nav box-sizing resets (#373, #377)' (#378) from fix/373-377-spa-css-resets into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m11s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-16 20:12:52 +00:00
timothyandClaude Opus 4.8 1575b9b537 fix(spa): add body margin + nav box-sizing resets (fixes #373, fixes #377)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m4s
Two missing-base-reset defects in the ChicoryTV SPA shell, both confirmed by
rendering the real app (not source-reading):

- #373: the browser-default `body { margin: 8px }` was never reset, so it framed
  every full-viewport layout — the app shell and the shell-less `.ctv-auth-page`
  boot pages (login/setup/checking/error), both `min-height:100vh` — with a light
  border on all four edges. Fix: `html, body { margin: 0 }` + paint the app surface
  on `body` so any residual gap/overscroll stays dark (matches the design-system
  reset in forms.card.html / chicorytv-admin templates). Verified body margin 0 and
  no edge border on the boot page and the shell, warm theme, 1280px and 900px.

- #377: the SPA ships no global `box-sizing` reset (default content-box), so
  `.ctv-nav-item { width:100%; padding:0 10px }` overflowed `.ctv-nav`
  (`overflow:auto`) by 20px → a horizontal scrollbar in the sidebar. Fix: scope
  `box-sizing: border-box` to `.ctv-nav-item`. Verified nav scrollWidth==clientWidth
  (216==216) and no horizontal scroll after the fix; the intended vertical nav scroll
  is unaffected.

Docs: spa-conventions.md §1 records the base reset and the deliberate absence of a
global box-sizing reset (set border-box locally when combining width:100% + padding).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:57:29 +02:00
timothyandCodex 10ae885b47 fix(analyzers): preserve non-CPM and image builds
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m52s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m40s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Guard analyzer package references for the inline-version MCP tool and copy the SDK suggestion baseline into the Docker build context before restore.

Refs #15

Co-Authored-By: Codex <codex@openai.com>
2026-07-16 21:52:27 +02:00
timothyandCodex 8f90cea5cc feat(analyzers): enable incremental latest-All enforcement
Centralize SDK and threading analyzers, baseline the .NET 10 All rule inventory at suggestion severity, and promote S3981 repo-wide. Fix the always-true worker count predicate and cover the idle/active branches.

Fixes #15

Co-Authored-By: Codex <codex@openai.com>
2026-07-16 21:52:27 +02:00
timothy 9938d9a6cd Merge pull request '#340: configurable advertised IPTV base URL for M3U/XMLTV' (#374)
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m3s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m34s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m44s
2026-07-16 19:51:27 +00:00
timothyandClaude Opus 4.8 dd273fbbb2 docs(202-e2e): record completed Plex real-server pin-flow validation
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m25s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Completes the outstanding Plex flow in the #202 post-merge integration
checklist against a disposable test Plex Media Server: pin-flow auth,
server/library discovery (the 2026-07-13 gap that returned servers:[]),
sync-preference persistence, and sign-out cleanup all passed. Records
exact API state evidence and the test-admin credential reset.

fixes #333

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:39:16 +02:00
timothyandClaude Opus 4.8 27d01db265 fix(iptv): XML-escape advertised base URL in XMLTV guide output (#340 review)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m30s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m43s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent review found that AdvertisedBaseUrl.TryParse accepts a path
prefix containing '&' (a legal URL-path char kept out of uri.Query), but
GetChannelGuideHandler substituted {RequestBase} raw into pre-built XML
written unescaped — so a configured base like https://host/a&b emitted a
bare '&', malforming the entire XMLTV guide (clients reject the document).

Escape the substituted base with SecurityElement.Escape before the raw
replace, mirroring how {AccessTokenUri} is already pre-escaped (&amp;).
No-op for normal URLs (goldens unchanged); M3U output is untouched (M3U
isn't XML). Adds a regression test asserting '&' → '&amp;' in the guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:08:42 +02:00
timothyandClaude Opus 4.8 3bc8192d3b feat(iptv): add configurable advertised base URL for M3U/XMLTV (fixes #340)
ErsatzTV built every absolute M3U/XMLTV URL from the incoming request's
Scheme/Host/PathBase, so a client fetching via a host that downstream
consumers can't resolve (e.g. Dispatcharr over Docker DNS → Kodi) baked
that internal host into programme-image/stream URLs.

Add an optional advertised IPTV base URL, backed by the existing
ConfigElement key/value store (key `iptv.base_url`, no EF migration):

- Central pure Core helper `AdvertisedBaseUrl` (TryParse/Resolve):
  validates absolute http(s), no credentials/query/fragment, preserves
  port + path prefix, normalizes trailing slash. Blank/invalid falls
  back to the request-derived values, so unset output is byte-identical.
- Resolved inside `GetChannelPlaylistHandler` (M3U guide/logo/stream) and
  `GetChannelGuideHandler` (both XMLTV {RequestBase} sites) — controllers
  stay thin, golden tests untouched.
- New `iptv` settings group: GET/PUT /api/v1/settings/iptv (blank clears,
  malformed → 422) + a new IPTV section on the SPA Settings screen.
- Scoped to M3U + XMLTV; HDHomeRun deliberately out of scope. Distinct
  from ETV_BASE_URL (which only sets ASP.NET PathBase).

Tests: AdvertisedBaseUrl unit tests (override/fallback/port/path/invalid),
handler override tests for both generators, settings controller + handler
tests, SPA client + screen tests. Docs: m3u-xmltv, decisions, domain-model,
regenerated OpenAPI v1.json + v1.d.ts + endpoint-index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:08:42 +02:00
timothy de16a7c066 Merge pull request 'chore(deps): update dependency jetbrains.resharper.globaltools to 2025.3.4.1' (#329) from renovate/jetbrains.resharper.globaltools-2025.x into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m16s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m58s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s
2026-07-16 18:47:04 +00:00
timothy be85a462a4 Merge pull request 'chore(deps): update dependency humanizer.core to 3.0.10' (#328) from renovate/humanizer-monorepo into main
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-16 18:38:12 +00:00
timothy 95d58f3b69 Merge branch 'main' into renovate/jetbrains.resharper.globaltools-2025.x
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-16 18:35:14 +00:00
timothy 3654fad163 Merge pull request 'ci(coverage): collect code coverage and publish a summary (refs #15)' (#370) from feat/15-ci-coverage into main
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m40s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m44s
2026-07-16 17:29:34 +00:00
timothy 9f0bb65fb4 docs(queue): exclude parked selector candidates (#372)
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 1m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Refs #237
2026-07-16 17:25:10 +00:00
timothy 72d47ef052 fix(api): protect local library path details (#371)
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (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
Fixes #334
2026-07-16 17:16:51 +00:00
timothyandClaude Opus 4.8 e78aa01c05 ci(coverage): exclude generated EF migrations to stop coverage OOM (exit 137)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m26s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m46s
The shared Build & test job OOM-killed (exit 137) with coverage enabled: the
whole-solution Architecture.Tests process instruments both EF migration folders
(~2.59M generated lines vs ~200k authored), ballooning coverlet's in-memory
hit-count arrays past the runner's limit.

Add coverlet.runsettings excluding **/Migrations/*.cs (+ generated/compiler
attributes) and pass it via --settings on the Test step. This cuts the
instrumented surface ~126x (2,510,185 -> 19,963 coverable lines in the
Architecture.Tests process, verified locally) and makes the reported percentage
reflect authored code. Documented in docs/ci-cd.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:03:54 +02:00
timothyandOpenAI Codex 0c6e8fab01 docs(queue): exclude parked selector candidates
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 7m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #237

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 18:58:44 +02:00
timothyandOpenAI Codex fe4706474e test(api): assert effective local library auth
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 2m53s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 56s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 7m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #334

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 18:54:57 +02:00
timothyandClaude Opus 4.8 a886fd2824 ci(coverage): make ReportGenerator install idempotent + pinned (review follow-up)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 7m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m38s
Cold-review should-fix on PR #370: `dotnet tool install` errors under set -e
if the tool is already present (retried step / cached runner image), which
would silently degrade the continue-on-error summary step to a no-op. Use
`dotnet tool update` (install-or-update) pinned to 5.5.10 for reproducible
output. Also order coverlet.collector after CliWrap to keep the ItemGroup
alphabetical (nit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:49:28 +02:00
timothyandOpenAI Codex 489956b167 fix(api): protect local library path details
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m30s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m28s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 55s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fixes #334

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 18:49:03 +02:00
timothyandClaude Opus 4.8 2090f7865c ci(coverage): collect code coverage and publish a summary (refs #15)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 22s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m29s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
#15 scope item 4 ("collect with coverlet and publish a summary; decide on
a floor later"). coverlet.collector was already referenced by four of the
six test projects but the CI Test step never passed --collect and nothing
summarized the result.

- Add coverlet.collector to ErsatzTV.Core.Tests and ErsatzTV.Tests so
  coverage is uniform across all *.Tests projects.
- CI Test step now runs --collect:"XPlat Code Coverage" --results-directory
  ./coverage, then a best-effort Coverage summary step merges the per-project
  Cobertura reports with ReportGenerator (TextSummary to the log,
  MarkdownSummaryGithub to the job step summary). No floor is enforced yet;
  continue-on-error keeps it purely informational.
- gitignore /coverage/; document the step in docs/ci-cd.md (test job).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:45:32 +02:00
timothy 5c1d2f63fa Merge pull request 'docs(queue): cascade selector through priority tiers' (#369) from codex/237-cascading-selector into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m13s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m29s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m16s
docs(queue): cascade selector through priority tiers (#369)
2026-07-16 14:14:01 +00:00
timothyandOpenAI Codex e8580f4a84 docs(queue): cascade selector through priority tiers
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #237

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 16:02:03 +02:00
timothy ca5ac94466 Merge pull request 'fix(api): report direct streams as on air' (#368) from codex/99-direct-onair into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m16s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m7s
fix(api): report direct streams as on air (#368)
2026-07-16 13:14:32 +00:00
timothyandOpenAI Codex d1a52eaa8b docs(api): use versioned channel routes
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m35s
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 15:03:53 +02:00
timothyandOpenAI Codex 7dee5194f4 fix(api): report direct streams as on air
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 22s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m2s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fixes #99

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 14:57:45 +02:00
timothy ee044ab271 Merge pull request 'fix(web): coherent, recoverable Plex authorized-but-no-servers state (#345)' (#366) from fix/345-plex-empty-servers into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m41s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
Merge PR #366: fix(web) coherent, recoverable Plex authorized-but-no-servers state (#345)

Separates Plex authorization (stored token) from connection (discovered servers)
so the authorized/unlocked/empty-servers state is coherent and recoverable
(offers Sign out, does not claim a connection). Gates the empty-servers
messaging on a released lock so it does not fire during discovery.

fixes #345
2026-07-16 12:36:52 +00:00
timothy 6532929cf2 fix(web): gate Plex authorized-no-servers messaging on released lock
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 24s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m56s
Adversarial review of the initial fix found that authorizedNoServers =
isAuthorized && !hasServers ignored isLocked. Because boot.state updates on
every poll observation, the "no eligible servers — sign out and retry"
subtitle + summary rendered during the normal `finalizing` window (locked,
authorized, no servers yet) alongside the "discovering your Plex servers…"
status — and during `budget-exhausted` (large first sync still holding the
lock, polling stopped, Sign out enabled) it urged the user to sign out
mid-sync. That reintroduced the contradictory-state class the PR fixes.

Gate the empty-servers presentation on !isLocked so it only appears at the
genuine released-lock terminal. Add regression coverage: the pin-flow test
now passes through the finalizing cell asserting the empty-servers messaging
is absent while locked, plus a budget-exhausted-while-locked case. Both new
guards were confirmed to fail on the ungated code (negative control).

Refs #345
2026-07-16 14:29:47 +02:00
timothy 0f0a0d7fe5 fix(web): coherent, recoverable Plex authorized-but-no-servers state
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m26s
A real Plex pin flow reached isAuthorized:true, isLocked:false, servers:[]
(a Plex account that owns/has been granted no eligible servers). The SPA
then showed contradictory state: the pin-flow status claimed "Connected to
Plex." while the Connection card said "Not signed in.", offered Sign in, and
hid Sign out — so the stored authorization could not be removed through the UI.

Root cause: PlexSourceScreen conflated authorization (a stored token,
isAuthorized) with connection (a discovered server, servers.length > 0) by
deriving everything from `connected = servers.length > 0`, and evaluatePinFlow
returned `success` ("Connected to Plex.") for any released-lock authorized
observation regardless of server count.

Fix (pure SPA; sign-out endpoint already works with empty servers):
- pinFlowPoll: add a distinct `authorized-no-servers` terminal status and a
  `hasServers` observation field; the released-lock branch now yields success
  only when a server was discovered, else authorized-no-servers with an honest
  message that distinguishes authorization from discovery.
- PlexSourceScreen: gate Sign in vs Sign out on `hasPlexAccount`
  (isAuthorized || hasServers) instead of the server count, so an authorized
  account can always sign out; add a subtitle + explanatory summary for the
  authorized/empty state; keep the Servers card gated on actual servers.
- Regression tests: the authorized/unlocked/empty transition (pin-flow and
  fresh-reload paths) and sign-out from that state.

fixes #345
2026-07-16 14:15:45 +02:00
timothy 572066157e Merge pull request '#365' from docs/handoff-selector-terminal into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m30s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m24s
docs: make queue selection non-terminal

Fixes #364
2026-07-16 12:13:05 +00:00
timothyandOpenAI Codex b39c3b7bb0 docs: make queue selection non-terminal
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m10s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 4m51s
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 14:00:16 +02:00
timothyandClaude Opus 4.8 0d7803079c ci: add advisory functional-E2E curl harness (fixes #299)
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m54s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m38s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 6m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m41s
Codify the manual live-E2E curl flows sessions have been re-running by hand
into a CI regression net: a new `functional-e2e` job boots the app from source
(via scripts/e2e-local.sh, parameterized for Release) and drives scripts/
e2e-functional.sh against it.

First-cut contracts (all curl-only, deterministic, no seeded media/ffmpeg/browser):
- legacy->SPA redirect sweep + the /api,/artwork never-redirect exemption
- auth/CSRF/security-stamp flow (setup-claim, read-gate, CSRF, login, logout+revoke)
- library-scan status contract (404/202/scan-status)
- optimistic-concurrency If-Match/412 round-trip

Advisory by design (separate job, not a `build` dependency, not a required
check) so a functional-E2E flake can't block the unit-test gate; promote once
proven, mirroring the migrations-job rollout. SQLite default -> no DB service.

Deferred to #299 follow-ups (need scanner+seeded media or a browser to be
deterministic): the racy 409 re-trigger, playout-build lock 409, Playwright UI
flows.

Assertions verified 30/30 green against a real Release-built instance; caught
/artwork/* returning 400 (not the 404 a static read suggested).

Docs updated same PR: docs/ci-cd.md (new job), docs/e2e-local.md (harness),
docs/decisions.md (append-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:49:33 +02:00
timothy 82467f188b Merge pull request '#361' from feat/247-shell-routing into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m37s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m45s
refactor(web): extract ChicoryTV shell and routing

Closes #247
2026-07-16 11:42:19 +00:00
timothyandOpenAI Codex 947c8aacd8 test(web): restore App composition smokes
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-16 13:30:44 +02:00
timothyandOpenAI Codex bd84593cd1 test(web): colocate screen behavior coverage (#247)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 20s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 26s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m50s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:51:35 +02:00
timothyandOpenAI Codex 805ca026c4 docs(web): define shell and action ownership
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:51:32 +02:00
timothyandOpenAI Codex 44f4db5a6d test(web): cover extracted shell contracts
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:51:23 +02:00
timothyandOpenAI Codex 30565145cd test(web): harden primary-action ownership regressions
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:37:36 +02:00
timothyandOpenAI Codex ce5aaa8706 refactor(web): extract app shell and routing
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:34:19 +02:00
timothyandOpenAI Codex 0416c00f96 refactor(web): replace primary-action events with context
Refs #247

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 22:33:16 +02:00
timothyandOpenAI Codex 3876809a83 chore(web): clean up App imports after screen extraction
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m53s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m45s
Refs #246

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 21:51:42 +02:00
timothyandOpenAI Codex e9fe12d1fd refactor(web): extract Guide screen
Move Guide rendering, timer and clipping helpers, and detailed behavior tests into a screen-owned module while retaining only shell composition coverage in App.

Refs #246

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 21:49:56 +02:00
timothyandOpenAI Codex bceffef856 refactor(web): extract Dashboard screen
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m19s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m57s
Move Dashboard rendering, health and on-air helpers, and detailed behavior tests into a screen-owned module while retaining only shell composition coverage in App.

Refs #246

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 21:16:40 +02:00
renovate 0f76860519 chore(deps): update dependency jetbrains.resharper.globaltools to 2025.3.4.1
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-13 03:02:30 +00:00
renovate 8e5075e419 chore(deps): update dependency humanizer.core to 3.0.10
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 37s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 35s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 48s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 40s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-13 03:01:42 +00:00
210 changed files with 24773 additions and 8743 deletions
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# design-sync-reminder — single hook, both directions (#388). Keeps the Claude Design project
# (`ChicoryTV Design System`, eb3b6122 / local mirror `design-system/`) in step with the shipped
# SPA. Trigger is PURELY MECHANICAL: "touching the UI" == a file matching UI_RE below. No prompt
# keyword guessing. Wired to two boundaries:
#
# start (PreToolUse / Write|Edit) — the FIRST time this session edits a UI file, remind to PULL
# the current design from Claude Design first.
# finish (Stop) — if the working tree actually changed a UI file, remind to
# MIRROR/PUSH the change back before wrapping up.
#
# UI_RE is the one place the "what counts as UI" fileset is defined: SPA .tsx/.css under web/src
# (test files excluded). Widen it here if the design surface grows.
#
# Fail-open: any parse trouble / non-match → emit nothing, exit 0. Throttled once per session per
# phase so it informs without nagging. DesignSync runs only from the main session (docs/design-sync.md).
# This is a reminder, never a hard gate — `start` only injects context; `finish` is a one-shot Stop nudge.
set -euo pipefail
UI_RE='(^|/)web/src/.*\.(tsx|css)$'
TEST_RE='\.test\.(tsx|ts)$'
phase="${1:-}"
input=$(cat)
me=$(printf '%s' "$input" | jq -r '.session_id // "nosess"' 2>/dev/null || true)
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
[ -z "$cwd" ] && cwd="$PWD"
marker="${TMPDIR:-/tmp}/ctv-designsync-${phase}-${me}"
case "$phase" in
start)
fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null || true)
[ -z "$fp" ] && exit 0
printf '%s' "$fp" | grep -qE "$TEST_RE" && exit 0 # skip test files
printf '%s' "$fp" | grep -qE "$UI_RE" || exit 0 # not a UI file → nothing
[ -f "$marker" ] && exit 0
: > "$marker" 2>/dev/null || true
read -r -d '' MSG <<'EOF' || true
[design-sync #388] About to edit a ChicoryTV SPA UI file. The `design-system/` prototypes mirror the Claude Design project (eb3b6122). If you're changing how a screen LOOKS, first PULL its current prototype from Claude Design so you start from the live design (docs/design-sync.md, pull = DesignSync list_files/get_file → design-system/, incremental). You'll be reminded to MIRROR the change back when the task finishes. DesignSync runs only from the main session.
EOF
jq -n --arg m "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$m}}'
exit 0
;;
finish)
# Did this turn actually change a UI file? (tracked diff vs HEAD + untracked, minus tests)
changed=$( { git -C "$cwd" diff --name-only HEAD 2>/dev/null; git -C "$cwd" ls-files --others --exclude-standard 2>/dev/null; } | grep -vE "$TEST_RE" | grep -E "$UI_RE" || true )
[ -z "$changed" ] && exit 0
[ -f "$marker" ] && exit 0
: > "$marker" 2>/dev/null || true
n=$(printf '%s\n' "$changed" | sed '/^$/d' | wc -l | tr -d ' ')
reason="[design-sync #388] This task changed ${n} SPA UI file(s) under web/src. Before wrapping up, MIRROR the visual change into the matching design-system/templates/chicorytv-admin/*.jsx prototype and push it to Claude Design (eb3b6122) in this same session, per docs/design-sync.md — so the design system does not drift from prod. If you already synced, or are deliberately deferring the mirror (say why), just note it and stop. DesignSync runs only from the main session. This one-shot reminder won't fire again this session."
jq -n --arg r "$reason" '{decision:"block",reason:$r}'
exit 0
;;
*)
exit 0
;;
esac
+21
View File
@@ -46,6 +46,16 @@
"timeout": 15
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" start",
"timeout": 10
}
]
}
],
"PostToolUse": [
@@ -59,6 +69,17 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" finish",
"timeout": 10
}
]
}
]
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2025.3.0.2",
"version": "2025.3.4.1",
"commands": [
"jb"
],
+9 -5
View File
@@ -106,13 +106,17 @@ ij_json_wrap_long_lines = false
dotnet_diagnostic.ca1848.severity = none
# --- Static-analysis pack adoption (ersatztv#15) ---
# Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are referenced centrally
# (Directory.Build.targets). Default every analyzer diagnostic to `suggestion` so the new
# packs don't fail the TreatWarningsAsErrors build; high-value rules get promoted to
# warning/error one at a time (see ersatztv#15 / docs/contributing.md). Explicit per-rule
# severities (e.g. ca1848 above) still take precedence over this bulk default.
# Threading analyzers and Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are enabled centrally.
# Default their diagnostics to `suggestion`; the SDK's exact per-rule suggestion baseline lives in
# eng/analyzers/sdk-all-suggestion.globalconfig because AnalysisLevel=latest-All otherwise injects
# exact warning severities that outrank this bulk setting. High-value rules are promoted one at a
# time. Explicit per-rule severities (e.g. ca1848 above) take precedence over both baselines.
dotnet_analyzer_diagnostic.severity = suggestion
# A collection count can never be negative. Treat comparisons that therefore collapse to a
# constant as errors; the first promotion caught a busy/idle branch that was permanently busy.
dotnet_diagnostic.S3981.severity = warning
# Blazor components: analyzers run on .razor/.cshtml @code too, and TWAE would otherwise
# turn their default-severity findings into build errors — keep them at suggestion as well.
[*.razor]
+100 -1
View File
@@ -101,7 +101,40 @@ jobs:
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
run: >-
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
# per test project (via --collect above); ReportGenerator merges them into a human-readable
# summary printed to the log and the job step summary. No floor is enforced yet ("decide on a
# floor later"), so this step is purely informational — continue-on-error keeps a missing
# report or a transient tool-install failure from ever blocking a build.
- name: Coverage summary
continue-on-error: true
run: |
set -euo pipefail
shopt -s globstar nullglob
reports=(coverage/**/coverage.cobertura.xml)
if [ ${#reports[@]} -eq 0 ]; then
echo "No coverage reports found under ./coverage -- skipping summary."
exit 0
fi
echo "Found ${#reports[@]} coverage report(s)."
# `update` is install-or-update (idempotent, unlike `install` which errors if the tool
# is already present under set -e); pinned for reproducible summary output.
dotnet tool update --global dotnet-reportgenerator-globaltool --version 5.5.10 >/dev/null
export PATH="$PATH:$HOME/.dotnet/tools"
reportgenerator \
"-reports:coverage/**/coverage.cobertura.xml" \
"-targetdir:coverage/report" \
"-reporttypes:TextSummary;MarkdownSummaryGithub"
echo "::group::Coverage summary"
cat coverage/report/Summary.txt
echo "::endgroup::"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
fi
migrations:
name: EF migration integrity (SQLite + MySql)
@@ -202,6 +235,72 @@ jobs:
done
echo "::endgroup::"
functional-e2e:
name: Functional E2E (curl contracts)
runs-on: ubuntu-latest
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
# the unit-test gate — promote it to a required check / build dependency once it's proven
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
# Runs on PRs and on main (regression net); skipped for v* tag builds.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
run: dotnet restore
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install SPA dependencies
working-directory: web
run: npm ci
- name: Build SPA
working-directory: web
run: npm run build
- name: Build (Release)
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
- name: Ensure ffmpeg is available
run: command -v ffmpeg >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y ffmpeg)
- name: Boot instance and run functional-E2E harness
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
CFG="$(mktemp -d)"
# e2e-local.sh copies wwwroot, launches the DLL in the background (logging to a file, so
# this command substitution returns as soon as the app is ready), and prints PID/CONFIG_DIR.
OUT="$(scripts/e2e-local.sh "$CFG")"
printf '%s\n' "$OUT"
PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')"
trap 'kill "$PID" 2>/dev/null || true' EXIT
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
build:
name: Build & push image (amd64)
# `small` = the dedicated small-jobs runner lane (server-management#574).
+2
View File
@@ -2,6 +2,8 @@
*.*~
project.lock.json
.DS_Store
# Code-coverage output (dotnet test --results-directory ./coverage, ersatztv#15)
/coverage/
*.pyc
.worktrees/
+13 -2
View File
@@ -3,6 +3,12 @@
<InformationalVersion>develop</InformationalVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
<!-- Analyzer posture (ersatztv#15): enable the complete SDK rule set and the
threading analyzer in every centrally managed project. The checked-in globalconfig
keeps the SDK baseline at suggestion; individually promoted rules become CI-blocking. -->
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest-All</AnalysisLevel>
<EnableThreadingAnalyzers>true</EnableThreadingAnalyzers>
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
packages as NU1901-1904 warnings. Several projects set
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
@@ -10,8 +16,13 @@
advisories to warnings (still printed in build logs); NU1904 (critical)
stays an error so criticals still block. Track fixes separately.
WarningsAsErrors promotes NU1904 in EVERY project (even those without
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide.
S3981 is the first explicitly promoted analyzer rule (ersatztv#15). -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
<WarningsAsErrors>$(WarningsAsErrors);NU1904;S3981</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<EditorConfigFiles Include="$(MSBuildThisFileDirectory)eng/analyzers/sdk-all-suggestion.globalconfig" />
</ItemGroup>
</Project>
+8 -10
View File
@@ -1,9 +1,7 @@
<Project>
<PropertyGroup>
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
</PropertyGroup>
<ItemGroup>
<!-- Guard on CPM so the gitignored .mcp tool, which deliberately uses inline package
versions, does not inherit a versionless analyzer PackageReference. -->
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
<PackageReference
Include="Microsoft.VisualStudio.Threading.Analyzers"
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
@@ -12,11 +10,11 @@
</PackageReference>
</ItemGroup>
<!-- Curated static-analysis packs (ersatztv#15), applied to every project. Versions are
central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored .mcp tool
(which opts out of CPM) doesn't pull versionless references. They start at `suggestion`
severity in .editorconfig so they don't fail the TreatWarningsAsErrors build; high-value
rules are promoted to warning/error incrementally. -->
<!-- Curated static-analysis packs (ersatztv#15), applied to every centrally managed project.
Versions are central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored
.mcp tool (which opts out of CPM) doesn't pull versionless references. They start at
`suggestion` severity in .editorconfig so they don't fail the TreatWarningsAsErrors build;
high-value rules are promoted to warning/error incrementally. -->
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
<PackageReference Include="Roslynator.Analyzers">
<PrivateAssets>all</PrivateAssets>
+1 -1
View File
@@ -19,7 +19,7 @@
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageVersion Include="Flurl" Version="4.0.0" />
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
<PackageVersion Include="Humanizer.Core" Version="3.0.10" />
<PackageVersion Include="Jint" Version="4.5.0" />
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
@@ -0,0 +1,44 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
public static class AutoTuneAxisMap
{
// Server-owned Lucene smart-collection query for an axis value.
public static string GenerateQuery(AutoTuneAxis axis, string value)
{
string escaped = EscapeLuceneValue(value);
return axis switch
{
AutoTuneAxis.TvShow => $"type:episode AND show_title:\"{escaped}\"",
AutoTuneAxis.TvGenre => $"type:episode AND genre:\"{escaped}\"",
AutoTuneAxis.MovieGenre => $"type:movie AND genre:\"{escaped}\"",
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
}
// Human-facing channel name. Movie-genre channels are suffixed so a genre that exists for
// both TV and movies ("Comedy" vs "Comedy Movies") does not produce two identically-named channels.
public static string GenerateName(AutoTuneAxis axis, string value) =>
axis switch
{
AutoTuneAxis.TvShow => value,
AutoTuneAxis.TvGenre => value,
AutoTuneAxis.MovieGenre => $"{value} Movies",
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// PseudoTV per-type defaults: single-show channels play in episode order; genre channels shuffle.
public static PlaybackOrder PlaybackOrderFor(AutoTuneAxis axis) =>
axis switch
{
AutoTuneAxis.TvShow => PlaybackOrder.SeasonEpisode,
AutoTuneAxis.TvGenre => PlaybackOrder.Shuffle,
AutoTuneAxis.MovieGenre => PlaybackOrder.Shuffle,
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
public static string EscapeLuceneValue(string value) =>
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
}
@@ -0,0 +1,27 @@
using System.Globalization;
namespace ErsatzTV.Application.Channels;
public static class AutoTuneNumberAllocator
{
// Allocate `count` sequential integer channel numbers starting at `startingNumber`,
// skipping any number already present in `existingNumbers`. Channel.Number is a string,
// so numbers are returned as invariant-culture strings.
public static List<string> Allocate(int startingNumber, int count, ISet<string> existingNumbers)
{
var result = new List<string>(count);
int next = startingNumber;
while (result.Count < count)
{
string candidate = next.ToString(CultureInfo.InvariantCulture);
if (!existingNumbers.Contains(candidate))
{
result.Add(candidate);
}
next++;
}
return result;
}
}
@@ -0,0 +1,35 @@
using ErsatzTV.Core.Domain;
using MediatR;
namespace ErsatzTV.Application.Channels;
public record CreateAutoTunedChannels(
int TemplateId,
string Group,
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
public record AutoTuneChannelSelection(
AutoTuneAxis Axis,
string Value,
string Name,
string Number);
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
{
public int CreatedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Created);
public int SkippedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Skipped);
public int FailedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Failed);
}
public record AutoTuneChannelOutcome(
string Name,
AutoTuneOutcomeStatus Status,
int? ChannelId,
string Reason);
public enum AutoTuneOutcomeStatus
{
Created,
Skipped,
Failed
}
@@ -0,0 +1,116 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels;
public class CreateAutoTunedChannelsHandler(ISender mediator)
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
{
private const string NumberTakenError = "Channel number must be unique";
private const string DefaultGroup = "Auto-Tuned";
public async Task<AutoTuneResult> Handle(
CreateAutoTunedChannels request,
CancellationToken cancellationToken)
{
string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim();
var outcomes = new List<AutoTuneChannelOutcome>();
foreach (AutoTuneChannelSelection selection in request.Channels ?? [])
{
outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken));
}
return new AutoTuneResult(outcomes);
}
private async Task<AutoTuneChannelOutcome> CreateOne(
int templateId,
string group,
AutoTuneChannelSelection selection,
CancellationToken cancellationToken)
{
string name = (selection.Name ?? string.Empty).Trim();
if (name.Length is 0 or > 50)
{
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name");
}
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
// 1. Create the smart collection that drives this channel.
Either<BaseError, SmartCollectionViewModel> scResult =
await mediator.Send(new CreateSmartCollection(query, name), cancellationToken);
SmartCollectionViewModel smartCollection = null;
foreach (BaseError error in scResult.LeftToSeq())
{
return new AutoTuneChannelOutcome(
name, AutoTuneOutcomeStatus.Failed, null, $"Smart collection: {error.Value}");
}
foreach (SmartCollectionViewModel vm in scResult.RightToSeq())
{
smartCollection = vm;
}
// 2. Create the channel from a single-item lineup referencing the smart collection.
var command = new CreateChannelFromLineup(
name,
selection.Number,
group,
string.Empty,
ArtworkContentTypeModel.None,
IsEnabled: true,
ShowInEpg: true,
templateId,
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
[
new CreateChannelFromLineupItem(
LibraryBrowseMediaType.SmartCollection,
CollectionType.SmartCollection,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: smartCollection.Id,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null)
]);
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
await mediator.Send(command, cancellationToken);
foreach (BaseError error in channelResult.LeftToSeq())
{
// Roll back the smart collection we just created so a retry of this
// axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort;
// the primary outcome below is still Skipped/Failed regardless of the delete result.
// Swallow any exception (not just an Either.Left) so a transient infra failure
// during rollback never aborts this channel's outcome or the batch; the
// orphaned SmartCollection is an acceptable degraded outcome.
try
{
await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken);
}
catch (Exception)
{
// intentionally ignored; see comment above
}
AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal)
? AutoTuneOutcomeStatus.Skipped
: AutoTuneOutcomeStatus.Failed;
return new AutoTuneChannelOutcome(name, status, null, error.Value);
}
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
}
}
@@ -0,0 +1,19 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels;
public record PreviewAutoTuneChannels(
List<AutoTuneAxis> Axes,
int MinItems,
int StartingNumber) : IRequest<Either<BaseError, List<AutoTuneProposal>>>;
public record AutoTuneProposal(
AutoTuneAxis Axis,
string Value,
string Name,
string Number,
int ItemCount,
bool AlreadyExists);
@@ -0,0 +1,144 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class PreviewAutoTuneChannelsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<PreviewAutoTuneChannels, Either<BaseError, List<AutoTuneProposal>>>
{
public async Task<Either<BaseError, List<AutoTuneProposal>>> Handle(
PreviewAutoTuneChannels request,
CancellationToken cancellationToken)
{
if (request.Axes is null || request.Axes.Count == 0)
{
return BaseError.New("At least one axis is required");
}
if (request.MinItems < 1)
{
return BaseError.New("Minimum items must be at least 1");
}
if (request.StartingNumber < 1)
{
return BaseError.New("Starting channel number must be at least 1");
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// Enumerate (axis, value, count) triples per requested axis, preserving axis order.
var raw = new List<(AutoTuneAxis Axis, string Value, int Count)>();
foreach (AutoTuneAxis axis in request.Axes.Distinct())
{
raw.AddRange(await EnumerateAxis(dbContext, axis, request.MinItems, cancellationToken));
}
System.Collections.Generic.HashSet<string> existingNumbers = (await dbContext.Channels.AsNoTracking()
.Select(c => c.Number).ToListAsync(cancellationToken))
.ToHashSet();
System.Collections.Generic.HashSet<string> existingNames = (await dbContext.Channels.AsNoTracking()
.Select(c => c.Name).ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Drop entries whose generated name would be rejected at create time (Channel name <= 50
// chars) before number allocation, so numbers aren't wasted on proposals that can never
// be created.
List<(AutoTuneAxis Axis, string Value, int Count, string Name)> survivors = raw
.Select(r => (r.Axis, r.Value, r.Count, Name: AutoTuneAxisMap.GenerateName(r.Axis, r.Value)))
.Where(r => r.Name.Length <= 50)
.ToList();
List<string> numbers = AutoTuneNumberAllocator.Allocate(
request.StartingNumber, survivors.Count, existingNumbers);
var proposals = new List<AutoTuneProposal>(survivors.Count);
for (int i = 0; i < survivors.Count; i++)
{
(AutoTuneAxis axis, string value, int count, string name) = survivors[i];
proposals.Add(new AutoTuneProposal(
axis, value, name, numbers[i], count, existingNames.Contains(name)));
}
return proposals;
}
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateAxis(
TvContext dbContext, AutoTuneAxis axis, int minItems, CancellationToken cancellationToken) =>
axis switch
{
AutoTuneAxis.TvShow => await EnumerateTvShows(dbContext, minItems, cancellationToken),
AutoTuneAxis.TvGenre => await EnumerateEpisodeGenres(dbContext, minItems, cancellationToken),
AutoTuneAxis.MovieGenre => await EnumerateMovieGenres(dbContext, minItems, cancellationToken),
_ => []
};
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateTvShows(
TvContext dbContext, int minItems, CancellationToken cancellationToken)
{
// Episode count per show id (Episode -> Season -> ShowId). Proven query style from LibraryBrowseItemMapper.
Dictionary<int, int> episodeCounts = await dbContext.Episodes.AsNoTracking()
.GroupBy(e => e.Season.ShowId)
.Select(g => new { ShowId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
var showTitles = await dbContext.ShowMetadata.AsNoTracking()
.Select(sm => new { sm.ShowId, sm.Title })
.ToListAsync(cancellationToken);
// Collapse shows that share a title (the generated show_title query matches them together).
var byTitle = new Dictionary<string, int>();
foreach (var row in showTitles)
{
if (string.IsNullOrWhiteSpace(row.Title))
{
continue;
}
episodeCounts.TryGetValue(row.ShowId, out int count);
byTitle[row.Title] = byTitle.GetValueOrDefault(row.Title) + count;
}
return byTitle
.Where(kv => kv.Value >= minItems)
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv => (AutoTuneAxis.TvShow, kv.Key, kv.Value))
.ToList();
}
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateEpisodeGenres(
TvContext dbContext, int minItems, CancellationToken cancellationToken)
{
var counts = await dbContext.EpisodeMetadata.AsNoTracking()
.SelectMany(m => m.Genres)
.GroupBy(g => g.Name)
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
.ToListAsync(cancellationToken);
return counts
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(c => (AutoTuneAxis.TvGenre, c.Name, c.Count))
.ToList();
}
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateMovieGenres(
TvContext dbContext, int minItems, CancellationToken cancellationToken)
{
var counts = await dbContext.MovieMetadata.AsNoTracking()
.SelectMany(m => m.Genres)
.GroupBy(g => g.Name)
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
.ToListAsync(cancellationToken);
return counts
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(c => (AutoTuneAxis.MovieGenre, c.Name, c.Count))
.ToList();
}
}
@@ -119,7 +119,6 @@ public class GetChannelGuideDataHandler(
responseChannels.Add(
new ChannelGuideChannelResponseModel(
channel.Id,
channel.Number,
channel.Name,
programmes.OrderBy(p => p.Start).ToList()));
@@ -1,9 +1,12 @@
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Security;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -15,7 +18,8 @@ public partial class GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
public async Task<Either<BaseError, ChannelGuide>> Handle(
@@ -23,6 +27,21 @@ public partial class GetChannelGuideHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<string> maybeBaseUrl =
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
maybeBaseUrl.IfNone(string.Empty),
request.Scheme,
request.Host,
request.BaseUrl);
// The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already
// emitted as &amp;), so the substituted base must be XML-escaped. A path prefix can legally
// contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and
// malform the whole guide. Normal URLs have no special chars, so this is a no-op for them.
string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}");
var hiddenChannelNumbers = dbContext.Channels
.Where(c => c.ShowInEpg == false)
.Select(c => c.Number)
@@ -48,7 +67,7 @@ public partial class GetChannelGuideHandler(
// TODO: is regex faster?
channelsFragment = channelsFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{RequestBase}", requestBase)
.Replace("{AccessTokenUri}", accessTokenUri);
var channelDataFragments = new Dictionary<string, string>();
@@ -70,7 +89,7 @@ public partial class GetChannelGuideHandler(
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{RequestBase}", requestBase)
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
@@ -1,6 +0,0 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelPlaybackSource(int ChannelId, DateTimeOffset At)
: IRequest<Option<ChannelPlaybackSourceResponseModel>>;
@@ -1,171 +0,0 @@
#nullable enable
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Resolves the physical playout item at a point in time without invoking the streaming or FFmpeg pipeline.
/// Guide projection is deliberately not used here: guide entries may merge filler or split a block differently
/// from the actual media-item boundaries a player must follow.
/// </summary>
public class GetChannelPlaybackSourceHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelPlaybackSource, Option<ChannelPlaybackSourceResponseModel>>
{
public async Task<Option<ChannelPlaybackSourceResponseModel>> Handle(
GetChannelPlaybackSource request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Channel? channel = await dbContext.Channels
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Id == request.ChannelId, cancellationToken);
if (channel is null)
{
return None;
}
// Deleting a mirror's source sets this nullable FK to null. Do not self-resolve to a stale
// playout that may remain attached to the mirror channel.
if (channel.PlayoutSource == ChannelPlayoutSource.Mirror && channel.MirrorSourceChannelId is null)
{
return None;
}
int sourceChannelId = channel.PlayoutSource == ChannelPlayoutSource.Mirror
? channel.MirrorSourceChannelId!.Value
: channel.Id;
TimeSpan playoutOffset = channel.PlayoutSource == ChannelPlayoutSource.Mirror
? channel.PlayoutOffset ?? TimeSpan.Zero
: TimeSpan.Zero;
DateTime sourceAtUtc = request.At.UtcDateTime - playoutOffset;
PlayoutItem? active = await ActiveItems(dbContext, sourceChannelId, sourceAtUtc)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync(cancellationToken);
DateTime? nextSourceTransition = active?.Finish;
if (nextSourceTransition is null)
{
nextSourceTransition = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Playout.ChannelId == sourceChannelId && pi.Start > sourceAtUtc)
.OrderBy(pi => pi.Start)
.Select(pi => (DateTime?)pi.Start)
.FirstOrDefaultAsync(cancellationToken);
}
DateTimeOffset resolvedAt = request.At.ToUniversalTime();
DateTimeOffset sourceAt = new(sourceAtUtc, TimeSpan.Zero);
DateTimeOffset? nextTransitionAt = nextSourceTransition.HasValue
? new DateTimeOffset(nextSourceTransition.Value + playoutOffset, TimeSpan.Zero)
: null;
ChannelPlaybackItemResponseModel? playbackItem = active is null
? null
: ToPlaybackItem(active, sourceAtUtc, playoutOffset);
return new ChannelPlaybackSourceResponseModel(
channel.Id,
sourceChannelId,
resolvedAt,
sourceAt,
nextTransitionAt,
playbackItem);
}
private static IQueryable<PlayoutItem> ActiveItems(TvContext dbContext, int sourceChannelId, DateTime sourceAtUtc) =>
dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Playout.ChannelId == sourceChannelId)
.Where(pi => pi.Start <= sourceAtUtc && pi.Finish > sourceAtUtc)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.AsSplitQuery();
private static ChannelPlaybackItemResponseModel ToPlaybackItem(
PlayoutItem item,
DateTime sourceAtUtc,
TimeSpan playoutOffset)
{
TimeSpan currentOffset = item.InPoint + (sourceAtUtc - item.Start);
if (currentOffset < item.InPoint)
{
currentOffset = item.InPoint;
}
if (item.OutPoint > item.InPoint && currentOffset > item.OutPoint)
{
currentOffset = item.OutPoint;
}
return new ChannelPlaybackItemResponseModel(
item.Id,
item.MediaItemId,
new DateTimeOffset(item.Start + playoutOffset, TimeSpan.Zero),
new DateTimeOffset(item.Finish + playoutOffset, TimeSpan.Zero),
item.InPoint.Ticks,
currentOffset.Ticks,
item.OutPoint.Ticks,
item.FillerKind,
GetSourceReference(item.MediaItem));
}
private static ChannelPlaybackSourceReferenceResponseModel GetSourceReference(MediaItem mediaItem) =>
mediaItem switch
{
JellyfinMovie movie => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: movie.ItemId),
JellyfinEpisode episode => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: episode.ItemId),
PlexMovie movie => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: movie.Key),
PlexEpisode episode => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: episode.Key),
PlexOtherVideo video => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: video.Key),
EmbyMovie movie => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: movie.ItemId),
EmbyEpisode episode => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: episode.ItemId),
RemoteStream stream => Reference(ChannelPlaybackSourceKind.RemoteUrl, isLive: stream.IsLive),
Movie movie => LocalFile(movie.MediaVersions),
Episode episode => LocalFile(episode.MediaVersions),
MusicVideo video => LocalFile(video.MediaVersions),
OtherVideo video => LocalFile(video.MediaVersions),
Song song => LocalFile(song.MediaVersions),
Image image => LocalFile(image.MediaVersions),
_ => Reference(ChannelPlaybackSourceKind.Unsupported)
};
private static ChannelPlaybackSourceReferenceResponseModel LocalFile(IEnumerable<MediaVersion> versions)
{
string? path = versions.FirstOrDefault()?.MediaFiles.FirstOrDefault()?.Path;
return string.IsNullOrWhiteSpace(path)
? Reference(ChannelPlaybackSourceKind.Unsupported)
: Reference(ChannelPlaybackSourceKind.LocalFile, path: path);
}
private static ChannelPlaybackSourceReferenceResponseModel Reference(
ChannelPlaybackSourceKind kind,
string? itemId = null,
string? path = null,
bool isLive = false) =>
new(kind, itemId, path, isLive);
}
@@ -4,19 +4,31 @@ using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
public class GetChannelPlaylistHandler(
IChannelRepository channelRepository,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
{
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
channelRepository.GetAll(cancellationToken)
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(
request.Scheme,
request.Host,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
public async Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken)
{
Option<string> maybeBaseUrl =
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
maybeBaseUrl.IfNone(string.Empty),
request.Scheme,
request.Host,
request.BaseUrl);
List<Channel> channels = EnsureMode(await channelRepository.GetAll(cancellationToken), request.Mode);
return new ChannelPlaylist(
scheme,
host,
baseUrl,
channels,
request.UserAgent,
request.AccessToken);
}
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
@@ -2,6 +2,7 @@ using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
@@ -10,7 +11,8 @@ namespace ErsatzTV.Application.Channels;
public class GetChannelStatesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegSegmenterService ffmpegSegmenterService)
IFFmpegSegmenterService ffmpegSegmenterService,
IDirectStreamSessionTracker directStreamSessionTracker)
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
{
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
@@ -141,7 +143,8 @@ public class GetChannelStatesForApiHandler(
return new ChannelStateResponseModel(
channel.Id,
channel.Number,
ffmpegSegmenterService.IsActive(channel.Number),
ffmpegSegmenterService.IsActive(channel.Number) ||
directStreamSessionTracker.IsActive(channel.Number),
nowPlaying);
})
.ToList();
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateIptvSettings(IptvSettingsViewModel IptvSettings) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,51 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Configuration;
public class UpdateIptvSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateIptvSettings, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
UpdateIptvSettings request,
CancellationToken cancellationToken)
{
Validation<BaseError, Unit> validation = Validate(request);
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(request.IptvSettings, cancellationToken));
}
private async Task<Unit> ApplyUpdate(IptvSettingsViewModel iptvSettings, CancellationToken cancellationToken)
{
string baseUrl = (iptvSettings.BaseUrl ?? string.Empty).Trim();
// A blank value clears the setting so the request-derived behavior is restored.
if (string.IsNullOrWhiteSpace(baseUrl))
{
await configElementRepository.Delete(ConfigElementKey.IptvBaseUrl, cancellationToken);
}
else
{
await configElementRepository.Upsert(ConfigElementKey.IptvBaseUrl, baseUrl, cancellationToken);
}
return Unit.Default;
}
private static Validation<BaseError, Unit> Validate(UpdateIptvSettings request)
{
string baseUrl = request.IptvSettings.BaseUrl;
// Blank is valid (clears the override); a non-blank value must be a well-formed advertised base URL.
if (string.IsNullOrWhiteSpace(baseUrl))
{
return Unit.Default;
}
return AdvertisedBaseUrl.TryParse(baseUrl)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(
"Advertised base URL must be an absolute http(s) URL with no credentials, query, or fragment");
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application.Configuration;
public class IptvSettingsViewModel
{
public string BaseUrl { get; set; }
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetIptvSettings : IRequest<IptvSettingsViewModel>;
@@ -0,0 +1,19 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetIptvSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetIptvSettings, IptvSettingsViewModel>
{
public async Task<IptvSettingsViewModel> Handle(GetIptvSettings request, CancellationToken cancellationToken)
{
Option<string> maybeBaseUrl =
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
return new IptvSettingsViewModel
{
BaseUrl = await maybeBaseUrl.IfNoneAsync(string.Empty)
};
}
}
@@ -4,7 +4,6 @@
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Configurations>Debug;Release;Debug No Sync</Configurations>
</PropertyGroup>
@@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
return Task.CompletedTask;
}
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count >= 0 || FFmpegProcess.ProcessCount > 0;
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count > 0 || FFmpegProcess.ProcessCount > 0;
if (request.ForceAggressive || !hasActiveWorkers)
{
_logger.LogDebug("Starting aggressive garbage collection");
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record ReshufflePlayout(int PlayoutId) : IRequest;
@@ -0,0 +1,40 @@
using System.Threading.Channels;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class ReshufflePlayoutHandler(
IMediator mediator,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ReshufflePlayout>
{
public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> maybePlayout = await dbContext.Playouts
.AsNoTracking()
.Where(p => p.ScheduleKind == PlayoutScheduleKind.Classic ||
p.ScheduleKind == PlayoutScheduleKind.Block ||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
foreach (Playout playout in maybePlayout)
{
// Roll a new play order. BuildPlayout(Reset) only reseeds Playout.Seed for CLASSIC playouts
// (PlayoutBuilder); Block/Sequential/Scripted rebuild deterministically from the existing seed.
// ErasePlayoutHistory is the one primitive that reseeds + clears the derived per-collection
// enumerator anchors for ALL four kinds — run it first, then rebuild from scratch.
await mediator.Send(new ErasePlayoutHistory(playout.Id), cancellationToken);
await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
}
}
}
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -65,7 +65,8 @@ public class
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Version,
playout.Seed);
}
private static Task<Validation<BaseError, Playout>> Validate(
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -57,7 +57,8 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Version,
playout.Seed);
}
private static Task<Validation<BaseError, Playout>> Validate(
@@ -1,4 +1,4 @@
using System.CommandLine.Parsing;
using System.CommandLine.Parsing;
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
@@ -60,7 +60,8 @@ public class
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Version,
playout.Seed);
}
private async Task<Validation<BaseError, Playout>> Validate(
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -65,7 +65,8 @@ public class
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Version,
playout.Seed);
}
private static Task<Validation<BaseError, Playout>> Validate(
+3 -2
View File
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Application.Playouts;
@@ -20,7 +20,8 @@ internal static class Mapper
// the paged-playouts query does not eager-load Deco (the list response does not surface
// the default deco); GetPlayoutById includes it for the detail response
playout.Deco?.Name,
playout.Version);
playout.Version,
playout.Seed);
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts;
@@ -14,7 +14,8 @@ public record PlayoutNameViewModel(
PlayoutBuildStatus BuildStatus,
int? DecoId,
string DecoName,
int Version)
int Version,
int Seed)
{
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
@@ -1,4 +1,4 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -31,6 +31,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
p.BuildStatus,
p.DecoId,
p.DecoId == null ? null : p.Deco.Name,
p.Version));
p.Version,
p.Seed));
}
}
@@ -8,6 +8,10 @@
<ItemGroup>
<PackageReference Include="CliWrap" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="LanguageExt.Core" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
@@ -0,0 +1,127 @@
using ErsatzTV.Core.Iptv;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Iptv;
[TestFixture]
public class AdvertisedBaseUrlTests
{
private const string RequestScheme = "http";
private const string RequestHost = "ersatztv:8409";
private const string RequestBaseUrl = "";
[Test]
public void Resolve_Uses_Configured_Value_When_Set()
{
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
"https://tv.example.com",
RequestScheme,
RequestHost,
RequestBaseUrl);
scheme.ShouldBe("https");
host.ShouldBe("tv.example.com");
baseUrl.ShouldBe("");
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Resolve_Falls_Back_To_Request_When_Blank(string configured)
{
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
configured,
RequestScheme,
RequestHost,
RequestBaseUrl);
scheme.ShouldBe(RequestScheme);
host.ShouldBe(RequestHost);
baseUrl.ShouldBe(RequestBaseUrl);
}
[Test]
public void Resolve_Falls_Back_To_Request_When_Invalid()
{
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
"not a url",
RequestScheme,
RequestHost,
RequestBaseUrl);
scheme.ShouldBe(RequestScheme);
host.ShouldBe(RequestHost);
baseUrl.ShouldBe(RequestBaseUrl);
}
[Test]
public void TryParse_Preserves_Non_Default_Port()
{
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse("http://192.168.1.99:8409").IfNone(("", "", ""));
scheme.ShouldBe("http");
host.ShouldBe("192.168.1.99:8409");
baseUrl.ShouldBe("");
}
[TestCase("http://tv.example.com:80", "http", "tv.example.com")]
[TestCase("https://tv.example.com:443", "https", "tv.example.com")]
public void TryParse_Drops_Redundant_Default_Port(string configured, string expectedScheme, string expectedHost)
{
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", ""));
scheme.ShouldBe(expectedScheme);
host.ShouldBe(expectedHost);
baseUrl.ShouldBe("");
}
[Test]
public void TryParse_Preserves_Path_Prefix()
{
(string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse("https://tv.example.com/etv").IfNone(("", "", ""));
baseUrl.ShouldBe("/etv");
}
[TestCase("https://tv.example.com/", "")]
[TestCase("https://tv.example.com/etv/", "/etv")]
[TestCase("https://tv.example.com/etv//", "/etv")]
public void TryParse_Normalizes_Trailing_Slash(string configured, string expectedBaseUrl)
{
(string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", ""));
baseUrl.ShouldBe(expectedBaseUrl);
}
[Test]
public void TryParse_Trims_Whitespace()
{
Option<(string Scheme, string Host, string BaseUrl)> result =
AdvertisedBaseUrl.TryParse(" https://tv.example.com/etv ");
result.IsSome.ShouldBeTrue();
result.IfNone(("", "", "")).BaseUrl.ShouldBe("/etv");
}
[Test]
public void TryParse_Handles_IPv6_Host()
{
(string _, string host, string _) = AdvertisedBaseUrl.TryParse("http://[2001:db8::1]:8409").IfNone(("", "", ""));
host.ShouldBe("[2001:db8::1]:8409");
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase("not a url")]
[TestCase("tv.example.com")] // no scheme (relative)
[TestCase("//tv.example.com")] // protocol-relative (not absolute)
[TestCase("ftp://tv.example.com")] // non-http(s) scheme
[TestCase("ws://tv.example.com")] // non-http(s) scheme
[TestCase("http://user:pass@tv.example.com")] // credentials
[TestCase("http://tv.example.com?foo=bar")] // query
[TestCase("http://tv.example.com/etv?foo=bar")] // query on a path
[TestCase("http://tv.example.com#frag")] // fragment
[TestCase("http://")] // no host
public void TryParse_Rejects_Invalid_Input(string configured) =>
AdvertisedBaseUrl.TryParse(configured).IsNone.ShouldBeTrue();
}
@@ -4,6 +4,7 @@ using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
@@ -104,6 +105,88 @@ public class ChannelGuideGoldenTests
public Task Guide_with_base_url() =>
Verify("guide-base-url.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "/etv", AccessToken: null));
// When an advertised base URL is configured (issue #340), {RequestBase} must use it instead of the
// request-derived scheme/host — proving the override reaches both fragment substitution sites.
[Test]
public async Task Guide_uses_advertised_base_url_when_configured()
{
MockFileSystem fileSystem = BuildCacheFileSystem();
var localFileSystem = Substitute.For<ILocalFileSystem>();
localFileSystem
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
.Returns(new[]
{
FragmentPath(fileSystem, "channels.xml"),
FragmentPath(fileSystem, "2.xml")
});
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("https://public.example.com/etv"));
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem,
configElementRepository);
Either<BaseError, ChannelGuide> result = await handler.Handle(
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
CancellationToken.None);
string xml = result.Match(
Right: guide => guide.ToXml(),
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
// The advertised origin replaces {RequestBase} on both the channel <icon> and the programme <icon>.
xml.ShouldContain("https://public.example.com/etv/iptv/logos/news.jpg");
xml.ShouldContain("https://public.example.com/etv/iptv/artwork/posters/abc.jpg");
xml.ShouldNotContain(Host);
}
// A configured base URL whose path prefix contains an XML-special character ('&' is a legal URL
// path char, so it passes AdvertisedBaseUrl validation) must be XML-escaped when substituted into
// the guide fragments — otherwise a bare '&' malforms the whole document. (Reviewer finding, #340.)
[Test]
public async Task Guide_xml_escapes_advertised_base_url()
{
MockFileSystem fileSystem = BuildCacheFileSystem();
var localFileSystem = Substitute.For<ILocalFileSystem>();
localFileSystem
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
.Returns(new[]
{
FragmentPath(fileSystem, "channels.xml"),
FragmentPath(fileSystem, "2.xml")
});
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("https://tv.example.com/a&b"));
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem,
configElementRepository);
Either<BaseError, ChannelGuide> result = await handler.Handle(
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
CancellationToken.None);
string xml = result.Match(
Right: guide => guide.ToXml(),
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
// The '&' from the base URL must be emitted as '&amp;', never a bare '&'.
xml.ShouldContain("https://tv.example.com/a&amp;b/iptv/logos/news.jpg");
xml.ShouldNotContain("a&b");
}
// --- harness ---
private async Task Verify(string goldenName, GetChannelGuide request)
@@ -123,11 +206,19 @@ public class ChannelGuideGoldenTests
FragmentPath(fileSystem, "2.xml")
});
// No advertised base URL configured — the {RequestBase} substitution must use the request-derived
// scheme/host/base, keeping today's output byte-for-byte identical (issue #340).
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.None);
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem);
localFileSystem,
configElementRepository);
Either<BaseError, ChannelGuide> result = await handler.Handle(request, CancellationToken.None);
@@ -0,0 +1,71 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Iptv;
[TestFixture]
public class GetChannelPlaylistHandlerTests
{
private const string Scheme = "https";
private const string Host = "tv.example.com";
[Test]
public async Task Uses_Advertised_Base_Url_When_Configured()
{
string m3u = await BuildM3U(configuredBaseUrl: "https://public.example.com/etv");
m3u.ShouldContain("https://public.example.com/etv/iptv/channel/1.");
m3u.ShouldContain("https://public.example.com/etv/iptv/xmltv.xml");
m3u.ShouldNotContain(Host);
}
[Test]
public async Task Falls_Back_To_Request_When_Unset()
{
string m3u = await BuildM3U(configuredBaseUrl: null);
m3u.ShouldContain("https://tv.example.com/iptv/channel/1.");
m3u.ShouldContain("https://tv.example.com/iptv/xmltv.xml");
m3u.ShouldNotContain("public.example.com");
}
private static async Task<string> BuildM3U(string configuredBaseUrl)
{
var channelRepository = Substitute.For<IChannelRepository>();
channelRepository.GetAll(Arg.Any<CancellationToken>()).Returns([BuildChannel()]);
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Optional(configuredBaseUrl));
var handler = new GetChannelPlaylistHandler(channelRepository, configElementRepository);
ChannelPlaylist playlist = await handler.Handle(
new GetChannelPlaylist(Scheme, Host, BaseUrl: "", Mode: "mixed", UserAgent: "VLC/3.0", AccessToken: null),
CancellationToken.None);
return playlist.ToM3U();
}
private static Channel BuildChannel() =>
new(new Guid("00000000-0000-0000-0000-000000000001"))
{
Number = "1",
Name = "News",
Group = "ErsatzTV",
IsEnabled = true,
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
Artwork = [],
FFmpegProfile = new FFmpegProfile
{
VideoFormat = FFmpegProfileVideoFormat.H264,
AudioFormat = FFmpegProfileAudioFormat.Aac
}
};
}
@@ -0,0 +1,3 @@
000 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Block Movie 01
001 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Block Movie 02
002 | 2026-01-16 09:00:00 - 2026-01-16 10:00:00 | None | Block Movie 03
@@ -0,0 +1,72 @@
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Movie 01
001 | 2026-01-15 00:30:00 - 2026-01-15 01:15:00 | None | Movie 02
002 | 2026-01-15 01:15:00 - 2026-01-15 02:15:00 | None | Movie 03
003 | 2026-01-15 02:15:00 - 2026-01-15 02:45:00 | None | Movie 04
004 | 2026-01-15 02:45:00 - 2026-01-15 03:30:00 | None | Movie 05
005 | 2026-01-15 03:30:00 - 2026-01-15 04:30:00 | None | Movie 06
006 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Movie 01
007 | 2026-01-15 05:00:00 - 2026-01-15 05:45:00 | None | Movie 02
008 | 2026-01-15 05:45:00 - 2026-01-15 06:45:00 | None | Movie 03
009 | 2026-01-15 06:45:00 - 2026-01-15 07:15:00 | None | Movie 04
010 | 2026-01-15 07:15:00 - 2026-01-15 08:00:00 | None | Movie 05
011 | 2026-01-15 08:00:00 - 2026-01-15 09:00:00 | None | Movie 06
012 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Movie 01
013 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Movie 02
014 | 2026-01-15 10:15:00 - 2026-01-15 11:15:00 | None | Movie 03
015 | 2026-01-15 11:15:00 - 2026-01-15 11:45:00 | None | Movie 04
016 | 2026-01-15 11:45:00 - 2026-01-15 12:30:00 | None | Movie 05
017 | 2026-01-15 12:30:00 - 2026-01-15 13:30:00 | None | Movie 06
018 | 2026-01-15 13:30:00 - 2026-01-15 14:00:00 | None | Movie 01
019 | 2026-01-15 14:00:00 - 2026-01-15 14:45:00 | None | Movie 02
020 | 2026-01-15 14:45:00 - 2026-01-15 15:45:00 | None | Movie 03
021 | 2026-01-15 15:45:00 - 2026-01-15 16:15:00 | None | Movie 04
022 | 2026-01-15 16:15:00 - 2026-01-15 17:00:00 | None | Movie 05
023 | 2026-01-15 17:00:00 - 2026-01-15 18:00:00 | None | Movie 06
024 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Movie 01
025 | 2026-01-15 18:30:00 - 2026-01-15 19:15:00 | None | Movie 02
026 | 2026-01-15 19:15:00 - 2026-01-15 20:15:00 | None | Movie 03
027 | 2026-01-15 20:15:00 - 2026-01-15 20:45:00 | None | Movie 04
028 | 2026-01-15 20:45:00 - 2026-01-15 21:30:00 | None | Movie 05
029 | 2026-01-15 21:30:00 - 2026-01-15 22:30:00 | None | Movie 06
030 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Movie 01
031 | 2026-01-15 23:00:00 - 2026-01-15 23:45:00 | None | Movie 02
032 | 2026-01-15 23:45:00 - 2026-01-16 00:45:00 | None | Movie 03
033 | 2026-01-16 00:45:00 - 2026-01-16 01:15:00 | None | Movie 04
034 | 2026-01-16 01:15:00 - 2026-01-16 02:00:00 | None | Movie 05
035 | 2026-01-16 02:00:00 - 2026-01-16 03:00:00 | None | Movie 06
036 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Movie 01
037 | 2026-01-16 03:30:00 - 2026-01-16 04:15:00 | None | Movie 02
038 | 2026-01-16 04:15:00 - 2026-01-16 05:15:00 | None | Movie 03
039 | 2026-01-16 05:15:00 - 2026-01-16 05:45:00 | None | Movie 04
040 | 2026-01-16 05:45:00 - 2026-01-16 06:30:00 | None | Movie 05
041 | 2026-01-16 06:30:00 - 2026-01-16 07:30:00 | None | Movie 06
042 | 2026-01-16 07:30:00 - 2026-01-16 08:00:00 | None | Movie 01
043 | 2026-01-16 08:00:00 - 2026-01-16 08:45:00 | None | Movie 02
044 | 2026-01-16 08:45:00 - 2026-01-16 09:45:00 | None | Movie 03
045 | 2026-01-16 09:45:00 - 2026-01-16 10:15:00 | None | Movie 04
046 | 2026-01-16 10:15:00 - 2026-01-16 11:00:00 | None | Movie 05
047 | 2026-01-16 11:00:00 - 2026-01-16 12:00:00 | None | Movie 06
048 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Movie 01
049 | 2026-01-16 12:30:00 - 2026-01-16 13:15:00 | None | Movie 02
050 | 2026-01-16 13:15:00 - 2026-01-16 14:15:00 | None | Movie 03
051 | 2026-01-16 14:15:00 - 2026-01-16 14:45:00 | None | Movie 04
052 | 2026-01-16 14:45:00 - 2026-01-16 15:30:00 | None | Movie 05
053 | 2026-01-16 15:30:00 - 2026-01-16 16:30:00 | None | Movie 06
054 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Movie 01
055 | 2026-01-16 17:00:00 - 2026-01-16 17:45:00 | None | Movie 02
056 | 2026-01-16 17:45:00 - 2026-01-16 18:45:00 | None | Movie 03
057 | 2026-01-16 18:45:00 - 2026-01-16 19:15:00 | None | Movie 04
058 | 2026-01-16 19:15:00 - 2026-01-16 20:00:00 | None | Movie 05
059 | 2026-01-16 20:00:00 - 2026-01-16 21:00:00 | None | Movie 06
060 | 2026-01-16 21:00:00 - 2026-01-16 21:30:00 | None | Movie 01
061 | 2026-01-16 21:30:00 - 2026-01-16 22:15:00 | None | Movie 02
062 | 2026-01-16 22:15:00 - 2026-01-16 23:15:00 | None | Movie 03
063 | 2026-01-16 23:15:00 - 2026-01-16 23:45:00 | None | Movie 04
064 | 2026-01-16 23:45:00 - 2026-01-17 00:30:00 | None | Movie 05
065 | 2026-01-17 00:30:00 - 2026-01-17 01:30:00 | None | Movie 06
066 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Movie 01
067 | 2026-01-17 02:00:00 - 2026-01-17 02:45:00 | None | Movie 02
068 | 2026-01-17 02:45:00 - 2026-01-17 03:45:00 | None | Movie 03
069 | 2026-01-17 03:45:00 - 2026-01-17 04:15:00 | None | Movie 04
070 | 2026-01-17 04:15:00 - 2026-01-17 05:00:00 | None | Movie 05
071 | 2026-01-17 05:00:00 - 2026-01-17 06:00:00 | None | Movie 06
@@ -0,0 +1,602 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem;
namespace ErsatzTV.Core.Tests.Scheduling;
// Golden-file characterization tests that lock the output of the playout builders — the core
// scheduling surface that turns a ProgramSchedule/Block calendar + Collection into a concrete list of
// PlayoutItems (issue #163). This slice covers the Classic builder (PlaybackOrder.Chronological) and
// the Block builder. Sequential (YAML) + Scripted goldens are tracked as a follow-up in #381 (they need
// a YAML fixture / an external-process harness respectively — not just a clock seam).
//
// This is the scheduling counterpart to ChannelPlaylistGoldenTests (#11, M3U) and
// ChannelGuideGoldenTests (#28, XMLTV). Goldens live under Goldens/Goldens/ and are regenerated via
// the Regenerate_goldens test or ETV_UPDATE_PLAYOUT_GOLDENS=1 — review the diff before committing.
//
// DETERMINISM: time enters the build ONLY via the pinned start (finish = start + 2 days); no wall clock
// is read. We snapshot the raw PlayoutItem.Start/Finish (DateTime, treated as UTC) — NOT the *Offset
// properties, which call .ToLocalTime() and would make the golden machine-timezone dependent.
//
// A few determinism invariants worth stating so a future reader doesn't "helpfully" break them:
// * We snapshot the builder's raw output (buildResult.AddedItems), NOT the persisted playout. With
// TrimStart, production would delete items before RemoveBefore (~start - 4h), so the golden's early
// lines are pre-trim. That is intentional: this locks the BUILDER's output, and it is deterministic.
// * Classic is TZ-independent for the captured fields (its internal DateTime->offset conversions only
// gate the day-by-day loop; the anchor carries currentTime forward as UTC), so it needs no TZ guard —
// unlike Block below. Do not add/remove a guard without re-checking this.
// * ResetPlayout randomizes playout.Seed, but the classic fixture neutralizes it: RandomStartPoint and
// ShuffleScheduleItems both default false and Chronological orders by (distinct) release date, so the
// seed cannot perturb output. Introducing release-date ties or flipping those flags would reintroduce
// nondeterminism.
[TestFixture]
public class PlayoutBuildGoldenTests
{
// Pinned build window — deterministic, UTC, no wall-clock dependency.
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
private SqliteConnection _connection;
private IDbContextFactory<TvContext> _dbContextFactory;
[OneTimeSetUp]
public async Task SetUpDatabase()
{
// Shared in-memory SQLite: the connection must stay open for the DB to live across contexts.
_connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
await _connection.OpenAsync();
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
.UseSqlite(_connection)
.Options;
_dbContextFactory = new TestTvContextFactory(options);
await using TvContext context = _dbContextFactory.CreateDbContext();
// EnsureCreated builds the schema from the model directly — sufficient here and far cheaper than
// replaying every migration. The MediaCollectionRepository's Dapper queries run against it fine.
await context.Database.EnsureCreatedAsync();
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
}
[OneTimeTearDown]
public void TearDownDatabase() => _connection?.Dispose();
[Test]
public Task Classic_chronological() => Verify("classic-chronological.txt", BuildChronologicalPlayout);
[Test]
public Task Block_playout() => Verify("block.txt", BuildBlockPlayout);
[Test]
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
public async Task Regenerate_goldens()
{
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", "1");
try
{
foreach (Func<Task> regen in new Func<Task>[] { Classic_chronological, Block_playout })
{
try
{
await regen();
}
catch (InconclusiveException)
{
// expected — Verify writes its golden then reports inconclusive
}
}
}
finally
{
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", null);
}
}
// --- harness ---
private async Task Verify(
string goldenName,
Func<Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)>> build)
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await build();
string actual = Canonicalize(Snapshot(items, titles));
string path = Path.Combine(GoldenDir(), goldenName);
if (Environment.GetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS") == "1")
{
Directory.CreateDirectory(GoldenDir());
await File.WriteAllTextAsync(path, actual);
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
return;
}
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline can't pass CI.
File.Exists(path).ShouldBeTrue(
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_PLAYOUT_GOLDENS=1) and commit it.");
string expected = Canonicalize(await File.ReadAllTextAsync(path));
actual.ShouldBe(expected);
}
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildChronologicalPlayout()
{
var cancellationToken = CancellationToken.None;
// Seed a fresh, deterministic dataset for this build. Titles + release dates are fixed and the
// durations vary (30/45/60) so the chronological ordering and item boundaries are visible.
var (playoutId, titles) = await SeedData(cancellationToken);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
NullLogger<PlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
// Build ONCE with Reset over the pinned 2-day window (internal overload = explicit start/finish).
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Test LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Six movies, fixed titles + release dates, varied durations to make ordering/boundaries visible.
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Movie {i:D2}",
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
var collection = new Collection
{
Name = "Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItems = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
Collection = collection,
CollectionId = collection.Id,
CollectionType = CollectionType.Collection,
PlayoutDuration = TimeSpan.FromHours(1),
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Chronological
}
};
var ffmpegProfile = new FFmpegProfile { Name = "Test FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
{
Name = "Test Channel",
Number = "1",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = "Test Schedule", Items = scheduleItems };
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id,
ScheduleKind = PlayoutScheduleKind.Classic
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
.AsNoTracking()
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
.Include(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.FirstOrDefaultAsync();
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
programSchedule,
[],
[],
TimeSpan.Zero);
}
// --- Block builder ---
//
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
// EffectiveBlock.GetEffectiveBlocks(..., TimeZoneInfo.Local, ...), so its output is machine-timezone
// dependent. This is a CHARACTERIZATION test: rather than change production code to inject the zone
// (that seam is issue #380's scope), we capture the golden under UTC and GUARD with Assume.That so the
// test RUNS under TZ=UTC (CI) and reports INCONCLUSIVE (a graceful skip, not a failure) under any other
// TZ — mirroring ChannelPlaylistGoldenTests' GuardVolatileEnvironment. Classic + other TZ-independent
// goldens are unaffected.
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildBlockPlayout()
{
// Guard on the offset AT the build instant (GetUtcOffset(Start)), not BaseUtcOffset: the latter is
// zero for DST zones like Europe/London year-round, so it would pass in a summer-dated fixture where
// London != UTC. GetUtcOffset pins the actual instant and stays correct regardless of fixture date.
Assume.That(
TimeZoneInfo.Local.GetUtcOffset(Start),
Is.EqualTo(TimeSpan.Zero),
"Block golden is captured under UTC; run with TZ=UTC. A real TZ seam is issue #380's scope.");
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedBlockData(cancellationToken);
var builder = new BlockPlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<ICollectionEtag>(),
NullLogger<BlockPlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetBlockReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
Start,
playout,
referenceData,
PlayoutBuildMode.Reset,
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedBlockData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Block LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Six movies, fixed titles + release dates, varied durations so chronological ordering and block
// boundaries are visible across the scheduled blocks.
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Block Movie {i:D2}",
ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
var collection = new Collection
{
Name = "Block Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// A single 60-minute block with three chronological items over the same collection. With
// AfterDurationEnd, each block fills until currentTime passes the block finish; history carries the
// chronological cursor across the blocks scheduled on successive days.
var blockGroup = new BlockGroup { Name = "Block Test Group" };
await context.BlockGroups.AddAsync(blockGroup, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var block = new Block
{
BlockGroup = blockGroup,
BlockGroupId = blockGroup.Id,
Name = "Test Block",
Minutes = 60,
StopScheduling = BlockStopScheduling.AfterDurationEnd,
Items = new List<BlockItem>
{
new()
{
Index = 1,
CollectionType = CollectionType.Collection,
Collection = collection,
CollectionId = collection.Id,
PlaybackOrder = PlaybackOrder.Chronological
},
new()
{
Index = 2,
CollectionType = CollectionType.Collection,
Collection = collection,
CollectionId = collection.Id,
PlaybackOrder = PlaybackOrder.Chronological
},
new()
{
Index = 3,
CollectionType = CollectionType.Collection,
Collection = collection,
CollectionId = collection.Id,
PlaybackOrder = PlaybackOrder.Chronological
}
}
};
await context.Blocks.AddAsync(block, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var templateGroup = new TemplateGroup { Name = "Template Test Group" };
await context.TemplateGroups.AddAsync(templateGroup, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var template = new Template
{
TemplateGroup = templateGroup,
TemplateGroupId = templateGroup.Id,
Name = "Test Template",
Items = new List<TemplateItem>()
};
template.Items.Add(new TemplateItem
{
Block = block,
BlockId = block.Id,
StartTime = TimeSpan.FromHours(9)
});
await context.Templates.AddAsync(template, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var ffmpegProfile = new FFmpegProfile { Name = "Block FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000002"))
{
Name = "Block Test Channel",
Number = "2",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ScheduleKind = PlayoutScheduleKind.Block
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playoutTemplate = new PlayoutTemplate
{
Playout = playout,
PlayoutId = playout.Id,
Template = template,
TemplateId = template.Id,
Index = 1,
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
};
await context.PlayoutTemplates.AddAsync(playoutTemplate, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetBlockReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
List<PlayoutItem> existingItems = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.PlayoutId == playoutId)
.ToListAsync();
List<PlayoutTemplate> playoutTemplates = await dbContext.PlayoutTemplates
.AsNoTracking()
.Where(pt => pt.PlayoutId == playoutId)
.Include(t => t.Template)
.ThenInclude(t => t.Items)
.ThenInclude(i => i.Block)
.ThenInclude(b => b.Items)
.Include(t => t.DecoTemplate)
.ThenInclude(t => t.Items)
.ThenInclude(i => i.Deco)
.ToListAsync();
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
existingItems,
playoutTemplates,
null,
[],
[],
TimeSpan.Zero);
}
// One line per PlayoutItem, ordered by Start then MediaItemId (stable tiebreak). Raw UTC Start/Finish
// serialized invariant — NOT the *Offset properties (those localize). Title resolved from the seed map.
private static string Snapshot(List<PlayoutItem> items, Dictionary<int, string> titles)
{
var ordered = items
.OrderBy(i => i.Start)
.ThenBy(i => i.MediaItemId)
.ToList();
var sb = new StringBuilder();
for (var index = 0; index < ordered.Count; index++)
{
PlayoutItem item = ordered[index];
string title = titles.TryGetValue(item.MediaItemId, out string t) ? t : $"#{item.MediaItemId}";
sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" - ");
sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.FillerKind.ToString());
sb.Append(" | ");
sb.Append(title);
sb.Append('\n');
}
return sb.ToString();
}
private static string Canonicalize(string text) =>
text.TrimStart('').ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() =>
new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
}
}
@@ -0,0 +1,22 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
public record AutoTuneProposalResponseModel(
string Axis,
string Value,
string Name,
string Number,
int ItemCount,
bool AlreadyExists);
public record AutoTuneChannelResultModel(
string Name,
string Status,
int? ChannelId,
string? Reason);
public record AutoTuneResultResponseModel(
List<AutoTuneChannelResultModel> Results,
int CreatedCount,
int SkippedCount,
int FailedCount);
@@ -14,7 +14,6 @@ public record ChannelGuideProgrammeResponseModel(
/// <summary>One channel's guide programmes for the requested window.</summary>
public record ChannelGuideChannelResponseModel(
int Id,
string Number,
string Name,
List<ChannelGuideProgrammeResponseModel> Programmes);
@@ -1,46 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Channels;
/// <summary>The kind of source selected by the channel schedule.</summary>
public enum ChannelPlaybackSourceKind
{
LocalFile,
JellyfinItem,
PlexItem,
EmbyItem,
RemoteUrl,
Unsupported
}
/// <summary>A token-free reference to the scheduled media source.</summary>
public record ChannelPlaybackSourceReferenceResponseModel(
ChannelPlaybackSourceKind Kind,
string? ItemId,
string? Path,
bool IsLive);
/// <summary>The physical playout item covering the requested channel time.</summary>
public record ChannelPlaybackItemResponseModel(
int PlayoutItemId,
int MediaItemId,
DateTimeOffset Start,
DateTimeOffset Finish,
long InPointTicks,
long CurrentOffsetTicks,
long OutPointTicks,
FillerKind FillerKind,
ChannelPlaybackSourceReferenceResponseModel Source);
/// <summary>
/// Resolves a viewer-facing channel time to the physical media item selected by the schedule.
/// This is intentionally playback-engine neutral and never starts an ErsatzTV transcoder.
/// </summary>
public record ChannelPlaybackSourceResponseModel(
int ChannelId,
int SourceChannelId,
DateTimeOffset ResolvedAt,
DateTimeOffset SourceAt,
DateTimeOffset? NextTransitionAt,
ChannelPlaybackItemResponseModel? Active);
@@ -12,4 +12,5 @@ public record PlayoutListItemResponseModel(
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus,
ChannelPlayoutMode PlayoutMode,
bool IsLocked);
bool IsLocked,
int Seed);
@@ -15,7 +15,8 @@ public record PlayoutResponseModel(
PlayoutBuildStatusResponseModel? BuildStatus,
int? DecoId,
string? DecoName,
bool IsLocked)
bool IsLocked,
int Seed)
{
public static PlayoutResponseModel From(
int id,
@@ -29,7 +30,8 @@ public record PlayoutResponseModel(
PlayoutBuildStatusResponseModel? buildStatus,
int? decoId,
string? decoName,
bool isLocked) =>
bool isLocked,
int seed) =>
new(
id,
scheduleKind,
@@ -42,5 +44,6 @@ public record PlayoutResponseModel(
buildStatus,
decoId,
decoName,
isLocked);
isLocked,
seed);
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
/// <summary>
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
/// absolute M3U/XMLTV URLs; an empty string means "use the incoming request's scheme/host/path".
/// </summary>
public record IptvSettingsResponseModel(string BaseUrl);
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public enum AutoTuneAxis
{
TvShow = 0,
TvGenre = 1,
MovieGenre = 2
}
+5
View File
@@ -62,6 +62,11 @@ public class ConfigElementKey
public static ConfigElementKey XmltvDaysToBuild => new("xmltv.days_to_build");
public static ConfigElementKey XmltvBlockBehavior => new("xmltv.block_behavior");
// Optional advertised IPTV base URL (issue #340). When set, overrides the request-derived
// scheme/host/PathBase used to build absolute M3U + XMLTV URLs; when blank/unset, request-derived
// values are used (today's behavior). Distinct from ETV_BASE_URL, which only sets ASP.NET PathBase.
public static ConfigElementKey IptvBaseUrl => new("iptv.base_url");
// Browser SPA authentication (issue #295). The single local-admin credential lives in ConfigElement
// rows (no DB migration): a username, a PBKDF2 password hash, and a security stamp that is rotated on
// every password change so a stamp mismatch in OnValidatePrincipal revokes all outstanding sessions.
-1
View File
@@ -4,7 +4,6 @@
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>disable</Nullable>
</PropertyGroup>
+67
View File
@@ -0,0 +1,67 @@
namespace ErsatzTV.Core.Iptv;
// Central resolution/validation for the optional advertised IPTV base URL (issue #340).
//
// ErsatzTV builds every absolute M3U/XMLTV URL from the incoming request's scheme + host + PathBase.
// When a downstream consumer fetches ErsatzTV via a host that other consumers can't resolve (e.g.
// Dispatcharr fetching over Docker DNS, then Kodi receiving those internal hostnames), the emitted
// URLs break. An operator can configure an advertised base URL to override those request-derived
// values consistently across the M3U (guide/logo/stream) and XMLTV ({RequestBase}) surfaces.
//
// When the configured value is blank or invalid, resolution falls back to the request-derived values
// so today's behavior is preserved byte-for-byte.
public static class AdvertisedBaseUrl
{
// Returns the effective (scheme, host, baseUrl) to use for absolute IPTV URLs. When the configured
// value is blank or invalid, returns the request-derived values unchanged.
public static (string Scheme, string Host, string BaseUrl) Resolve(
string configured,
string requestScheme,
string requestHost,
string requestBaseUrl) =>
TryParse(configured).Match(
Some: parsed => parsed,
None: () => (requestScheme, requestHost, requestBaseUrl));
// Validates + normalizes an advertised base URL. None => blank or invalid. A valid value is an
// absolute http(s) URL with no credentials, query, or fragment; an optional port and path prefix
// are preserved, and a trailing slash is normalized away (so a root "/" yields an empty base, and
// "/etv/" yields "/etv" — matching the PathBase convention the URL builders concatenate).
public static Option<(string Scheme, string Host, string BaseUrl)> TryParse(string configured)
{
if (string.IsNullOrWhiteSpace(configured))
{
return None;
}
if (!Uri.TryCreate(configured.Trim(), UriKind.Absolute, out Uri uri))
{
return None;
}
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
{
return None;
}
if (!string.IsNullOrEmpty(uri.UserInfo))
{
return None;
}
if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
{
return None;
}
if (string.IsNullOrEmpty(uri.Host))
{
return None;
}
// Uri.Authority is host[:port] (omitting a redundant default port, wrapping IPv6 in brackets)
// and excludes any userinfo — exactly the "{host}" the URL builders expect.
string baseUrl = uri.AbsolutePath.TrimEnd('/');
return (uri.Scheme, uri.Authority, baseUrl);
}
}
-1
View File
@@ -4,7 +4,6 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>VSTHRD200,CA1873</NoWarn>
@@ -5,7 +5,6 @@
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
-1
View File
@@ -8,7 +8,6 @@
<Configurations>Debug;Release;Debug No Sync</Configurations>
<Platforms>AnyCPU</Platforms>
<UserSecretsId>729e6271-c307-43c8-8e36-1b36c39f6de2</UserSecretsId>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>VSTHRD200,CA1873</NoWarn>
</PropertyGroup>
@@ -0,0 +1,44 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class AutoTuneAxisMapTests
{
[Test]
public void GenerateQuery_Builds_Expected_Lucene()
{
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "The Office")
.ShouldBe("type:episode AND show_title:\"The Office\"");
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvGenre, "Comedy")
.ShouldBe("type:episode AND genre:\"Comedy\"");
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.MovieGenre, "Action")
.ShouldBe("type:movie AND genre:\"Action\"");
}
[Test]
public void GenerateQuery_Escapes_Quotes_And_Backslashes()
{
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "Bob\"s \\Show")
.ShouldBe("type:episode AND show_title:\"Bob\\\"s \\\\Show\"");
}
[Test]
public void GenerateName_Suffixes_Movie_Genres_Only()
{
AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvShow, "The Office").ShouldBe("The Office");
AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvGenre, "Comedy").ShouldBe("Comedy");
AutoTuneAxisMap.GenerateName(AutoTuneAxis.MovieGenre, "Action").ShouldBe("Action Movies");
}
[Test]
public void PlaybackOrderFor_Uses_PseudoTV_Defaults()
{
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvShow).ShouldBe(PlaybackOrder.SeasonEpisode);
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvGenre).ShouldBe(PlaybackOrder.Shuffle);
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.MovieGenre).ShouldBe(PlaybackOrder.Shuffle);
}
}
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using ErsatzTV.Application.Channels;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class AutoTuneNumberAllocatorTests
{
[Test]
public void Allocate_Skips_Taken_Numbers()
{
var existing = new HashSet<string> { "500", "502" };
List<string> result = AutoTuneNumberAllocator.Allocate(500, 3, existing);
result.ShouldBe(new List<string> { "501", "503", "504" });
}
[Test]
public void Allocate_From_Empty_Is_Sequential()
{
List<string> result = AutoTuneNumberAllocator.Allocate(1, 3, new HashSet<string>());
result.ShouldBe(new List<string> { "1", "2", "3" });
}
[Test]
public void Allocate_Zero_Count_Is_Empty()
{
AutoTuneNumberAllocator.Allocate(500, 0, new HashSet<string>()).ShouldBeEmpty();
}
}
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class CreateAutoTunedChannelsHandlerTests
{
private ISender _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<ISender>();
// Smart collection creation always succeeds, echoing an incrementing id.
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var cmd = ci.Arg<CreateSmartCollection>();
return (Either<BaseError, SmartCollectionViewModel>)
new SmartCollectionViewModel(7, cmd.Name, cmd.Query);
});
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, LanguageExt.Unit>)LanguageExt.Unit.Default);
}
[Test]
public async Task Creates_Selected_Channels_And_Reports_Counts()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
new CreateChannelFromLineupResponseModel(88, null, 1, 2));
var result = await Handle(new CreateAutoTunedChannels(
TemplateId: 3, Group: "Auto-Tuned",
new List<AutoTuneChannelSelection>
{
new(AutoTuneAxis.TvShow, "The Office", "The Office", "500")
}));
result.CreatedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Created);
result.Results[0].ChannelId.ShouldBe(88);
// Smart collection built with the server-generated query.
await _mediator.Received().Send(
Arg.Is<CreateSmartCollection>(c => c.Query == "type:episode AND show_title:\"The Office\""),
Arg.Any<CancellationToken>());
// Channel created referencing the smart collection id, number, and SeasonEpisode order.
await _mediator.Received().Send(
Arg.Is<CreateChannelFromLineup>(c =>
c.Number == "500" &&
c.TemplateId == 3 &&
c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode &&
c.Lineup.Count == 1 &&
c.Lineup[0].CollectionType == CollectionType.SmartCollection &&
c.Lineup[0].SmartCollectionId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Number_Collision_Is_Skipped_Not_Failed()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("Channel number must be unique"));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.SkippedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped);
}
[Test]
public async Task Number_Collision_Rolls_Back_Orphaned_SmartCollection()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("Channel number must be unique"));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped);
await _mediator.Received().Send(
Arg.Is<DeleteSmartCollection>(d => d.SmartCollectionId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Other_Errors_Are_Failed()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("FFmpegProfile 9 does not exist."));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.FailedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed);
}
private Task<AutoTuneResult> Handle(CreateAutoTunedChannels request) =>
new CreateAutoTunedChannelsHandler(_mediator).Handle(request, CancellationToken.None);
}
@@ -61,7 +61,6 @@ public class GetChannelGuideDataHandlerTests
CancellationToken.None);
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
result.Channels.Single().Id.ShouldBeGreaterThan(0);
}
[Test]
@@ -1,467 +0,0 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelPlaybackSourceHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 14, 20, 0, 0, TimeSpan.Zero);
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Resolve_Jellyfin_Item_And_Schedule_Offset()
{
string jellyfinItemId = Guid.NewGuid().ToString("N");
DateTime start = Now.UtcDateTime.AddMinutes(-10);
DateTime finish = Now.UtcDateTime.AddMinutes(20);
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(7, "7.1");
var movie = new JellyfinMovie
{
Id = 70,
ItemId = jellyfinItemId,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var playout = new Playout { Id = 71, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 72,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
InPoint = TimeSpan.FromMinutes(5),
OutPoint = TimeSpan.FromMinutes(35),
FillerKind = FillerKind.None
};
context.Channels.Add(channel);
context.JellyfinMovies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(7, Now);
result.ChannelId.ShouldBe(7);
result.SourceChannelId.ShouldBe(7);
result.SourceAt.ShouldBe(Now);
result.NextTransitionAt.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
result.Active.ShouldNotBeNull();
result.Active.Start.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.FromMinutes(15).Ticks);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.JellyfinItem);
result.Active.Source.ItemId.ShouldBe(jellyfinItemId);
result.Active.Source.Path.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Use_Actual_Filler_Item_Not_Guide_Display_Item()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(8, "8");
var filler = new OtherVideo
{
Id = 80,
OtherVideoMetadata = [],
MediaVersions =
[
new MediaVersion
{
MediaFiles = [new MediaFile { Path = "/media/bumper.mkv", PathHash = "bumper" }]
}
]
};
var playout = new Playout { Id = 81, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 82,
MediaItem = filler,
MediaItemId = filler.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1),
OutPoint = TimeSpan.FromMinutes(2),
FillerKind = FillerKind.MidRoll,
GuideGroup = 4
};
context.Channels.Add(channel);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(8, Now);
result.Active.ShouldNotBeNull();
result.Active.PlayoutItemId.ShouldBe(82);
result.Active.FillerKind.ShouldBe(FillerKind.MidRoll);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.LocalFile);
result.Active.Source.Path.ShouldBe("/media/bumper.mkv");
}
[Test]
public async Task Handle_Should_Apply_Mirror_Clock_And_Viewer_Facing_Timestamps()
{
TimeSpan offset = TimeSpan.FromHours(1);
DateTime sourceStart = Now.UtcDateTime.Subtract(offset).AddMinutes(-5);
DateTime sourceFinish = Now.UtcDateTime.Subtract(offset).AddMinutes(25);
await using (TvContext context = _db.CreateContext())
{
Channel source = MakeChannel(9, "9");
Channel mirror = MakeChannel(10, "10");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannelId = source.Id;
mirror.PlayoutOffset = offset;
var remote = new RemoteStream
{
Id = 90,
Url = "https://example.invalid/live.m3u8",
IsLive = true,
RemoteStreamMetadata = [],
MediaVersions = []
};
var playout = new Playout { Id = 91, Channel = source, ChannelId = source.Id, Items = [] };
var item = new PlayoutItem
{
Id = 92,
MediaItem = remote,
MediaItemId = remote.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = sourceStart,
Finish = sourceFinish,
OutPoint = TimeSpan.FromMinutes(30)
};
context.Channels.AddRange(source, mirror);
context.RemoteStreams.Add(remote);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(10, Now);
result.SourceChannelId.ShouldBe(9);
result.SourceAt.ShouldBe(Now.Subtract(offset));
result.Active.ShouldNotBeNull();
result.Active.Start.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
result.Active.Finish.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
result.NextTransitionAt.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.FromMinutes(5).Ticks);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.RemoteUrl);
result.Active.Source.IsLive.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Select_Item_At_Start_And_Exclude_Item_At_Finish()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(11, "11");
var first = new Movie { Id = 110, MovieMetadata = [], MediaVersions = [] };
var second = new Movie { Id = 111, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 112, Channel = channel, ChannelId = channel.Id, Items = [] };
context.Channels.Add(channel);
context.Movies.AddRange(first, second);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
new PlayoutItem
{
Id = 113,
MediaItem = first,
MediaItemId = first.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-30),
Finish = Now.UtcDateTime
},
new PlayoutItem
{
Id = 114,
MediaItem = second,
MediaItemId = second.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime,
Finish = Now.UtcDateTime.AddMinutes(30)
});
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(11, Now);
result.Active.ShouldNotBeNull();
result.Active.PlayoutItemId.ShouldBe(114);
result.Active.MediaItemId.ShouldBe(111);
result.Active.Start.ShouldBe(Now);
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.Zero.Ticks);
result.NextTransitionAt.ShouldBe(Now.AddMinutes(30));
}
[Test]
public async Task Handle_Should_Return_Next_Start_During_Gap()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(12, "12");
var movie = new Movie { Id = 120, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 121, Channel = channel, ChannelId = channel.Id, Items = [] };
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
new PlayoutItem
{
Id = 122,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-30),
Finish = Now.UtcDateTime
},
new PlayoutItem
{
Id = 123,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(10),
Finish = Now.UtcDateTime.AddMinutes(40)
});
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(12, Now);
result.Active.ShouldBeNull();
result.NextTransitionAt.ShouldBe(Now.AddMinutes(10));
}
[Test]
public async Task Handle_Should_Return_No_Next_Transition_During_Terminal_Gap()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(13, "13");
var movie = new Movie { Id = 130, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
var finishedItem = new PlayoutItem
{
Id = 132,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddHours(-1),
Finish = Now.UtcDateTime.AddMinutes(-30)
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(finishedItem);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(13, Now);
result.Active.ShouldBeNull();
result.NextTransitionAt.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Map_Server_Items_And_Unsupported_Empty_Local_Media()
{
const string jellyfinItemId = "jellyfin-episode";
const string plexKey = "/library/metadata/123";
const string embyItemId = "emby-movie";
await using (TvContext context = _db.CreateContext())
{
var jellyfinEpisode = new JellyfinEpisode
{
Id = 140,
ItemId = jellyfinItemId,
Etag = string.Empty,
EpisodeMetadata = [],
MediaVersions = []
};
var plexMovie = new PlexMovie
{
Id = 150,
Key = plexKey,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var embyMovie = new EmbyMovie
{
Id = 160,
ItemId = embyItemId,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var emptyLocalMovie = new Movie { Id = 170, MovieMetadata = [], MediaVersions = [] };
AddActiveItem(context, MakeChannel(14, "14"), jellyfinEpisode, 141, 142);
AddActiveItem(context, MakeChannel(15, "15"), plexMovie, 151, 152);
AddActiveItem(context, MakeChannel(16, "16"), embyMovie, 161, 162);
AddActiveItem(context, MakeChannel(17, "17"), emptyLocalMovie, 171, 172);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceReferenceResponseModel jellyfin = (await GetResult(14, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel plex = (await GetResult(15, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel emby = (await GetResult(16, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel unsupported = (await GetResult(17, Now)).Active!.Source;
jellyfin.Kind.ShouldBe(ChannelPlaybackSourceKind.JellyfinItem);
jellyfin.ItemId.ShouldBe(jellyfinItemId);
plex.Kind.ShouldBe(ChannelPlaybackSourceKind.PlexItem);
plex.ItemId.ShouldBe(plexKey);
emby.Kind.ShouldBe(ChannelPlaybackSourceKind.EmbyItem);
emby.ItemId.ShouldBe(embyItemId);
unsupported.Kind.ShouldBe(ChannelPlaybackSourceKind.Unsupported);
unsupported.ItemId.ShouldBeNull();
unsupported.Path.ShouldBeNull();
unsupported.IsLive.ShouldBeFalse();
}
[Test]
public async Task Handle_Should_Return_None_For_Missing_Channel()
{
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(404, Now),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Return_None_For_Mirror_Without_Source_Channel()
{
await using (TvContext context = _db.CreateContext())
{
Channel mirror = MakeChannel(18, "18");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
var staleMovie = new Movie { Id = 180, MovieMetadata = [], MediaVersions = [] };
var stalePlayout = new Playout
{
Id = 181,
Channel = mirror,
ChannelId = mirror.Id,
Items = []
};
var staleItem = new PlayoutItem
{
Id = 182,
MediaItem = staleMovie,
MediaItemId = staleMovie.Id,
Playout = stalePlayout,
PlayoutId = stalePlayout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1)
};
context.Channels.Add(mirror);
context.Movies.Add(staleMovie);
context.Playouts.Add(stalePlayout);
context.PlayoutItems.Add(staleItem);
await context.SaveChangesAsync();
}
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(18, Now),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
private async Task<ChannelPlaybackSourceResponseModel> GetResult(int channelId, DateTimeOffset at)
{
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(channelId, at),
CancellationToken.None);
return result.Match(
Some: value => value,
None: () => throw new AssertionException("Expected a playback-source response"));
}
private static void AddActiveItem(
TvContext context,
Channel channel,
MediaItem mediaItem,
int playoutId,
int playoutItemId)
{
var playout = new Playout { Id = playoutId, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = playoutItemId,
MediaItem = mediaItem,
MediaItemId = mediaItem.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1)
};
context.Channels.Add(channel);
context.MediaItems.Add(mediaItem);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
}
private static Channel MakeChannel(int id, string number) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
SortNumber = id,
Name = $"Channel {number}",
Group = "Test",
Categories = string.Empty,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated
};
}
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NSubstitute;
@@ -18,12 +19,14 @@ public class GetChannelStatesForApiHandlerTests
private InMemoryTvContext _db = null!;
private IFFmpegSegmenterService _segmenter = null!;
private IDirectStreamSessionTracker _directStreams = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_segmenter = Substitute.For<IFFmpegSegmenterService>();
_directStreams = Substitute.For<IDirectStreamSessionTracker>();
}
[TearDown]
@@ -36,7 +39,7 @@ public class GetChannelStatesForApiHandlerTests
DateTime finish = Now.AddMinutes(20);
await SeedChannelWithMovie(start, finish);
_segmenter.IsActive("7.1").Returns(true);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -51,11 +54,26 @@ public class GetChannelStatesForApiHandlerTests
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Return_OnAir_For_Direct_Stream_Session()
{
await using TvContext context = _db.CreateContext();
context.Channels.Add(MakeChannel(8, "8"));
await context.SaveChangesAsync();
_directStreams.IsActive("8").Returns(true);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().OnAir.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
{
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -67,7 +85,7 @@ public class GetChannelStatesForApiHandlerTests
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
{
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -81,7 +99,7 @@ public class GetChannelStatesForApiHandlerTests
await using TvContext context = _db.CreateContext();
context.Channels.Add(MakeChannel(8, "8"));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -133,7 +151,7 @@ public class GetChannelStatesForApiHandlerTests
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -176,7 +194,7 @@ public class GetChannelStatesForApiHandlerTests
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -216,7 +234,7 @@ public class GetChannelStatesForApiHandlerTests
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
ChannelStateResponseModel state =
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
@@ -264,7 +282,7 @@ public class GetChannelStatesForApiHandlerTests
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class PreviewAutoTuneChannelsHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Enumerates_Shows_Above_MinItems_With_Counts_And_Numbers()
{
// Show 1 "The Office" with 3 episodes; Show 2 "Short" with 1 episode.
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102, 103 });
await SeedShow(showId: 2, title: "Short", seasonId: 22, episodeIds: new[] { 201 });
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.Count.ShouldBe(1);
proposals[0].Value.ShouldBe("The Office");
proposals[0].Name.ShouldBe("The Office");
proposals[0].ItemCount.ShouldBe(3);
proposals[0].Number.ShouldBe("500");
proposals[0].AlreadyExists.ShouldBeFalse();
}
[Test]
public async Task Flags_AlreadyExists_By_Channel_Name_And_Skips_Taken_Numbers()
{
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102 });
await SeedChannel(number: "500", name: "The Office");
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 1, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals[0].AlreadyExists.ShouldBeTrue();
proposals[0].Number.ShouldBe("501"); // 500 is taken
}
[Test]
public async Task Enumerates_Movie_Genres_With_Suffixed_Names()
{
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: "Action");
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: "Action");
await SeedMovieWithGenre(movieId: 3, metadataId: 3, genre: "Drama");
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.Count.ShouldBe(1); // Drama has only 1 movie, below minItems
proposals[0].Value.ShouldBe("Action");
proposals[0].Name.ShouldBe("Action Movies");
proposals[0].ItemCount.ShouldBe(2);
}
[Test]
public async Task Excludes_Proposals_Whose_Generated_Name_Exceeds_50_Chars()
{
// "Movies" suffix (7 chars) pushes this over the 50-char Channel.Name limit.
const string longGenre = "A Really Really Long And Overly Descriptive Genre"; // 50 chars, +" Movies" = 57
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: longGenre);
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: longGenre);
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.ShouldNotContain(p => p.Value == longGenre);
proposals.ShouldBeEmpty();
}
[Test]
public async Task Empty_Axes_Is_Error()
{
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis>(), MinItems: 5, StartingNumber: 500));
LeftOf(result).Value.ShouldContain("axis");
}
private Task<Either<BaseError, List<AutoTuneProposal>>> Handle(PreviewAutoTuneChannels request) =>
new PreviewAutoTuneChannelsHandler(_db.Factory).Handle(request, CancellationToken.None);
private async Task SeedShow(int showId, string title, int seasonId, int[] episodeIds)
{
await using TvContext context = _db.CreateContext();
context.Shows.Add(new Show
{
Id = showId,
ShowMetadata = new List<ShowMetadata> { new() { ShowId = showId, Title = title } },
Seasons = new List<Season>
{
new()
{
Id = seasonId, ShowId = showId,
Episodes = episodeIds.Select(id => new Episode { Id = id, SeasonId = seasonId }).ToList()
}
}
});
await context.SaveChangesAsync();
}
private async Task SeedMovieWithGenre(int movieId, int metadataId, string genre)
{
await using TvContext context = _db.CreateContext();
context.Movies.Add(new Movie
{
Id = movieId,
MovieMetadata = new List<MovieMetadata>
{
new() { Id = metadataId, MovieId = movieId, Title = $"Movie {movieId}",
Genres = new List<Genre> { new() { Name = genre } } }
}
});
await context.SaveChangesAsync();
}
private async Task SeedChannel(string number, string name)
{
await using TvContext context = _db.CreateContext();
context.Channels.Add(new Channel(System.Guid.NewGuid())
{
Number = number, Name = name, Group = "Test", SortNumber = double.Parse(number)
});
await context.SaveChangesAsync();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> e) =>
e.Match(Left: x => x, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> e) =>
e.Match(Left: x => throw new AssertionException($"Expected Right, got {x.Value}"), Right: r => r);
}
@@ -0,0 +1,108 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.Configuration;
[TestFixture]
public class IptvSettingsHandlerTests
{
private const string Key = "iptv.base_url";
private IConfigElementRepository _configElementRepository = null!;
[SetUp]
public void SetUp() => _configElementRepository = Substitute.For<IConfigElementRepository>();
[Test]
public async Task Get_Returns_Empty_When_Unset()
{
_configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.None);
var handler = new GetIptvSettingsHandler(_configElementRepository);
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
result.BaseUrl.ShouldBe(string.Empty);
}
[Test]
public async Task Get_Returns_Stored_Value()
{
_configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("https://tv.example.com/etv"));
var handler = new GetIptvSettingsHandler(_configElementRepository);
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
result.BaseUrl.ShouldBe("https://tv.example.com/etv");
}
[Test]
public async Task Update_Upserts_Trimmed_Value_When_Valid()
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = " https://public.example.com/etv " }),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _configElementRepository.Received(1).Upsert(
Arg.Is<ConfigElementKey>(k => k.Key == Key),
"https://public.example.com/etv",
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public async Task Update_Clears_Setting_When_Blank(string blank)
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = blank }),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _configElementRepository.Received(1).Delete(
Arg.Is<ConfigElementKey>(k => k.Key == Key),
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Upsert(
Arg.Any<ConfigElementKey>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[TestCase("not a url")]
[TestCase("ftp://tv.example.com")]
[TestCase("http://user:pass@tv.example.com")]
[TestCase("http://tv.example.com?foo=bar")]
public async Task Update_Returns_Error_And_Persists_Nothing_When_Invalid(string invalid)
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = invalid }),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await _configElementRepository.DidNotReceive().Upsert(
Arg.Any<ConfigElementKey>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,49 @@
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Maintenance;
[TestFixture]
[NonParallelizable]
public class ReleaseMemoryHandlerTests
{
[Test]
public async Task Should_Use_Aggressive_Collection_When_No_Workers_Are_Active()
{
FFmpegProcess.ProcessCount.ShouldBe(0);
IFFmpegSegmenterService segmenterService = Substitute.For<IFFmpegSegmenterService>();
segmenterService.Workers.Returns([]);
ILogger<ReleaseMemoryHandler> logger = Substitute.For<ILogger<ReleaseMemoryHandler>>();
var handler = new ReleaseMemoryHandler(segmenterService, logger);
await handler.Handle(new ReleaseMemory(false), CancellationToken.None);
ShouldHaveLogged(logger, "Starting aggressive garbage collection");
}
[Test]
public async Task Should_Use_Regular_Collection_When_A_Worker_Is_Active()
{
FFmpegProcess.ProcessCount.ShouldBe(0);
IFFmpegSegmenterService segmenterService = Substitute.For<IFFmpegSegmenterService>();
segmenterService.Workers.Returns([Substitute.For<IHlsSessionWorker>()]);
ILogger<ReleaseMemoryHandler> logger = Substitute.For<ILogger<ReleaseMemoryHandler>>();
var handler = new ReleaseMemoryHandler(segmenterService, logger);
await handler.Handle(new ReleaseMemory(false), CancellationToken.None);
ShouldHaveLogged(logger, "Starting garbage collection");
}
private static void ShouldHaveLogged(ILogger<ReleaseMemoryHandler> logger, string expectedMessage) =>
logger.ReceivedCalls()
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() == expectedMessage)
.ShouldBeTrue();
}
@@ -0,0 +1,132 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Channel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Playouts;
[TestFixture]
public class ReshufflePlayoutHandlerTests
{
private InMemoryTvContext _db = null!;
private Channel<IBackgroundServiceRequest> _worker = null!;
private IMediator _mediator = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
_mediator = Substitute.For<IMediator>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private ReshufflePlayoutHandler CreateHandler() => new(_mediator, _worker.Writer, _db.Factory);
private async Task<int> SeedPlayout(PlayoutScheduleKind kind, int? seed = null)
{
await using TvContext context = _db.CreateContext();
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
if (seed.HasValue)
{
playout.Seed = seed.Value;
}
context.Playouts.Add(playout);
await context.SaveChangesAsync();
return playout.Id;
}
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task Handle_Should_Erase_History_Then_Enqueue_Reset_Build_For_Supported_Kind(
PlayoutScheduleKind kind)
{
int id = await SeedPlayout(kind);
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<ErasePlayoutHistory>(e => e.PlayoutId == id),
Arg.Any<CancellationToken>());
_worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
var build = request.ShouldBeOfType<BuildPlayout>();
build.PlayoutId.ShouldBe(id);
build.Mode.ShouldBe(PlayoutBuildMode.Reset);
_worker.Reader.TryRead(out _).ShouldBeFalse();
}
[TestCase(PlayoutScheduleKind.ExternalJson)]
[TestCase(PlayoutScheduleKind.None)]
public async Task Handle_Should_Not_Erase_History_Or_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
int id = await SeedPlayout(kind);
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
_worker.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task Handle_Should_Actually_Reseed_Block_Playout_Via_ErasePlayoutHistory()
{
// This is the C1 regression test: BuildPlayout(Reset) alone only reseeds Playout.Seed for
// Classic playouts (PlayoutBuilder). For Block/Sequential/Scripted it is a no-op reshuffle
// unless the handler routes through ErasePlayoutHistory first. Wire the substituted
// IMediator to actually invoke the real ErasePlayoutHistoryHandler against the in-memory DB
// so the reseed side effect is genuinely exercised, not merely asserted-as-called.
const int originalSeed = 12345;
int id = await SeedPlayout(PlayoutScheduleKind.Block, originalSeed);
// seed a PlayoutHistory row so we can also assert the deterministic "cleared" side effect
// (avoids relying solely on new Random().Next() != originalSeed, which is a ~1-in-2^31 flake)
await using (TvContext seedContext = _db.CreateContext())
{
seedContext.PlayoutHistory.Add(
new PlayoutHistory
{
PlayoutId = id,
Key = "test-collection",
When = DateTime.UtcNow,
Finish = DateTime.UtcNow
});
await seedContext.SaveChangesAsync();
}
_mediator.Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>())
.Returns(callInfo => new ErasePlayoutHistoryHandler(_db.Factory).Handle(
(ErasePlayoutHistory)callInfo[0],
(CancellationToken)callInfo[1]));
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await using TvContext context = _db.CreateContext();
Playout playout = await context.Playouts.SingleAsync(p => p.Id == id);
playout.Seed.ShouldNotBe(originalSeed);
List<PlayoutHistory> remainingHistory = await context.PlayoutHistory
.Where(h => h.PlayoutId == id)
.ToListAsync();
remainingHistory.ShouldBeEmpty();
}
}
@@ -118,6 +118,20 @@ public class ApiControllerSecurityTests
}
}
[Test]
public void Local_Library_Detail_Should_Require_Authentication_While_Catalog_List_Remains_Opt_Out()
{
MethodInfo detailAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetById))
?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetById)}");
MethodInfo listAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetAll))
?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetAll)}");
EffectiveRequiresAuthentication(typeof(LocalLibrariesController), detailAction)
.ShouldBeTrue("local-library detail exposes server filesystem paths and must stay authenticated");
EffectiveRequiresAuthentication(typeof(LocalLibrariesController), listAction)
.ShouldBeFalse("the ordinary local-library catalog should retain the read-auth opt-out");
}
[Test]
public void ScannerController_Should_Be_Localhost_Only()
{
@@ -131,6 +145,10 @@ public class ApiControllerSecurityTests
private static bool IsGloballyProtected() => ApiAuthorizationFilterIsGlobal;
private static bool EffectiveRequiresAuthentication(Type controllerType, MethodInfo action) =>
controllerType.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any() ||
action.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any();
private static bool IsApiAuthorizationFilterRegisteredGlobally()
{
var settings = new Dictionary<string, string?>
@@ -15,7 +15,6 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Filters;
using LanguageExt;
using static LanguageExt.Prelude;
using MediatR;
@@ -274,28 +273,6 @@ public class ChannelControllerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetPlaybackSource_Should_Require_Authentication_And_Map_Query()
{
DateTimeOffset at = new(2026, 7, 14, 20, 0, 0, TimeSpan.Zero);
var model = new ChannelPlaybackSourceResponseModel(7, 7, at, at, null, null);
_mediator.Send(Arg.Any<GetChannelPlaybackSource>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelPlaybackSourceResponseModel>.Some(model));
IActionResult result = await _controller.GetPlaybackSource(7, at, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
await _mediator.Received(1).Send(
Arg.Is<GetChannelPlaybackSource>(q => q.ChannelId == 7 && q.At == at),
Arg.Any<CancellationToken>());
MethodInfo method = typeof(ChannelController).GetMethod(nameof(ChannelController.GetPlaybackSource))!;
method.GetCustomAttribute<RequiresAuthenticationAttribute>().ShouldNotBeNull();
ResponseCacheAttribute cache = method.GetCustomAttribute<ResponseCacheAttribute>()!;
cache.NoStore.ShouldBeTrue();
cache.Location.ShouldBe(ResponseCacheLocation.None);
}
[Test]
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
{
@@ -507,6 +484,7 @@ public class ChannelControllerTests
null,
null,
null,
0,
0);
private static ChannelDetailResponseModel MakeDetailModel(int id) =>
@@ -1,4 +1,3 @@
using System.Text.Json;
using ErsatzTV.Serialization;
using ErsatzTV.Tests.Support;
using Microsoft.OpenApi;
@@ -72,32 +71,6 @@ public class OpenApiContractHonestyTests
}
}
[Test]
public async Task Serialized_Gated_Operations_Should_Name_The_ApiKey_Scheme()
{
string json = await _document.SerializeAsJsonAsync(
OpenApiSpecVersion.OpenApi3_1,
CancellationToken.None);
using JsonDocument serialized = JsonDocument.Parse(json);
foreach (JsonProperty path in serialized.RootElement.GetProperty("paths").EnumerateObject())
{
foreach (JsonProperty operation in path.Value.EnumerateObject())
{
if (operation.NameEquals("parameters"))
{
continue;
}
JsonElement security = operation.Value.GetProperty("security");
security.GetArrayLength().ShouldBeGreaterThan(0, $"{operation.Name} {path.Name} should declare security");
security[0].TryGetProperty(ApiSecurityOperationTransformer.SchemeName, out JsonElement scopes)
.ShouldBeTrue($"{operation.Name} {path.Name} should name the ApiKey scheme after serialization");
scopes.ValueKind.ShouldBe(JsonValueKind.Array);
}
}
}
[Test]
public void Troubleshoot_Playback_Actions_Should_Carry_Stable_Explicit_OperationIds()
{
@@ -70,6 +70,7 @@ public class PlayoutControllerTests
nameof(PlayoutController.EraseItemsAndHistory),
"POST",
"/api/v1/playouts/{id:int}/erase-items-and-history");
ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle");
ShouldHaveActionRoute(
nameof(PlayoutController.GetItemSchedulingContext),
"GET",
@@ -152,6 +153,30 @@ public class PlayoutControllerTests
result.Page.Single().IsLocked.ShouldBeTrue();
}
[Test]
public async Task GetById_Should_Surface_Seed()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));
IActionResult result = await _controller.GetById(9, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>()
.Value.ShouldBeOfType<PlayoutResponseModel>()
.Seed.ShouldBe(4242);
}
[Test]
public async Task GetAll_Should_Surface_Seed()
{
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { MakePlayout(9) with { Seed = 4242 } }));
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
result.Page[0].Seed.ShouldBe(4242);
}
// ----- Erase items / history -----
[Test]
@@ -237,6 +262,62 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
// ----- Reshuffle -----
[Test]
public async Task Reshuffle_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.ExternalJson)]
[TestCase(PlayoutScheduleKind.None)]
public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
await _mediator.Received(1).Send(
Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
// ----- Playout item scheduling context -----
[Test]
@@ -1341,6 +1422,7 @@ public class PlayoutControllerTests
new PlayoutBuildStatus(),
null,
null,
0,
0);
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
@@ -1361,7 +1443,8 @@ public class PlayoutControllerTests
vm.BuildStatus.Message),
vm.DecoId,
vm.DecoName,
isLocked);
isLocked,
vm.Seed);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
@@ -242,6 +242,48 @@ public class SettingsControllerTests
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetIptv_Should_Map_Vm_To_Response_Model()
{
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(new IptvSettingsViewModel { BaseUrl = "https://tv.example.com/etv" });
IptvSettingsResponseModel result = await _controller.GetIptv(CancellationToken.None);
result.ShouldBe(new IptvSettingsResponseModel("https://tv.example.com/etv"));
}
[Test]
public async Task UpdateIptv_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
{
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(new IptvSettingsViewModel { BaseUrl = "https://public.example.com" });
IActionResult result = await _controller.UpdateIptv(
new UpdateIptvSettingsRequest("https://public.example.com"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new IptvSettingsResponseModel("https://public.example.com"));
await _mediator.Received(1).Send(
Arg.Is<UpdateIptvSettings>(c => c.IptvSettings.BaseUrl == "https://public.example.com"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateIptv_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateIptv(
new UpdateIptvSettingsRequest("not a url"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetScanner_Should_Return_Library_Refresh_Interval()
{
+4
View File
@@ -11,6 +11,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers">
-47
View File
@@ -32,14 +32,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integrations", "integrations", "{4958D7D8-4791-2CCE-6FFA-082B65933577}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "jellyfin", "jellyfin", "{65793B68-0114-8A23-3D53-9EDEAEBFFD0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ChicoryTV", "integrations\jellyfin\Jellyfin.Plugin.ChicoryTV\Jellyfin.Plugin.ChicoryTV.csproj", "{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ChicoryTV.Tests", "integrations\jellyfin\Jellyfin.Plugin.ChicoryTV.Tests\Jellyfin.Plugin.ChicoryTV.Tests.csproj", "{10235034-68F6-44AC-8C54-4C6F12DAD534}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -323,42 +315,6 @@ Global
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x64.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x64.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x86.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x86.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|Any CPU.Build.0 = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x64.ActiveCfg = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x64.Build.0 = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x86.ActiveCfg = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x86.Build.0 = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x86.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x64.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x64.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x86.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x86.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|Any CPU.Build.0 = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x64.ActiveCfg = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x64.Build.0 = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x86.ActiveCfg = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x86.Build.0 = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -369,8 +325,5 @@ Global
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{2EF80455-953D-4696-831D-E8CBCA82B0EF} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{56F56E76-CEF4-4639-B7BB-03FD201BB019} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{65793B68-0114-8A23-3D53-9EDEAEBFFD0F} = {4958D7D8-4791-2CCE-6FFA-082B65933577}
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8} = {65793B68-0114-8A23-3D53-9EDEAEBFFD0F}
{10235034-68F6-44AC-8C54-4C6F12DAD534} = {65793B68-0114-8A23-3D53-9EDEAEBFFD0F}
EndGlobalSection
EndGlobal
+41 -23
View File
@@ -11,7 +11,6 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -49,28 +48,6 @@ public class ChannelController(
CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
[HttpGet("/api/v1/channels/{id:int}/playback-source")]
[Tags("Channels")]
[EndpointSummary("Resolve the scheduled playback source for a channel")]
[EndpointDescription(
"Returns the physical media item and in-file offset selected by the channel schedule at the requested " +
"time. This read-only endpoint never starts an ErsatzTV transcoder. at defaults to now.")]
[EndpointGroupName("general")]
[RequiresAuthentication]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[ProducesResponseType(typeof(ChannelPlaybackSourceResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPlaybackSource(
int id,
[FromQuery] DateTimeOffset? at,
CancellationToken cancellationToken)
{
Option<ChannelPlaybackSourceResponseModel> result = await mediator.Send(
new GetChannelPlaybackSource(id, at ?? DateTimeOffset.UtcNow),
cancellationToken);
return result.ToGetResult();
}
[HttpGet("/api/v1/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
[Tags("Channels")]
[EndpointSummary("Get available music video credits template names")]
@@ -237,6 +214,47 @@ public class ChannelController(
return result.ToDeletedResult();
}
[HttpPost("/api/v1/channels/auto-tune/preview", Name = "PreviewAutoTuneChannels")]
[Tags("Channels")]
[EndpointSummary("Preview auto-tuned channels")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<AutoTuneProposalResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> PreviewAutoTune(
[Required][FromBody] PreviewAutoTuneChannelsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, List<AutoTuneProposal>> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(
Left: error => error.ToErrorResult(),
Right: proposals => new OkObjectResult(proposals.Select(ProjectToResponseModel).ToList()));
}
[HttpPost("/api/v1/channels/auto-tune", Name = "CreateAutoTunedChannels")]
[Tags("Channels")]
[EndpointSummary("Create auto-tuned channels")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(AutoTuneResultResponseModel), StatusCodes.Status200OK)]
public async Task<IActionResult> CreateAutoTuned(
[Required][FromBody] CreateAutoTunedChannelsRequest request,
CancellationToken cancellationToken)
{
AutoTuneResult result = await mediator.Send(request.ToCommand(), cancellationToken);
return new OkObjectResult(ProjectToResponseModel(result));
}
private static AutoTuneProposalResponseModel ProjectToResponseModel(AutoTuneProposal p) =>
new(p.Axis.ToString(), p.Value, p.Name, p.Number, p.ItemCount, p.AlreadyExists);
private static AutoTuneResultResponseModel ProjectToResponseModel(AutoTuneResult r) =>
new(
r.Results.Select(o => new AutoTuneChannelResultModel(
o.Name, o.Status.ToString(), o.ChannelId, o.Reason)).ToList(),
r.CreatedCount,
r.SkippedCount,
r.FailedCount);
[HttpPost("/api/v1/channels/{id:int}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -40,6 +41,7 @@ public class LocalLibrariesController(
[HttpGet("/api/v1/libraries/local/{id:int}", Name = "GetLocalLibrary")]
[Tags("Libraries")]
[EndpointSummary("Get a local library by id")]
[RequiresAuthentication]
[ProducesResponseType(typeof(LocalLibraryDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
+43 -2
View File
@@ -683,6 +683,45 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
return NoContent();
}
[HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")]
[Tags("Playouts")]
[EndpointSummary("Reshuffle a playout")]
[EndpointDescription(
"Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it " +
"and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Reshuffle(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
{
return BaseError.New(
"[Reshuffle] is only valid for Classic, Block, Sequential, or Scripted playouts")
.ToErrorResult();
}
}
await mediator.Send(new ReshufflePlayout(id), cancellationToken);
return Accepted();
}
[HttpGet("/api/v1/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
[Tags("Playouts")]
[EndpointSummary("Decode a playout item's scheduling context")]
@@ -786,7 +825,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
ToBuildStatus(vm.BuildStatus),
vm.DecoId,
vm.DecoName,
isLocked);
isLocked,
vm.Seed);
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
new(
@@ -835,7 +875,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus),
vm.PlayoutMode,
isLocked);
isLocked,
vm.Seed);
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
@@ -0,0 +1,30 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record PreviewAutoTuneChannelsRequest(
List<AutoTuneAxis> Axes,
int MinItems,
int StartingNumber)
{
public PreviewAutoTuneChannels ToCommand() => new(Axes, MinItems, StartingNumber);
}
public record CreateAutoTunedChannelsRequest(
int TemplateId,
string Group,
List<AutoTunedChannelRequest> Channels)
{
public CreateAutoTunedChannels ToCommand() =>
new(TemplateId, Group, (Channels ?? []).Select(c => c.ToCommand()).ToList());
}
public record AutoTunedChannelRequest(
AutoTuneAxis Axis,
string Value,
string Name,
string Number)
{
public AutoTuneChannelSelection ToCommand() => new(Axis, Value, Name, Number);
}
@@ -0,0 +1,13 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
/// absolute M3U/XMLTV URLs; send an empty string to clear it and use the incoming request's origin.
/// </summary>
public record UpdateIptvSettingsRequest(string BaseUrl)
{
public UpdateIptvSettings ToCommand() =>
new(new IptvSettingsViewModel { BaseUrl = BaseUrl });
}
@@ -121,6 +121,39 @@ public class SettingsController(IMediator mediator) : ControllerBase
});
}
// IPTV settings
[HttpGet("/api/v1/settings/iptv", Name = "GetIptvSettings")]
[Tags("Settings")]
[EndpointSummary("Get IPTV output settings")]
[ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<IptvSettingsResponseModel> GetIptv(CancellationToken cancellationToken)
{
IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/v1/settings/iptv", Name = "UpdateIptvSettings")]
[Tags("Settings")]
[EndpointSummary("Update IPTV output settings")]
[ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateIptv(
[Required] [FromBody]
UpdateIptvSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// Scanner settings
[HttpGet("/api/v1/settings/scanner", Name = "GetScannerSettings")]
@@ -274,6 +307,9 @@ public class SettingsController(IMediator mediator) : ControllerBase
private static PlayoutSettingsResponseModel ProjectToResponseModel(PlayoutSettingsViewModel vm) =>
new(vm.DaysToBuild, vm.SkipMissingItems, vm.ScriptedScheduleTimeoutSeconds);
private static IptvSettingsResponseModel ProjectToResponseModel(IptvSettingsViewModel vm) =>
new(vm.BaseUrl);
private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) =>
new(vm.DaysToBuild, ToApiTimeZone(vm.TimeZone), ToApiBlockBehavior(vm.BlockBehavior));
-1
View File
@@ -11,7 +11,6 @@
<Configurations>Debug;Release;Debug No Sync</Configurations>
<Platforms>AnyCPU</Platforms>
<UserSecretsId>bf31217d-f4ec-4520-8cc3-138059044ede</UserSecretsId>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
<NeutralLanguage>en-US</NeutralLanguage>
@@ -36,11 +36,7 @@ public sealed class ApiSecurityOperationTransformer(IApiKeyProvider apiKeyProvid
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement
{
// Microsoft.OpenApi 2.x needs the host document to serialize this as the
// component name. Without it the in-memory requirement looks populated,
// but the generated JSON contains `security: [ {} ]`, which means anonymous
// access in OpenAPI rather than the X-Api-Key requirement enforced at runtime.
[new OpenApiSecuritySchemeReference(SchemeName, context.Document, null)] = new List<string>()
[new OpenApiSecuritySchemeReference(SchemeName)] = new List<string>()
});
operation.Responses ??= new OpenApiResponses();
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Coverage collector config (ersatztv#15). Passed to dotnet test via
"settings coverlet.runsettings" alongside collect:"XPlat Code Coverage".
The two EF Core migration folders are ~2.59M lines of generated snapshot code
(vs ~200k lines of authored code); instrumenting them balloons coverlet memory
and OOM killed the shared Build and test job (exit 137). Excluding generated
migration code bounds memory and makes the reported percentage reflect authored
code. Keep this comment free of double hyphens (invalid in XML comments).
-->
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<ExcludeByFile>**/Migrations/*.cs</ExcludeByFile>
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
<Exclude>[*]*.Migrations.*</Exclude>
<SkipAutoProps>true</SkipAutoProps>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
@@ -0,0 +1,900 @@
// Auto-Tune — generate a whole channel lineup from library metadata (#69).
// The "automatic-first" creation mode alongside the manual Channel Builder.
// One screen, three steps: Configure axes/defaults -> Preview proposed channels
// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
// Additive & non-destructive: never edits or deletes an existing channel; number
// or name collisions are skipped, never overwritten. Each generated channel is
// backed by a live SmartCollection query so it keeps tracking the library.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
// ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
// nameOf() and order are display-only here; the real server owns query + name.
const AXES = [
{ id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
{ id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
{ id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
];
const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
// ---- Mock library metadata (EF distinct+count in the real app) ------------
const LIBRARY = {
TvShow: [
{ value: "The Office", count: 201 },
{ value: "Friends", count: 236 },
{ value: "Breaking Bad", count: 62 },
{ value: "Parks and Recreation", count: 125 },
{ value: "The Twilight Zone", count: 156 },
{ value: "Firefly", count: 3 }, // below default minItems -> filtered
],
TvGenre: [
{ value: "Comedy", count: 640 },
{ value: "Drama", count: 512 },
{ value: "Sci-Fi", count: 208 },
{ value: "Crime", count: 174 },
],
MovieGenre: [
{ value: "Action", count: 42 },
{ value: "Sci-Fi", count: 28 },
{ value: "Horror", count: 35 },
{ value: "Comedy", count: 51 },
{ value: "Noir", count: 3 }, // below default minItems -> filtered
],
};
// Coexistence demo: some names already exist (deduped, unchecked by default)
// and some numbers are already taken (allocation skips them).
const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
const TAKEN_NUMBERS = new Set(["500", "503"]);
const TEMPLATES = ["Standard", "Movie night", "Music videos"];
// ---- Preview computation (advisory numbers, re-validated at create) -------
function buildProposals(axisIds, minItems, startingNumber) {
const out = [];
let next = Math.max(1, startingNumber | 0);
const takenThisRun = new Set(TAKEN_NUMBERS);
const alloc = () => {
while (takenThisRun.has(String(next))) next++;
const n = String(next);
takenThisRun.add(n);
next++;
return n;
};
// Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
AXES.forEach((ax) => {
if (!axisIds.has(ax.id)) return;
LIBRARY[ax.id]
.filter((row) => row.count >= minItems)
.slice()
.sort((a, b) => a.value.localeCompare(b.value))
.forEach((row) => {
const name = ax.nameOf(row.value);
const exists = EXISTING_NAMES.has(name);
out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
});
});
return out;
}
// ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
// explicit initials + color (ChannelLogo only derives them from name). --
const BUG_PALETTE = [
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
];
const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
function Bug({ name, initials, ci, size = 32 }) {
const idx = ci != null ? ci : bugHash(name);
const [fg, bg] = BUG_PALETTE[idx];
return (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
</span>
);
}
// ---- Example content sources (advisory sample of the smart-collection query)
const GENRE_SHOWS = {
Comedy: ["The Office", "Friends", "Parks and Recreation"],
Drama: ["Breaking Bad", "The Twilight Zone"],
"Sci-Fi": ["The Twilight Zone", "Firefly"],
Crime: ["Breaking Bad"],
};
const MOVIE_TITLES = {
Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
"Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
Horror: ["Hollow", "The Vigil", "Saltmarsh"],
Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
Noir: ["Rain on 5th", "The Long Con"],
};
const isGenreAxis = (axis) => axis !== "TvShow";
function baseSources(p) {
if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
return []; // TvShow channels track a single show — no per-source editor
}
function effectiveSources(p, ov) {
const ex = ov.exclude || [];
return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
}
// Advisory item-count estimate as sources are added/removed (per-source share).
function estItems(p, ov) {
const base = baseSources(p);
if (!base.length) return p.itemCount;
const share = p.itemCount / base.length;
return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
}
// Sources with their rotation weight (episodes played per rotation).
function sourceList(p, ov) {
return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
}
// Compact rotation-weight stepper ( N +).
function Weight({ w, onChange }) {
const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
return (
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
</div>
);
}
// ---- Example schedule (rundown → EPG blocks), advisory only ---------------
// Effective order for a channel: the axis default, unless overridden per channel.
function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
function buildSchedule(p, ov) {
const mins = RUNTIME[p.axis] || 30;
const shuffled = isShuffled(p, ov);
let seq;
if (p.axis === "TvShow") {
const n = Math.min(8, Math.max(3, p.itemCount));
let eps = Array.from({ length: n }, (_, i) => i + 1);
if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
} else {
const list = sourceList(p, ov).filter((s) => s.weight > 0);
if (!list.length) return [];
if (shuffled) {
// Weighted round-robin interleave — honors the per-source rotation ratio.
const total = list.reduce((a, s) => a + s.weight, 0);
const st = list.map((s) => ({ ...s, acc: 0 }));
const count = Math.min(9, Math.max(5, total * 2));
seq = Array.from({ length: count }, () => {
let pick = null;
st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
pick.acc -= total;
return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
});
} else {
// Sequential — each source plays its rotation count in turn.
seq = [];
list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
seq = seq.slice(0, 9);
}
}
let t = 20 * 60;
return seq.map((b) => {
const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
t += mins;
return { ...b, start, mins };
});
}
function MiniEpg({ blocks }) {
if (!blocks.length) return (
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches nothing to schedule.</div>
);
const PPM = 2.0;
return (
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
{blocks.map((b, i) => (
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
</div>
))}
</div>
</div>
);
}
function ConfigRow({ label, value, first }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
</div>
);
}
// ---- Full channel settings (mirrors the manual Channel Builder) -----------
const CH_TEMPLATES = [
{ id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
{ id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
{ id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
];
const ADV_GROUPS = [
{ group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
{ group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
{ group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
{ group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
];
// Friendly toggle row (label + description + Switch), with an override tag.
function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
return (
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
</div>
<Switch checked={checked} onChange={onChange} />
</label>
);
}
// Channel Template picker (collapsed row + dropdown + spec chips).
function TemplatePicker({ value, onPick }) {
const [open, setOpen] = React.useState(false);
const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
return (
<div>
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
</div>
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
</button>
{open && (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{CH_TEMPLATES.map((x) => {
const on = x.id === value;
return (
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
</div>
</button>
);
})}
</div>
)}
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
{t.sets.map((s) => (
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
))}
</div>
</div>
);
}
// Advanced (~24-field) override disclosure: View defaults / Override.
function AdvancedSettings({ ov, patch, templateName }) {
const [mode, setMode] = React.useState("closed"); // closed | view | override
const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
const vals = { ...defaults, ...(ov.adv || {}) };
const override = mode === "override";
const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
const overridden = Object.keys(ov.adv || {}).length;
const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
return (
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
<span style={{ flex: 1 }} />
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
</div>
{mode !== "closed" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
</div>
{ADV_GROUPS.map((g) => (
<div key={g.group}>
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k, v]) => override ? (
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
) : (
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
// Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
function LogoDrop({ name, src, onSet }) {
const [over, setOver] = React.useState(false);
const fileRef = React.useRef(null);
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
return (
<div onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
onClick={() => fileRef.current && fileRef.current.click()}
style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
</div>
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
</div>
);
}
// ---- Channel detail slide-over: full per-channel settings -----------------
function DetailPanel({ p, ov, patch, onClose }) {
const [addText, setAddText] = React.useState("");
const name = ov.customName != null ? ov.customName : p.name;
const bug = ov.bug || {};
const ax = AXIS[p.axis];
const number = ov.number != null ? ov.number : p.number;
const shuffled = isShuffled(p, ov);
const always = ov.always != null ? ov.always : true;
const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
const avatar = (size) => bug.src
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
const genre = isGenreAxis(p.axis);
const base = baseSources(p);
const ex = ov.exclude || [];
const inc = ov.include || [];
const kept = base.filter((s) => !ex.includes(s));
const items = estItems(p, ov);
const schedule = buildSchedule(p, ov);
const ratios = ov.ratios || {};
const wOf = (s) => ratios[s] || 1;
const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
const list = [...kept, ...inc];
const multi = list.length > 1;
const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
const addInc = () => {
const v = addText.trim();
if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
setAddText("");
};
return (
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
{avatar(34)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
</div>
</div>
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Channel identity</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
</label>
</div>
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
{avatar(48)}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
</label>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
{BUG_PALETTE.map((pair, i) => {
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
return (
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
</button>
);
})}
</div>
</div>
</div>
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={eyebrow}>Playback</div>
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Channel Template</div>
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Query &amp; size</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
</div>
</section>
{genre && (
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Content sources</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged {p.value}. Exclude a title even though it matches, add one that isnt tagged, or set how often each plays.</span>
</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
{list.length === 0 && (
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources add one below.</div>
)}
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
</div>
))}
</div>
{multi && (
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
</span>
)}
<div style={{ display: "flex", gap: 8 }}>
<div style={{ flex: 1 }}>
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
</div>
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
</div>
{ex.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
{ex.map((s) => (
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
</button>
))}
</div>
)}
</section>
)}
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Example schedule</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 the built playout may differ.</span>
</div>
<MiniEpg blocks={schedule} />
</section>
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
</div>
</aside>
</div>
);
}
// =========================================================================
function AutoTune() {
const [step, setStep] = React.useState("configure"); // configure | preview | create
const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
const [minItems, setMinItems] = React.useState("5");
const [startingNumber, setStartingNumber] = React.useState("500");
const [group, setGroup] = React.useState("Auto-Tuned");
const [template, setTemplate] = React.useState("Standard");
const [proposals, setProposals] = React.useState([]);
const [selected, setSelected] = React.useState(() => new Set());
const [results, setResults] = React.useState(null);
const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
const [detail, setDetail] = React.useState(null); // proposal name open in the panel
const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
const patchOv = (name) => (patch) => setOverrides((o) => {
const cur = o[name] || {};
const delta = typeof patch === "function" ? patch(cur) : patch;
return { ...o, [name]: { ...cur, ...delta } };
});
const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
const toggleAxis = (id) =>
setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const runPreview = () => {
const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
setProposals(p);
// Default selection: everything that isn't an already-existing name.
setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
setStep("preview");
};
const runCreate = () => {
// Demo the three outcome states across the selected set.
const chosen = proposals.filter((p) => selected.has(p.name));
const res = chosen.map((p, i) => {
const nm = dispName(p);
if (i === chosen.length - 1 && chosen.length > 2)
return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
if (p.name === "Sci-Fi Movies")
return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
});
setResults(res);
setStep("create");
};
const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
<Toolbar
step={step}
axisCount={axisIds.size}
selectedCount={selected.size}
onPreview={runPreview}
onCreate={runCreate}
onBack={() => setStep("configure")}
onRestart={restart}
/>
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{step === "configure" && (
<Configure
axisIds={axisIds} toggleAxis={toggleAxis}
minItems={minItems} setMinItems={setMinItems}
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
group={group} setGroup={setGroup}
template={template} setTemplate={setTemplate}
/>
)}
{step === "preview" && (
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
openDetail={setDetail} dispName={dispName} />
)}
{step === "create" && <Results results={results} group={group} />}
</main>
{detail != null && (() => {
const p = proposals.find((x) => x.name === detail);
if (!p) return null;
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
})()}
</div>
);
}
// ---- Toolbar with step rail + contextual primary action -------------------
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Sparkles" s={19} /></span>
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
</div>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<StepRail step={step} />
</div>
{step === "configure" && (
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
</Tooltip>
)}
{step === "preview" && (
<React.Fragment>
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
</Button>
</Tooltip>
</React.Fragment>
)}
{step === "create" && (
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
)}
</div>
);
}
const STEPS = [
{ id: "configure", label: "Configure" },
{ id: "preview", label: "Preview" },
{ id: "create", label: "Create" },
];
function StepRail({ step }) {
const idx = STEPS.findIndex((s) => s.id === step);
return (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{STEPS.map((s, i) => {
const done = i < idx, active = i === idx;
const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
return (
<React.Fragment key={s.id}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
<span style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
</div>
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
</React.Fragment>
);
})}
</div>
);
}
// ---- Step 1: Configure ----------------------------------------------------
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
preview the proposed channels, then create the ones you want. Existing channels are never touched.
</p>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Generate from</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
{AXES.map((ax) => {
const on = axisIds.has(ax.id);
return (
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
className="ctv-press"
style={{
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
}}>
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
</span>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
</div>
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</div>
</button>
);
})}
</div>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Defaults</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
</Field>
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
</Field>
<Field label="Channel group" hint="Every generated channel lands in this group.">
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
</Field>
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
</Field>
</div>
</section>
</div>
);
}
function Field({ label, hint, children }) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
{children}
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
</label>
);
}
// ---- Step 2: Preview ------------------------------------------------------
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
const selectable = proposals.filter((p) => !p.alreadyExists);
const existingCount = proposals.length - selectable.length;
if (!proposals.length) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
<Ico n="SearchX" s={26} />
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched try lowering the minimum items.</div>
</div>
);
}
const setAll = (rows, on) => setSelected((s) => {
const n = new Set(s);
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
return n;
});
return (
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
</span>
{existingCount > 0 && (
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist deselected</Tag>
)}
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
</div>
{groups.map(({ ax, rows }) => {
const groupSel = rows.filter((r) => !r.alreadyExists);
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
const someOn = groupSel.some((r) => selected.has(r.name));
return (
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
<Ico n={ax.icon} s={15} />
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
<Badge tone="neutral">{rows.length}</Badge>
<span style={{ flex: 1 }} />
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</span>
</div>
<div>
{rows.map((r, i) => {
const on = selected.has(r.name);
const ov = overrides[r.name] || {};
const name = dispName(r);
const isExp = expanded.has(r.name);
return (
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from {r.value}</div>
</button>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
</span>
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span>&nbsp;items</Badge>
{r.alreadyExists ? (
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
) : (
<React.Fragment>
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
</IconButton>
</React.Fragment>
)}
</div>
{isExp && !r.alreadyExists && (
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={eyebrow}>Example schedule</span>
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
</div>
<MiniEpg blocks={buildSchedule(r, ov)} />
</div>
)}
</div>
);
})}
</div>
</section>
);
})}
</div>
);
}
// ---- Step 3: Results ------------------------------------------------------
const RESULT_META = {
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
};
function Results({ results, group }) {
const count = (s) => results.filter((r) => r.status === s).length;
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
</div>
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
{results.map((r, i) => {
const m = RESULT_META[r.status];
return (
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
</div>
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
<Badge tone={m.tone}>{r.status}</Badge>
</div>
);
})}
</section>
</div>
);
}
window.CTVAutoTune = AutoTune;
})();
@@ -0,0 +1,167 @@
# Handoff: ChicoryTV — Auto-Tune (generate channels from library metadata, #69)
## Overview
**Auto-Tune** is the *automatic-first* channel-creation mode — the counterpart to the manual
**Channel Builder** ("New Channel"). Instead of building one channel by hand, it enumerates your
library's metadata along one or more **axes** (TV Shows, TV Genres, Movie Genres), **previews** the
whole set of proposed channels so you can pick which to keep, then **bulk-creates** the selected
ones. It is **additive and non-destructive**: it never edits or deletes an existing channel; name
and number collisions are *skipped*, never overwritten. Each generated channel is backed by a live
**SmartCollection** query, so a "Comedy" channel keeps picking up new comedies as the library grows.
Concept borrowed from **PseudoTV Live's** signature Auto-Tuning, deliberately fixing its two
weaknesses: PseudoTV is all-or-nothing per category with no preview, and it wipes+rebuilds the whole
lineup on every run. Ours adds a preview/select step and is non-destructive.
The **backend already shipped** (PR1, ersatztv#379) — two endpoints under the frozen `/api/v1`. This
handoff is **PR2: the SPA screen** that drives them.
> **Scope note (2026-07-16).** The prototype (`AutoTune.jsx`) was iterated to add a per-channel
> **DetailPanel** slide-over (opened from a "Configure" button on each Preview row — full per-channel
> editor: identity/image, shuffle/always-playing, template + Advanced overrides, weighted content
> sources, exclude/add-untagged, example schedule). That panel needs backend the PR1 endpoints don't
> have, so **it is DEFERRED to a follow-up arc — ersatztv#383** (children #384 enumerate members,
> #385 per-channel overrides + rotation weights in create, #386 the SPA panel). **PR2 (this handoff)
> implements the 3-step wizard only — Configure → Preview → Create — on the existing PR1 endpoints;
> no per-row Configure button.** The DetailPanel sections below the wizard spec are recorded for the
> #383 arc, not PR2.
## About the design files
The files in this bundle are **design references built in HTML/React** (a prototype on the ChicoryTV
UI kit) — **not production code**. Recreate the design in the target codebase (the ChicoryTV React
SPA, `web/`) using its real components (`web/src/components/`) and CSS-custom-property tokens. The
prototype (`AutoTune.jsx`) is the source of truth for **layout, spacing, motion, and interaction**;
its mock data and mock outcome logic are illustrative only — the real screen calls the API.
## Fidelity
**High-fidelity.** Colors, typography, spacing, radii, and interactions are resolved and use tokens
throughout, so it is fully theme-aware (verified in the warm + cool accent themes). All values below
are exact.
---
## The screen — a 3-step wizard
One full-height column: a fixed **toolbar** (`12px 20px` padding, `1px solid var(--border-hairline)`
bottom border) over a scrolling `<main>`. The toolbar is constant across steps; its content and
primary action change per step.
**Toolbar (all steps):**
- Left: a 34×34 accent **icon tile** (`var(--radius-sm)`, `background: var(--ctv-accent-soft)`,
`color: var(--ctv-accent)`) with the `Sparkles` icon; then title **"Auto-Tune"** (`--text-md`,
semibold) + subtitle **"Generate channels from your library"** (`--text-xs`, `--text-secondary`).
- Center: a **step rail** — three steps (`Configure · Preview · Create`) joined by 26px hairline
connectors. Each step is a 20px round chip + label. The **active** step: chip
`background var(--ctv-accent)` / `color var(--text-on-accent)`, label accent + semibold. A
**completed** step: chip `var(--ctv-accent-soft)` bg + accent `Check` icon, label
`--text-secondary`. A **future** step: chip `var(--ctv-surface-2)` + `--text-disabled`.
- Right: the contextual primary action (below).
### Step 1 — Configure
Centered column, `max-width: 860px`, `padding: 26px 24px 40px`.
1. **Intro paragraph** (`--text-sm`, `--text-secondary`, `max-width: 620px`): what Auto-Tune does +
the non-destructive promise.
2. **"Generate from"** section (eyebrow label) — a **3-column grid** of selectable **axis cards**
(one per axis). Each card is a `<button>` (`ctv-press` for the tactile scale-on-press):
- Selected: `1px solid var(--ctv-accent)` border, `var(--ctv-accent-soft)` bg, `var(--shadow-sm)`;
a filled accent `CheckCircle2` top-right; the icon tile is `var(--ctv-accent)` /
`var(--text-on-accent)`.
- Unselected: `1px solid var(--border-control)`, `var(--surface-card)` bg; a `Circle` outline
top-right (`--text-disabled`); icon tile `var(--ctv-surface-2)` / `--text-secondary`.
- Content: axis icon (Tv / Clapperboard / Film), **title**, one-line **tagline**, and a bottom
**order** chip (`ListOrdered` "Episode order" for TV Shows; `Shuffle` "Shuffled" for the genres).
3. **"Defaults"** section — a 2-column grid of four labelled fields, each with a helper line
(`Field` component: label `--text-xs` medium, hint `--text-2xs` `--text-disabled`):
- **Minimum items** (`Input type=number`, `Hash` leading icon) — skip channels below this count.
- **Starting channel number** (`Input type=number`, `Tv` icon) — numbers count up from here.
- **Channel group** (`Input`, `FolderTree` icon) — the group every generated channel lands in.
- **Channel template** (`Select`) — the batch's streaming/playout/filler defaults.
**Primary action:** `Preview channels` (primary, `Eye` icon). **Disabled** with a tooltip until ≥1
axis is selected.
### Step 2 — Preview
Centered column, `max-width: 920px`.
- **Summary bar:** `N of M selected` (mono N), an info **Tag** `"{k} already exist — deselected"`
when any proposal's name collides, a spacer, then ghost **Select all** / **Clear** buttons (they
only touch selectable — non-existing — rows).
- **One section per axis** that produced rows (`var(--surface-card)`, hairline border,
`var(--radius-md)`), in axis order (TV Shows → TV Genres → Movie Genres). Section header
(`var(--ctv-bg-sunken)`): a **group checkbox** (tri-state: checked / indeterminate when partial),
the axis icon + title, a neutral **Badge** with the row count, and a right-aligned order eyebrow.
- **Rows** (hairline-separated): per-row **Checkbox**, the allocated **number** (mono,
`min-width 42`), the channel **name** (semibold, ellipsis) over a `from "{value}"` sub-line, a
neutral **Badge** `{itemCount} items`, and — for an already-existing name — an `Exists` **Tag**.
Existing rows render at `opacity: 0.55` with a **disabled, unchecked** checkbox (dedup: you can't
re-create a channel that already exists by that name).
- **Empty state** (no proposals matched): centered `SearchX` + "try lowering the minimum items".
**Primary action:** `Create {N} channels` (primary, `Check` icon; label pluralizes; disabled until
≥1 selected) preceded by a ghost `Back` (`ArrowLeft`) that returns to Configure.
### Step 3 — Create (results)
Centered column, `max-width: 860px`.
- **Three `Stat` tiles** across the top: **Created / Skipped / Failed** counts (icons
`CheckCircle2` / `MinusCircle` / `XCircle`).
- A **"Added to group {group}"** line (`FolderTree` icon).
- A **results list** (card, hairline rows): per channel a status **glyph** in the status color
(`CheckCircle2` live-green / `MinusCircle` secondary / `XCircle` danger), the **name**, an optional
**reason** sub-line (skip/fail explanation), the new **`#{channelId}`** (mono) when created, and a
status **Badge** (`positive` / `neutral` / `danger`).
**Primary action:** `Start over` (primary, `RotateCcw`) — resets the wizard to Configure.
---
## API mapping (the real screen)
Both endpoints already exist (PR1). The client sends **only `axis` + `value`** back — never a Lucene
query; the server regenerates it (query authorship is server-side only).
| UI element | Endpoint / field |
|---|---|
| `Preview channels` | `POST /api/v1/channels/auto-tune/preview` |
| — axis cards | request `axes: AutoTuneAxis[]` (`"TvShow" \| "TvGenre" \| "MovieGenre"`) |
| — Minimum items | request `minItems: number` |
| — Starting channel number | request `startingNumber: number` |
| Preview rows | response `AutoTuneProposalResponseModel[]`: `{ axis, value, name, number (string), itemCount, alreadyExists }` |
| — number chip | `number` (string — channel numbers can be `"500.1"`; render as-is) |
| — `{itemCount} items` badge | `itemCount` |
| — greyed + `Exists` | `alreadyExists === true` (deselect + disable) |
| `Create {N} channels` | `POST /api/v1/channels/auto-tune` |
| — Channel template field | request `templateId: number` (from `GET /api/v1/channel-templates` / `…/default`) |
| — Channel group field | request `group: string` |
| — selected rows | request `channels: { axis, value, name, number }[]` (echo the selected proposals) |
| Results tiles + list | response `AutoTuneResultResponseModel`: `{ results: { name, status ("Created"\|"Skipped"\|"Failed"), channelId, reason }[], createdCount, skippedCount, failedCount }` |
Notes:
- **Numbers are advisory.** The preview allocates them (skipping taken numbers); the create handler
**re-validates** at create time — a number taken in between yields a per-channel `Skipped`, not a
batch failure. So a `Skipped` outcome with "number … already taken" is normal, not an error.
- **Default selection = every proposal whose `alreadyExists` is false.**
- **Grouping/ordering** is fixed: axis order (TvShow, TvGenre, MovieGenre) then by value; mirror the
server's ordering rather than re-sorting client-side.
- **Template default:** preselect `GET /api/v1/channel-templates/default` (fall back to the first
template) so the field is never empty; the batch requires a `templateId`.
## Real-SPA implementation notes (target = `web/`)
- New screen `web/src/screens/AutoTuneScreen.tsx`; new route id `autoTune`, path `/app/auto-tune`,
label **"Auto-Tune"**, icon `Sparkles`, placed **right after `builder`** in the first sidebar nav
group (the two channel-creation modes sit together). No `primaryAction` label — the wizard's
action lives in-body (per `spa-conventions.md` §10: the "+" banner is for single unambiguous
*create* list screens; a multi-step wizard drives its own buttons), so the shell shows no banner
button for this screen.
- New API module `web/src/api/autoTune.ts` (re-export the generated DTOs; `previewAutoTune(body)` +
`createAutoTunedChannels(body)` over the shared `request` helper; a `messageFrom…Error` narrower),
re-exported from `web/src/api/index.ts`.
- Reuse `getChannelTemplates` / `getDefaultChannelTemplate` from `web/src/api/channelTemplates.ts`
for the template `Select`.
- Follow `spa-conventions.md` §3 for the two async calls (discriminated-union state, `seqRef` +
`activeRef`, no synchronous set-state in an effect body). The wizard is transient (a create flow,
not an editor of persisted data), so it does **not** register the §8 unsaved-changes guard.
- Map prototype primitives → real components: `Button`, `Input`, `Select`, `Checkbox`, `Badge`,
`Tag`, `Stat`, `Tooltip`, `Spinner` from `web/src/components/`. The axis cards and step rail are
small screen-local components built from `ctv-*` utility classes + tokens (no new shared primitive).
- Docs to update in the same PR: `docs/domain-model.md` (add `/app/auto-tune` to the channel routes),
`docs/blazor-route-parity.md` (net-new SPA screen, no Blazor ancestor), `docs/spa-conventions.md`
(if the wizard/step-rail pattern is worth recording), and `design-system/` committed alongside.
@@ -0,0 +1,241 @@
// API Key screen — machine key management and local admin password change.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Card, Input, Spinner } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
// Mock data
const MACHINE_KEY = "etv_0b4f8c2a1e9d5f3b7a6c4e2d0f1a8b9c5d3e2f1a0b4c6d8e9f0a1b2c3d4e5f6g";
const mockAuthMethod = "local"; // 'local' or 'oidc' — determines if password card shows
function CardFrame({ title, subtitle, children }) {
return (
<div style={{
backgroundColor: "var(--surface-card)",
border: "1px solid var(--border-hairline)",
borderRadius: "var(--radius-md)",
padding: "var(--pad-card)",
display: "flex",
flexDirection: "column",
gap: 12
}}>
{title && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{title}</h2>
{subtitle && (
<p style={{ margin: 0, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</p>
)}
</div>
)}
{children}
</div>
);
}
function MachineKeyCard() {
const [revealed, setRevealed] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const copy = () => {
navigator.clipboard?.writeText(MACHINE_KEY).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<CardFrame
title="Machine API Key"
subtitle="This key authenticates MCP and external REST clients. The browser no longer uses it — you sign in with your account instead."
>
{/* Success callout */}
<div style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 14px",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--ctv-ok-soft)",
border: "1px solid var(--status-ok)",
color: "var(--text-primary)"
}}>
<Ico n="ShieldCheck" s={15} color="var(--status-ok)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
Give this key to an MCP server or external REST client to let it call this server's API.
</span>
</div>
{/* Machine key field */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-secondary)" }}>
Machine key
</label>
<div style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--ctv-bg-sunken)",
border: "1px solid var(--border-control)",
fontFamily: "var(--font-mono)"
}}>
<Ico n="KeyRound" s={14} color="var(--text-disabled)" />
<code style={{
flex: 1,
overflowX: "auto",
whiteSpace: "nowrap",
font: "var(--text-sm)/1.3 var(--font-mono)",
color: "var(--text-primary)",
margin: 0,
background: "none"
}}>
{revealed ? MACHINE_KEY : "••••••••••••••••••••••••"}
</code>
</div>
</div>
{/* Action buttons */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
<Button
size="sm"
variant="ghost"
startIcon={revealed ? <Ico n="EyeOff" s={14} /> : <Ico n="Eye" s={14} />}
onClick={() => setRevealed(!revealed)}
>
{revealed ? "Hide" : "Reveal"}
</Button>
<Button
size="sm"
variant="secondary"
startIcon={copied ? <Ico n="Check" s={14} /> : <Ico n="Copy" s={14} />}
onClick={copy}
>
{copied ? "Copied" : "Copy"}
</Button>
</div>
</CardFrame>
);
}
function LocalPasswordCard() {
const [currentPassword, setCurrentPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
const [justSaved, setJustSaved] = React.useState(false);
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && !saving;
const submit = () => {
if (!canSubmit) return;
setSaving(true);
setError(null);
setJustSaved(false);
// Simulate API call
setTimeout(() => {
setCurrentPassword("");
setNewPassword("");
setJustSaved(true);
setSaving(false);
}, 800);
};
return (
<CardFrame
title="Local admin password"
subtitle="Change the password for your local admin account."
>
{error && (
<div style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 14px",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--ctv-warn-soft)",
border: "1px solid var(--status-warn)",
color: "var(--text-primary)"
}}>
<Ico n="TriangleAlert" s={15} color="var(--status-warn)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)" }}>
{error}
</span>
</div>
)}
{/* Form fields */}
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: error ? 12 : 0 }}>
<Input
label="Current password"
type="password"
value={currentPassword}
onChange={(e) => {
setCurrentPassword(e.target.value);
setJustSaved(false);
}}
leadingIcon={<Ico n="Lock" s={14} />}
placeholder="Enter your current password"
/>
<Input
label="New password"
type="password"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setJustSaved(false);
}}
leadingIcon={<Ico n="Lock" s={14} />}
placeholder="Enter your new password"
/>
</div>
{/* Action bar */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginTop: 16 }}>
{justSaved && (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)",
color: "var(--status-ok)"
}}>
<Ico n="Check" s={14} />
Password changed.
</span>
)}
{!justSaved && <span />}
<Button
size="sm"
variant="primary"
startIcon={<Ico n="Check" s={14} />}
onClick={submit}
disabled={!canSubmit}
loading={saving}
>
{saving ? "Changing…" : "Change password"}
</Button>
</div>
</CardFrame>
);
}
function ApiKey() {
return (
<div style={{
display: "flex",
flexDirection: "column",
height: "100%",
gap: 16,
padding: "20px",
overflow: "auto"
}}>
<MachineKeyCard />
{mockAuthMethod === "local" && <LocalPasswordCard />}
</div>
);
}
window.CTVApiKey = ApiKey;
})();
@@ -0,0 +1,900 @@
// Auto-Tune — generate a whole channel lineup from library metadata (#69).
// The "automatic-first" creation mode alongside the manual Channel Builder.
// One screen, three steps: Configure axes/defaults -> Preview proposed channels
// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
// Additive & non-destructive: never edits or deletes an existing channel; number
// or name collisions are skipped, never overwritten. Each generated channel is
// backed by a live SmartCollection query so it keeps tracking the library.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
// ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
// nameOf() and order are display-only here; the real server owns query + name.
const AXES = [
{ id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
{ id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
{ id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
];
const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
// ---- Mock library metadata (EF distinct+count in the real app) ------------
const LIBRARY = {
TvShow: [
{ value: "The Office", count: 201 },
{ value: "Friends", count: 236 },
{ value: "Breaking Bad", count: 62 },
{ value: "Parks and Recreation", count: 125 },
{ value: "The Twilight Zone", count: 156 },
{ value: "Firefly", count: 3 }, // below default minItems -> filtered
],
TvGenre: [
{ value: "Comedy", count: 640 },
{ value: "Drama", count: 512 },
{ value: "Sci-Fi", count: 208 },
{ value: "Crime", count: 174 },
],
MovieGenre: [
{ value: "Action", count: 42 },
{ value: "Sci-Fi", count: 28 },
{ value: "Horror", count: 35 },
{ value: "Comedy", count: 51 },
{ value: "Noir", count: 3 }, // below default minItems -> filtered
],
};
// Coexistence demo: some names already exist (deduped, unchecked by default)
// and some numbers are already taken (allocation skips them).
const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
const TAKEN_NUMBERS = new Set(["500", "503"]);
const TEMPLATES = ["Standard", "Movie night", "Music videos"];
// ---- Preview computation (advisory numbers, re-validated at create) -------
function buildProposals(axisIds, minItems, startingNumber) {
const out = [];
let next = Math.max(1, startingNumber | 0);
const takenThisRun = new Set(TAKEN_NUMBERS);
const alloc = () => {
while (takenThisRun.has(String(next))) next++;
const n = String(next);
takenThisRun.add(n);
next++;
return n;
};
// Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
AXES.forEach((ax) => {
if (!axisIds.has(ax.id)) return;
LIBRARY[ax.id]
.filter((row) => row.count >= minItems)
.slice()
.sort((a, b) => a.value.localeCompare(b.value))
.forEach((row) => {
const name = ax.nameOf(row.value);
const exists = EXISTING_NAMES.has(name);
out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
});
});
return out;
}
// ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
// explicit initials + color (ChannelLogo only derives them from name). --
const BUG_PALETTE = [
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
];
const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
function Bug({ name, initials, ci, size = 32 }) {
const idx = ci != null ? ci : bugHash(name);
const [fg, bg] = BUG_PALETTE[idx];
return (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
</span>
);
}
// ---- Example content sources (advisory sample of the smart-collection query)
const GENRE_SHOWS = {
Comedy: ["The Office", "Friends", "Parks and Recreation"],
Drama: ["Breaking Bad", "The Twilight Zone"],
"Sci-Fi": ["The Twilight Zone", "Firefly"],
Crime: ["Breaking Bad"],
};
const MOVIE_TITLES = {
Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
"Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
Horror: ["Hollow", "The Vigil", "Saltmarsh"],
Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
Noir: ["Rain on 5th", "The Long Con"],
};
const isGenreAxis = (axis) => axis !== "TvShow";
function baseSources(p) {
if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
return []; // TvShow channels track a single show — no per-source editor
}
function effectiveSources(p, ov) {
const ex = ov.exclude || [];
return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
}
// Advisory item-count estimate as sources are added/removed (per-source share).
function estItems(p, ov) {
const base = baseSources(p);
if (!base.length) return p.itemCount;
const share = p.itemCount / base.length;
return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
}
// Sources with their rotation weight (episodes played per rotation).
function sourceList(p, ov) {
return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
}
// Compact rotation-weight stepper ( N +).
function Weight({ w, onChange }) {
const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
return (
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
</div>
);
}
// ---- Example schedule (rundown → EPG blocks), advisory only ---------------
// Effective order for a channel: the axis default, unless overridden per channel.
function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
function buildSchedule(p, ov) {
const mins = RUNTIME[p.axis] || 30;
const shuffled = isShuffled(p, ov);
let seq;
if (p.axis === "TvShow") {
const n = Math.min(8, Math.max(3, p.itemCount));
let eps = Array.from({ length: n }, (_, i) => i + 1);
if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
} else {
const list = sourceList(p, ov).filter((s) => s.weight > 0);
if (!list.length) return [];
if (shuffled) {
// Weighted round-robin interleave — honors the per-source rotation ratio.
const total = list.reduce((a, s) => a + s.weight, 0);
const st = list.map((s) => ({ ...s, acc: 0 }));
const count = Math.min(9, Math.max(5, total * 2));
seq = Array.from({ length: count }, () => {
let pick = null;
st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
pick.acc -= total;
return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
});
} else {
// Sequential — each source plays its rotation count in turn.
seq = [];
list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
seq = seq.slice(0, 9);
}
}
let t = 20 * 60;
return seq.map((b) => {
const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
t += mins;
return { ...b, start, mins };
});
}
function MiniEpg({ blocks }) {
if (!blocks.length) return (
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches nothing to schedule.</div>
);
const PPM = 2.0;
return (
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
{blocks.map((b, i) => (
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
</div>
))}
</div>
</div>
);
}
function ConfigRow({ label, value, first }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
</div>
);
}
// ---- Full channel settings (mirrors the manual Channel Builder) -----------
const CH_TEMPLATES = [
{ id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
{ id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
{ id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
];
const ADV_GROUPS = [
{ group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
{ group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
{ group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
{ group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
];
// Friendly toggle row (label + description + Switch), with an override tag.
function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
return (
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
</div>
<Switch checked={checked} onChange={onChange} />
</label>
);
}
// Channel Template picker (collapsed row + dropdown + spec chips).
function TemplatePicker({ value, onPick }) {
const [open, setOpen] = React.useState(false);
const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
return (
<div>
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
</div>
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
</button>
{open && (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{CH_TEMPLATES.map((x) => {
const on = x.id === value;
return (
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
</div>
</button>
);
})}
</div>
)}
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
{t.sets.map((s) => (
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
))}
</div>
</div>
);
}
// Advanced (~24-field) override disclosure: View defaults / Override.
function AdvancedSettings({ ov, patch, templateName }) {
const [mode, setMode] = React.useState("closed"); // closed | view | override
const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
const vals = { ...defaults, ...(ov.adv || {}) };
const override = mode === "override";
const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
const overridden = Object.keys(ov.adv || {}).length;
const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
return (
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
<span style={{ flex: 1 }} />
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
</div>
{mode !== "closed" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
</div>
{ADV_GROUPS.map((g) => (
<div key={g.group}>
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k, v]) => override ? (
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
) : (
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
// Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
function LogoDrop({ name, src, onSet }) {
const [over, setOver] = React.useState(false);
const fileRef = React.useRef(null);
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
return (
<div onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
onClick={() => fileRef.current && fileRef.current.click()}
style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
</div>
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
</div>
);
}
// ---- Channel detail slide-over: full per-channel settings -----------------
function DetailPanel({ p, ov, patch, onClose }) {
const [addText, setAddText] = React.useState("");
const name = ov.customName != null ? ov.customName : p.name;
const bug = ov.bug || {};
const ax = AXIS[p.axis];
const number = ov.number != null ? ov.number : p.number;
const shuffled = isShuffled(p, ov);
const always = ov.always != null ? ov.always : true;
const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
const avatar = (size) => bug.src
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
const genre = isGenreAxis(p.axis);
const base = baseSources(p);
const ex = ov.exclude || [];
const inc = ov.include || [];
const kept = base.filter((s) => !ex.includes(s));
const items = estItems(p, ov);
const schedule = buildSchedule(p, ov);
const ratios = ov.ratios || {};
const wOf = (s) => ratios[s] || 1;
const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
const list = [...kept, ...inc];
const multi = list.length > 1;
const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
const addInc = () => {
const v = addText.trim();
if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
setAddText("");
};
return (
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
{avatar(34)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
</div>
</div>
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Channel identity</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
</label>
</div>
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
{avatar(48)}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
</label>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
{BUG_PALETTE.map((pair, i) => {
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
return (
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
</button>
);
})}
</div>
</div>
</div>
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={eyebrow}>Playback</div>
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Channel Template</div>
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Query &amp; size</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
</div>
</section>
{genre && (
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Content sources</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged {p.value}. Exclude a title even though it matches, add one that isnt tagged, or set how often each plays.</span>
</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
{list.length === 0 && (
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources add one below.</div>
)}
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
</div>
))}
</div>
{multi && (
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
</span>
)}
<div style={{ display: "flex", gap: 8 }}>
<div style={{ flex: 1 }}>
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
</div>
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
</div>
{ex.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
{ex.map((s) => (
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
</button>
))}
</div>
)}
</section>
)}
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Example schedule</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 the built playout may differ.</span>
</div>
<MiniEpg blocks={schedule} />
</section>
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
</div>
</aside>
</div>
);
}
// =========================================================================
function AutoTune() {
const [step, setStep] = React.useState("configure"); // configure | preview | create
const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
const [minItems, setMinItems] = React.useState("5");
const [startingNumber, setStartingNumber] = React.useState("500");
const [group, setGroup] = React.useState("Auto-Tuned");
const [template, setTemplate] = React.useState("Standard");
const [proposals, setProposals] = React.useState([]);
const [selected, setSelected] = React.useState(() => new Set());
const [results, setResults] = React.useState(null);
const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
const [detail, setDetail] = React.useState(null); // proposal name open in the panel
const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
const patchOv = (name) => (patch) => setOverrides((o) => {
const cur = o[name] || {};
const delta = typeof patch === "function" ? patch(cur) : patch;
return { ...o, [name]: { ...cur, ...delta } };
});
const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
const toggleAxis = (id) =>
setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const runPreview = () => {
const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
setProposals(p);
// Default selection: everything that isn't an already-existing name.
setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
setStep("preview");
};
const runCreate = () => {
// Demo the three outcome states across the selected set.
const chosen = proposals.filter((p) => selected.has(p.name));
const res = chosen.map((p, i) => {
const nm = dispName(p);
if (i === chosen.length - 1 && chosen.length > 2)
return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
if (p.name === "Sci-Fi Movies")
return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
});
setResults(res);
setStep("create");
};
const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
<Toolbar
step={step}
axisCount={axisIds.size}
selectedCount={selected.size}
onPreview={runPreview}
onCreate={runCreate}
onBack={() => setStep("configure")}
onRestart={restart}
/>
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{step === "configure" && (
<Configure
axisIds={axisIds} toggleAxis={toggleAxis}
minItems={minItems} setMinItems={setMinItems}
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
group={group} setGroup={setGroup}
template={template} setTemplate={setTemplate}
/>
)}
{step === "preview" && (
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
openDetail={setDetail} dispName={dispName} />
)}
{step === "create" && <Results results={results} group={group} />}
</main>
{detail != null && (() => {
const p = proposals.find((x) => x.name === detail);
if (!p) return null;
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
})()}
</div>
);
}
// ---- Toolbar with step rail + contextual primary action -------------------
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Sparkles" s={19} /></span>
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
</div>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<StepRail step={step} />
</div>
{step === "configure" && (
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
</Tooltip>
)}
{step === "preview" && (
<React.Fragment>
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
</Button>
</Tooltip>
</React.Fragment>
)}
{step === "create" && (
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
)}
</div>
);
}
const STEPS = [
{ id: "configure", label: "Configure" },
{ id: "preview", label: "Preview" },
{ id: "create", label: "Create" },
];
function StepRail({ step }) {
const idx = STEPS.findIndex((s) => s.id === step);
return (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{STEPS.map((s, i) => {
const done = i < idx, active = i === idx;
const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
return (
<React.Fragment key={s.id}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
<span style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
</div>
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
</React.Fragment>
);
})}
</div>
);
}
// ---- Step 1: Configure ----------------------------------------------------
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
preview the proposed channels, then create the ones you want. Existing channels are never touched.
</p>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Generate from</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
{AXES.map((ax) => {
const on = axisIds.has(ax.id);
return (
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
className="ctv-press"
style={{
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
}}>
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
</span>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
</div>
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</div>
</button>
);
})}
</div>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Defaults</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
</Field>
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
</Field>
<Field label="Channel group" hint="Every generated channel lands in this group.">
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
</Field>
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
</Field>
</div>
</section>
</div>
);
}
function Field({ label, hint, children }) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
{children}
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
</label>
);
}
// ---- Step 2: Preview ------------------------------------------------------
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
const selectable = proposals.filter((p) => !p.alreadyExists);
const existingCount = proposals.length - selectable.length;
if (!proposals.length) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
<Ico n="SearchX" s={26} />
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched try lowering the minimum items.</div>
</div>
);
}
const setAll = (rows, on) => setSelected((s) => {
const n = new Set(s);
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
return n;
});
return (
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
</span>
{existingCount > 0 && (
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist deselected</Tag>
)}
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
</div>
{groups.map(({ ax, rows }) => {
const groupSel = rows.filter((r) => !r.alreadyExists);
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
const someOn = groupSel.some((r) => selected.has(r.name));
return (
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
<Ico n={ax.icon} s={15} />
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
<Badge tone="neutral">{rows.length}</Badge>
<span style={{ flex: 1 }} />
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</span>
</div>
<div>
{rows.map((r, i) => {
const on = selected.has(r.name);
const ov = overrides[r.name] || {};
const name = dispName(r);
const isExp = expanded.has(r.name);
return (
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from {r.value}</div>
</button>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
</span>
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span>&nbsp;items</Badge>
{r.alreadyExists ? (
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
) : (
<React.Fragment>
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
</IconButton>
</React.Fragment>
)}
</div>
{isExp && !r.alreadyExists && (
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={eyebrow}>Example schedule</span>
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
</div>
<MiniEpg blocks={buildSchedule(r, ov)} />
</div>
)}
</div>
);
})}
</div>
</section>
);
})}
</div>
);
}
// ---- Step 3: Results ------------------------------------------------------
const RESULT_META = {
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
};
function Results({ results, group }) {
const count = (s) => results.filter((r) => r.status === s).length;
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
</div>
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
{results.map((r, i) => {
const m = RESULT_META[r.status];
return (
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
</div>
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
<Badge tone={m.tone}>{r.status}</Badge>
</div>
);
})}
</section>
</div>
);
}
window.CTVAutoTune = AutoTune;
})();
@@ -0,0 +1,228 @@
// Block Playout Troubleshooting screen — playout picker → grouped block table →
// per-block history log → decoded history-row detail panel. A drill-down diagnostic
// tool, not a management surface: dense tables, monospace keys, quiet chrome.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Card, Input, Select } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const PLAYOUTS = [
{ value: "", label: "Select a block playout…" },
{ value: "12", label: "1.1 - WeekdayMornings" },
{ value: "18", label: "4.2 - Retro80s" },
{ value: "23", label: "9.1 - KidsAllDay" },
];
const BLOCKS = [
{ id: 101, group: "Morning", name: "Wake Up Block", minutes: 60 },
{ id: 102, group: "Morning", name: "Cartoon Hour", minutes: 60 },
{ id: 103, group: "Morning", name: "News Bridge", minutes: 30 },
{ id: 201, group: "Afternoon", name: "Sitcom Rerun Block", minutes: 90 },
{ id: 202, group: "Afternoon", name: "Movie Matinee", minutes: 120 },
{ id: -1, group: "Afternoon", name: "Filler (synthesized)", minutes: 15 },
{ id: 301, group: "Prime", name: "Drama Block", minutes: 60 },
{ id: 302, group: "Prime", name: "Late Night Block", minutes: 45 },
];
const HISTORY = [
{ id: 9001, when: "2026-07-17T06:00:00", finish: "2026-07-17T07:00:00", key: "2026-07-17|06:00|101", details: "Collection:GlobalWatch(shuffle)" },
{ id: 9002, when: "2026-07-16T06:00:00", finish: "2026-07-16T07:00:00", key: "2026-07-16|06:00|101", details: "Collection:GlobalWatch(shuffle)" },
{ id: 9003, when: "2026-07-15T06:00:00", finish: "2026-07-15T07:00:00", key: "2026-07-15|06:00|101", details: "Collection:GlobalWatch(shuffle)" },
{ id: 9004, when: "2026-07-14T06:00:00", finish: "2026-07-14T07:00:00", key: "2026-07-14|06:00|101", details: "Collection:GlobalWatch(chrono)" },
{ id: 9005, when: "2026-07-13T06:00:00", finish: "2026-07-13T07:00:00", key: "2026-07-13|06:00|101", details: "MultiCollection:MorningMix(shuffle)" },
{ id: 9006, when: "2026-07-12T06:00:00", finish: "2026-07-12T07:00:00", key: "2026-07-12|06:00|101", details: "Collection:GlobalWatch(shuffle)" },
];
const DETAILS = {
9001: { playbackOrder: "Shuffle", collectionType: "Collection", name: "GlobalWatch", mediaItemType: "Episode", mediaItemTitle: "Tom & Jerry — The Cat Concerto" },
};
function formatDateTime(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function DetailRow({ label, value }) {
return (
<div style={{ display: "contents" }}>
<div style={{ padding: "8px var(--pad-cell-x)", font: "var(--weight-medium) var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", borderTop: "1px solid var(--border-hairline)" }}>{label}</div>
<div style={{ padding: "8px var(--pad-cell-x)", font: "var(--text-sm)/1.3 var(--font-mono)", color: "var(--text-primary)", borderTop: "1px solid var(--border-hairline)" }}>{value === "" ? "—" : value}</div>
</div>
);
}
const actionbar = { display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 };
const section = { padding: "16px 20px 0" };
const tableWrap = { border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" };
const th = { textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", whiteSpace: "nowrap", position: "sticky", top: 0, background: "var(--surface-card)", borderBottom: "1px solid var(--border-hairline)", zIndex: 2 };
const td = { padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle" };
function BlockPlayoutTroubleshooting() {
const [playoutId, setPlayoutId] = React.useState("18");
const [blockFilter, setBlockFilter] = React.useState("");
const [selectedBlockId, setSelectedBlockId] = React.useState(102);
const [selectedHistoryId, setSelectedHistoryId] = React.useState(9001);
const [pageSize, setPageSize] = React.useState("10");
const [hoverBlock, setHoverBlock] = React.useState(null);
const [hoverRow, setHoverRow] = React.useState(null);
const needle = blockFilter.trim().toLowerCase();
const filtered = needle === "" ? BLOCKS : BLOCKS.filter((b) => b.name.toLowerCase().includes(needle));
const groups = [];
const gmap = {};
filtered.forEach((b) => { if (!gmap[b.group]) { gmap[b.group] = []; groups.push(b.group); } gmap[b.group].push(b); });
const selectedBlock = BLOCKS.find((b) => b.id === selectedBlockId) || null;
const details = DETAILS[selectedHistoryId] || null;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "auto" }}>
{/* playout picker */}
<div style={actionbar}>
<Select value={playoutId} onChange={() => {}} options={PLAYOUTS} style={{ minWidth: 280 }} />
<span style={{ flex: 1 }} />
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-faint)" }}>
<Ico n="Stethoscope" s={14} color="var(--text-faint)" /> Diagnostic view
</span>
</div>
{/* block filter */}
<div style={{ ...actionbar, borderBottom: "none", paddingBottom: 0 }}>
<Input
value={blockFilter}
onChange={(e) => setBlockFilter(e.target.value)}
placeholder="Search for blocks…"
leadingIcon={<Ico n="Search" s={14} />}
style={{ minWidth: 280 }}
/>
</div>
{/* blocks table */}
<div style={section}>
<div style={tableWrap}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={th}>Block</th>
<th style={{ ...th, width: 120 }}>Minutes</th>
<th style={{ ...th, width: 140, textAlign: "right" }}>History</th>
</tr>
</thead>
<tbody>
{groups.map((g) => (
<React.Fragment key={g}>
<tr>
<td colSpan={3} style={{ padding: "0 var(--pad-cell-x)", height: 30, background: "var(--ctv-bg-sunken)", borderTop: "1px solid var(--border-hairline)", borderBottom: "1px solid var(--border-hairline)" }}>
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.05em", textTransform: "uppercase", color: "var(--text-secondary)" }}>{g || "Ungrouped"}</span>
</td>
</tr>
{gmap[g].map((b) => {
const isSel = selectedBlockId === b.id;
const isHover = hoverBlock === b.id;
return (
<tr
key={b.id}
onMouseEnter={() => setHoverBlock(b.id)}
onMouseLeave={() => setHoverBlock(null)}
style={{ borderTop: "1px solid var(--border-hairline)", background: isSel ? "var(--ctv-accent-soft)" : isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}
>
<td style={{ ...td, font: isSel ? "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)" : td.font }}>{b.name}</td>
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{b.minutes}</td>
<td style={{ ...td, textAlign: "right" }}>
{b.id >= 0 && (
<Button size="sm" variant="secondary" startIcon={<Ico n="History" s={14} />} onClick={() => setSelectedBlockId(b.id)}>
History
</Button>
)}
</td>
</tr>
);
})}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
{/* history table */}
{selectedBlock && (
<div style={section}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 0" }}>
<strong style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
History {selectedBlock.name}
</strong>
<span style={{ flex: 1 }} />
<Select value={pageSize} onChange={(e) => setPageSize(e.target.value)} options={["10", "25", "50", "100"]} style={{ width: 96 }} size="sm" />
</div>
<div style={tableWrap}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={th}>Start</th>
<th style={th}>Finish</th>
<th style={th}>Key</th>
<th style={th}>Details</th>
</tr>
</thead>
<tbody>
{HISTORY.map((entry, i) => {
const isSel = selectedHistoryId === entry.id;
const isHover = hoverRow === entry.id;
return (
<tr
key={entry.id}
onClick={() => setSelectedHistoryId(entry.id)}
onMouseEnter={() => setHoverRow(entry.id)}
onMouseLeave={() => setHoverRow(null)}
style={{ cursor: "pointer", borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)", background: isSel ? "var(--ctv-accent-soft)" : isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}
>
<td style={{ ...td, whiteSpace: "nowrap" }}>{formatDateTime(entry.when)}</td>
<td style={{ ...td, whiteSpace: "nowrap" }}>{formatDateTime(entry.finish)}</td>
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{entry.key}</td>
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{entry.details}</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span><span style={mono}>{HISTORY.length}</span> entries</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<IconButton size="sm" title="Previous page" disabled><Ico n="ChevronLeft" s={15} /></IconButton>
<span style={{ font: "var(--text-xs)/1 var(--font-sans)" }}>Page <span style={mono}>1</span> of <span style={mono}>1</span></span>
<IconButton size="sm" title="Next page" disabled><Ico n="ChevronRight" s={15} /></IconButton>
</span>
</div>
</div>
</div>
)}
{/* decoded history-row detail panel */}
{selectedBlock && (
<div style={{ ...section, paddingBottom: 20 }}>
<Card padded title="History details" subtitle="Decoded from the selected history row">
{details ? (
<div style={{ display: "grid", gridTemplateColumns: "180px 1fr" }}>
<DetailRow label="Playback Order" value={details.playbackOrder} />
<DetailRow label="Collection Type" value={details.collectionType} />
<DetailRow label="Name" value={details.name} />
<DetailRow label="Media Item Type" value={details.mediaItemType} />
<DetailRow label="Media Item Title" value={details.mediaItemTitle} />
</div>
) : (
<div style={{ padding: "8px 0", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
Select a history row above to decode its stored key.
</div>
)}
</Card>
</div>
)}
</div>
);
}
window.CTVBlockPlayoutTroubleshooting = BlockPlayoutTroubleshooting;
})();
@@ -0,0 +1,281 @@
// Blocks screen — grouped block library (list view) drilling into a block editor
// (metadata + ordered item table + item detail form), mirroring BlocksScreen.tsx's
// two-mode layout (list vs. editor) inside one static mockup.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, Card, Input, Select, Checkbox, Tooltip } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const GROUPS = [
{
id: 1,
name: "Anime Blocks",
blocks: [
{ id: 101, name: "Toonami Late Night", minutes: 180 },
{ id: 102, name: "Saturday Morning Anime", minutes: 120 },
],
},
{
id: 2,
name: "News & Talk",
blocks: [
{ id: 201, name: "Morning Briefing", minutes: 60 },
{ id: 202, name: "Evening Roundup", minutes: 90 },
{ id: 203, name: "Late Edition", minutes: 45 },
],
},
{
id: 3,
name: "Nostalgia",
blocks: [{ id: 301, name: "90s Sitcom Hour", minutes: 60 }],
},
{
id: 4,
name: "Holiday Specials",
blocks: [],
},
];
const ITEMS = [
{ key: "i1", collection: "Toonami Bumps & Filler", type: "Collection", order: "Shuffle", epg: true, watermarks: false },
{ key: "i2", collection: "Cowboy Bebop", type: "Television Show", order: "Season, Episode", epg: true, watermarks: false },
{ key: "i3", collection: "Trigun", type: "Television Show", order: "Season, Episode", epg: true, watermarks: true },
{ key: "i4", collection: "Late Night Interstitials", type: "Smart Collection", order: "Random", epg: false, watermarks: false },
{ key: "i5", collection: "\"toonami OR bebop\"", type: "Search Query", order: "Chronological", epg: true, watermarks: false },
];
const COLLECTION_TYPE_OPTIONS = ["Collection", "Television Show", "Television Season", "Smart Collection", "Search Query"];
const PLAYBACK_ORDER_OPTIONS = ["Chronological", "Shuffle", "Random", "Random Rotation"];
const WATERMARKS = [{ id: 1, name: "Bottom-right bug" }, { id: 2, name: "Corner logo (translucent)" }];
const GRAPHICS = [{ id: 1, name: "Now Playing lower third" }, { id: 2, name: "Up Next bumper" }];
function Row({ label, children, control = 360, first }) {
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 16,
padding: "12px var(--pad-cell-x)",
borderTop: first ? "none" : "1px solid var(--border-hairline)",
}}
>
<div style={{ flex: 1, minWidth: 0, paddingTop: 6, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</div>
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
</div>
);
}
function FlushRow({ children, first }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0 var(--pad-cell-x)",
height: 46,
borderTop: first ? "none" : "1px solid var(--border-hairline)",
}}
>
{children}
</div>
);
}
function MultiSelectMock({ options, selected }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{options.map((o) => (
<Checkbox key={o.id} checked={selected.includes(o.id)} label={o.name} onChange={() => {}} />
))}
</div>
);
}
function BlockList({ onOpenBlock }) {
const totalBlocks = GROUPS.reduce((n, g) => n + g.blocks.length, 0);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
<Badge tone="neutral">{totalBlocks} blocks</Badge>
<span style={{ flex: 1 }} />
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<div style={{ flex: 1, maxWidth: 360 }}>
<Input leadingIcon={<Ico n="Search" s={14} />} placeholder="Search for blocks…" value="" onChange={() => {}} />
</div>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
{GROUPS.map((g) => (
<Card
key={g.id}
padded={false}
title={g.name}
actions={
<div style={{ display: "flex", gap: 8 }}>
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New block</Button>
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
</div>
}
>
{g.blocks.length === 0 ? (
<div style={{ padding: "18px var(--pad-cell-x)", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>No blocks in this group.</div>
) : (
g.blocks.map((b, i) => (
<FlushRow key={b.id} first={i === 0}>
<Ico n="Boxes" s={15} color="var(--ctv-accent)" />
<button
type="button"
onClick={onOpenBlock}
style={{
flex: 1,
textAlign: "left",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
color: "var(--ctv-accent)",
}}
>
{b.name}
</button>
<Badge tone="neutral">{b.minutes} min</Badge>
<Tooltip label="Copy block"><IconButton size="sm" variant="ghost" title="Copy block"><Ico n="Copy" s={14} /></IconButton></Tooltip>
<Tooltip label="Delete block"><IconButton size="sm" variant="ghost" title="Delete block"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
</FlushRow>
))
)}
</Card>
))}
</div>
</div>
);
}
function BlockEditor({ onBack }) {
const [selectedKey, setSelectedKey] = React.useState("i2");
const selected = ITEMS.find((it) => it.key === selectedKey) || null;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>All blocks</Button>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Anime Blocks / Edit block</span>
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="Eye" s={14} />}>Preview</Button>
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save block</Button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
<Card padded={false} title="Block">
<Row control={360} first label="Block group">
<Input disabled size="sm" value="Anime Blocks" onChange={() => {}} />
</Row>
<Row control={360} label="Block name">
<Input size="sm" value="Toonami Late Night" onChange={() => {}} />
</Row>
<Row control={200} label="Duration (hours)">
<Input size="sm" type="number" trailing="hours" value="3" onChange={() => {}} />
</Row>
<Row control={200} label="Duration (minutes)">
<Select value="0" onChange={() => {}} options={["0", "15", "30", "45"]} />
</Row>
<Row control={360} label="Stop scheduling block items">
<Select value="Before Duration End" onChange={() => {}} options={["Before Duration End", "After Duration End"]} />
</Row>
</Card>
<Card padded={false} title="Block items" actions={<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add item</Button>}>
<div style={{ overflow: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderBottom: "1px solid var(--border-hairline)" }}>Collection</th>
<th style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderBottom: "1px solid var(--border-hairline)" }}>Playback order</th>
<th style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderBottom: "1px solid var(--border-hairline)" }}>Show in EPG</th>
<th style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderBottom: "1px solid var(--border-hairline)" }}>Disable watermarks</th>
<th style={{ textAlign: "right", padding: "0 var(--pad-cell-x)", height: 34, borderBottom: "1px solid var(--border-hairline)" }}></th>
</tr>
</thead>
<tbody>
{ITEMS.map((it, index) => {
const isSel = it.key === selectedKey;
return (
<tr
key={it.key}
onClick={() => setSelectedKey(it.key)}
style={{
cursor: "pointer",
background: isSel ? "var(--ctv-accent-soft)" : "transparent",
borderTop: index === 0 ? "none" : "1px solid var(--border-hairline)",
}}
>
<td style={{ padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span>{it.collection}</span>
<Badge tone="neutral">{it.type}</Badge>
</div>
</td>
<td style={{ padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{it.order}</td>
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
<Checkbox checked={it.epg} onChange={() => {}} />
</td>
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
<Checkbox checked={it.watermarks} onChange={() => {}} />
</td>
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", gap: 2, justifyContent: "flex-end" }}>
<IconButton size="sm" variant="ghost" title="Duplicate"><Ico n="Copy" s={14} /></IconButton>
<IconButton size="sm" variant="ghost" title="Move up" disabled={index === 0}><Ico n="ArrowUp" s={14} /></IconButton>
<IconButton size="sm" variant="ghost" title="Move down" disabled={index === ITEMS.length - 1}><Ico n="ArrowDown" s={14} /></IconButton>
<IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
{selected && (
<Card padded={false} title="Block item">
<Row control={360} first label="Collection type">
<Select value={selected.type === "Television Show" ? "Television Show" : selected.type} onChange={() => {}} options={COLLECTION_TYPE_OPTIONS} />
</Row>
<Row control={360} label={selected.type}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Badge tone="accent">{selected.collection}</Badge>
<Input size="sm" placeholder="Type to search…" value="" onChange={() => {}} />
</div>
</Row>
<Row control={360} label="Playback order">
<Select value={selected.order} onChange={() => {}} options={PLAYBACK_ORDER_OPTIONS} />
</Row>
<Row control={360} label="Watermarks">
<MultiSelectMock options={WATERMARKS} selected={selected.watermarks ? [1] : []} />
</Row>
<Row control={360} label="Graphics elements">
<MultiSelectMock options={GRAPHICS} selected={[2]} />
</Row>
</Card>
)}
</div>
</div>
);
}
function Blocks() {
const [mode, setMode] = React.useState("list");
return mode === "list" ? <BlockList onOpenBlock={() => setMode("editor")} /> : <BlockEditor onBack={() => setMode("list")} />;
}
window.CTVBlocks = Blocks;
})();
@@ -10,12 +10,14 @@
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
// ---- Mock library (shows / movies / music / docs across libraries) --------
// ---- Mock library (shows / movies / artists across libraries) -------------
// `seasons: true` marks a TelevisionShow with the "browse seasons" drill-in
// (issue #180) — lets a specific season be added instead of the whole show.
const LIB = [
{ id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96 },
{ id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96, seasons: true },
{ id: "s2", title: "Tom & Jerry", type: "Show", library: "Cartoons", sub: "164 shorts", min: 72 },
{ id: "s3", title: "Popeye", type: "Show", library: "Cartoons", sub: "108 shorts", min: 54 },
{ id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84 },
{ id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84, seasons: true },
{ id: "s5", title: "Scooby-Doo", type: "Show", library: "Cartoons", sub: "97 episodes", min: 60 },
{ id: "s6", title: "Pink Panther", type: "Show", library: "Cartoons", sub: "124 shorts", min: 48 },
{ id: "m1", title: "Blade Runner", type: "Movie", library: "Movies", sub: "1982 · Sci-Fi", min: 117 },
@@ -24,27 +26,31 @@
{ id: "m4", title: "Metropolis", type: "Movie", library: "Movies", sub: "1927 · Sci-Fi", min: 153 },
{ id: "m5", title: "Nosferatu", type: "Movie", library: "Movies", sub: "1922 · Horror", min: 94 },
{ id: "t1", title: "The Twilight Zone", type: "Show", library: "TV Shows", sub: "156 episodes", min: 51 },
{ id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50 },
{ id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50, seasons: true },
{ id: "t3", title: "Columbo", type: "Show", library: "TV Shows", sub: "69 episodes", min: 94 },
{ id: "t4", title: "Cheers", type: "Show", library: "TV Shows", sub: "275 episodes", min: 24 },
{ id: "u1", title: "80s Synthpop", type: "Music", library: "Music Videos", sub: "212 videos", min: 45 },
{ id: "u2", title: "90s Alt Rock", type: "Music", library: "Music Videos", sub: "188 videos", min: 45 },
{ id: "u3", title: "Chart Countdown", type: "Music", library: "Music Videos", sub: "Top 40", min: 60 },
{ id: "d1", title: "Blue Planet", type: "Doc", library: "Documentaries", sub: "8 episodes", min: 50 },
{ id: "d2", title: "Planet Earth", type: "Doc", library: "Documentaries", sub: "11 episodes", min: 50 },
{ id: "d3", title: "Cosmos", type: "Doc", library: "Documentaries", sub: "13 episodes", min: 60 },
{ id: "u1", title: "80s Synthpop", type: "Artist", library: "Music Videos", sub: "212 items", min: 45 },
{ id: "u2", title: "90s Alt Rock", type: "Artist", library: "Music Videos", sub: "188 items", min: 45 },
{ id: "u3", title: "Chart Countdown", type: "Artist", library: "Music Videos", sub: "Top 40", min: 60 },
{ id: "d1", title: "Blue Planet", type: "Movie", library: "Documentaries", sub: "1h 30m", min: 90 },
{ id: "d2", title: "Planet Earth", type: "Movie", library: "Documentaries", sub: "50m", min: 50 },
{ id: "d3", title: "Cosmos", type: "Movie", library: "Documentaries", sub: "1h 00m", min: 60 },
];
const LIBRARIES = ["All", "Cartoons", "Movies", "TV Shows", "Music Videos", "Documentaries"];
const TYPE_ICON = { Show: "Tv", Movie: "Film", Music: "Music", Doc: "Radio", Multi: "Layers", Smart: "Sparkles", Manual: "FolderTree", Rerun: "Repeat" };
// Icon/label per LibraryBrowseMediaType — mirrors web/src/media/mediaKinds.ts TYPE_ICON/TYPE_LABEL
// (only Movie/TelevisionShow/Artist are browsable top-level library kinds; Collection/Smart/Multi/
// Rerun/Playlist are the "Collections" source).
const TYPE_ICON = { Show: "Tv", Movie: "Film", Artist: "Music", Collection: "FolderTree", Multi: "Layers", Smart: "Sparkles", Rerun: "Repeat", Playlist: "ListVideo" };
// Collections — curated groups (Manual / Smart / Multi / Rerun) addable as a
// single lineup entry, so the builder isn't limited to raw library items.
const COLLECTIONS = [
{ id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true },
{ id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true },
{ id: "c3", title: "Retro Station IDs", type: "Manual", library: "Collection", sub: "Manual · 24 items", min: 12, coll: true },
{ id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly",min: 120, coll: true },
{ id: "c5", title: "Halloween Marathon", type: "Manual", library: "Collection", sub: "Manual · 9 films", min: 900, coll: true },
{ id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true },
{ id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true },
{ id: "c3", title: "Retro Station IDs", type: "Collection", library: "Collection", sub: "24 items", min: 12, coll: true },
{ id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly", min: 120, coll: true },
{ id: "c5", title: "Halloween Marathon", type: "Collection", library: "Collection", sub: "9 films", min: 900, coll: true },
{ id: "c6", title: "Late Night Bumpers", type: "Playlist", library: "Collection", sub: "24 items", min: 18, coll: true },
];
// Channel Templates — bundle the clunky technical config; one is pre-selected.
@@ -59,23 +65,43 @@
sets: ["1080p H.264", "HLS Segmenter", "Shuffle", "Always on", "Bumpers + ads"] },
];
// Advanced (~30-field) override set, grouped. Values shown as template-inherited.
// Advanced field set grouped + labeled to match the real ChannelBuilder's
// ADVANCED_KEYS (web/src/builder/ChannelBuilder.tsx): 22 counted fields
// (20 override keys + the dedicated playbackOrder/playoutMode toggles),
// plus Group/Categories which ride along in Behavior but aren't counted.
// kind picks the override-mode control: "select" | "input" | "switch".
const ADV = [
{ group: "Streaming", fields: [
["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920×1080"],
["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"],
{ k: "FFmpeg profile", v: "1080p NVENC", kind: "select", opts: ["1080p NVENC", "1080p x264 veryfast", "720p x264 fast", "4K HEVC NVENC"] },
{ k: "Streaming mode", v: "HLS Direct", kind: "select", opts: ["MPEG-TS", "HLS Direct", "HLS Segmenter", "MPEG-TS Hybrid"] },
{ k: "Transcode mode", v: "On Demand", kind: "select", opts: ["On Demand"] },
]},
{ group: "Filler", fields: [
["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"],
["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"],
{ k: "Fallback filler", v: "Static Bumper", kind: "select", opts: ["Static Bumper", "Test Pattern", "None"] },
{ k: "Pre-roll filler", v: "Static Bumper", kind: "select", opts: ["Static Bumper", "Retro Promo", "None"] },
{ k: "Mid-roll filler", v: "Ad Break", kind: "select", opts: ["Ad Break", "None"] },
{ k: "Post-roll filler", v: "None", kind: "select", opts: ["Outro Card", "None"] },
{ k: "Watermark", v: "ChicoryTV Bug", kind: "select", opts: ["ChicoryTV Bug", "None"] },
]},
{ group: "Playback", fields: [
["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"],
["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"],
{ k: "Playback order", v: "Chronological", kind: "select", opts: ["Chronological", "Shuffle", "Random", "ShuffleInOrder", "SeasonEpisode", "Marathon"] },
{ k: "Preferred audio language", v: "eng", kind: "input" },
{ k: "Preferred subtitle language", v: "Not set", kind: "input" },
{ k: "Preferred audio title", v: "Not set", kind: "input" },
{ k: "Subtitle mode", v: "Forced", kind: "select", opts: ["None", "Forced", "Default", "Any"] },
{ k: "Stream selector mode", v: "Default", kind: "select", opts: ["Default", "Custom", "Troubleshooting"] },
{ k: "Stream selector", v: "Not set", kind: "input" },
]},
{ group: "Behavior", fields: [
["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"],
["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"],
{ k: "Playout mode", v: "Always on", kind: "select", opts: ["Always on", "On demand"] },
{ k: "Music video credits", v: "None", kind: "select", opts: ["None", "GenerateSubtitles"] },
{ k: "Song video mode", v: "Default", kind: "select", opts: ["Default", "WithProgress"] },
{ k: "Idle behavior", v: "Stop on disconnect", kind: "select", opts: ["Stop on disconnect", "Keep running"] },
{ k: "Fixed start time", v: "Flexible", kind: "select", opts: ["Strict", "Flexible"] },
{ k: "Random start point", v: "Off", kind: "switch" },
{ k: "Shuffle schedule items", v: "Off", kind: "switch" },
{ k: "Group", v: "ChicoryTV", kind: "input", uncounted: true },
{ k: "Categories", v: "", kind: "input", uncounted: true, viewOk: false },
]},
];
@@ -167,7 +193,9 @@
}
// ---- Library card ----------------------------------------------------------
function LibCard({ item, added, compact, onAdd, onDragStart, onDragEnd }) {
// `item.seasons` -> a TelevisionShow shows a "browse seasons" affordance
// (issue #180) so a single season can be added instead of the whole show.
function LibCard({ item, added, compact, onAdd, onSeasons, onDragStart, onDragEnd }) {
if (compact) {
return (
<div draggable onDragStart={onDragStart} onDragEnd={onDragEnd} onDoubleClick={onAdd} title="Double-click or drag to add" className="ctv-press"
@@ -178,6 +206,11 @@
<div style={{ font: "var(--weight-medium) var(--text-xs)/1.15 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{item.type} · <span style={mono}>{item.sub}</span></div>
</div>
{item.seasons && (
<IconButton size="sm" title="Browse seasons" onClick={(e) => { e.stopPropagation(); onSeasons && onSeasons(); }}>
<Ico n="FolderTree" s={15} />
</IconButton>
)}
<IconButton size="sm" title={added ? "Added" : "Add to lineup"} onClick={onAdd}>
<Ico n={added ? "Check" : "Plus"} s={15} color={added ? "var(--action-primary)" : undefined} />
</IconButton>
@@ -189,6 +222,13 @@
style={{ position: "relative", cursor: "grab" }}>
<div style={{ position: "relative", borderRadius: "var(--radius-sm)", boxShadow: added ? "0 0 0 2px var(--action-primary)" : "none" }}>
<Poster item={item} w="100%" h={150} />
{item.seasons && (
<button type="button" onClick={(e) => { e.stopPropagation(); onSeasons && onSeasons(); }}
style={{ position: "absolute", left: 6, bottom: 6, display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 7px", borderRadius: "var(--radius-pill)", cursor: "pointer",
background: "rgba(11,13,17,.78)", border: "1px solid var(--border-control)", color: "var(--text-primary)", font: "var(--weight-medium) 10px/1 var(--font-sans)", backdropFilter: "blur(2px)" }}>
<Ico n="FolderTree" s={11} />Seasons
</button>
)}
{added && (
<div style={{ position: "absolute", top: 6, right: 6, width: 22, height: 22, borderRadius: "50%", background: "var(--action-primary)", color: "var(--text-on-accent)", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-sm)", animation: "ctv-pop 260ms cubic-bezier(0.16,1,0.3,1)" }}>
<Ico n="Check" s={14} />
@@ -286,9 +326,11 @@
// ---- Advanced disclosure — one-click Override (edit) or View defaults ------
function Advanced({ templateName, scrollerRef }) {
const [mode, setMode] = React.useState("closed"); // closed | view | override
const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; });
const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach((f) => { o[f.k] = f.v; })); return o; });
const override = mode === "override";
const count = ADV.reduce((n, g) => n + g.fields.length, 0);
// Counted fields mirror the real fieldCount = ADVANCED_KEYS.length + 2
// (playbackOrder + playoutMode); Group/Categories ride along uncounted.
const count = ADV.reduce((n, g) => n + g.fields.filter((f) => !f.uncounted).length, 0);
const fieldsRef = React.useRef(null);
const rootRef = React.useRef(null);
const [maxH, setMaxH] = React.useState(0);
@@ -342,16 +384,33 @@
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
{override ? (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k]) => (
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => setVals((s) => ({ ...s, [k]: e.target.value }))} />
))}
{g.fields.map((f) => {
if (f.kind === "switch") {
const on = vals[f.k] === "On";
return (
<div key={f.k} style={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 2px" }}>
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{f.k}</span>
<Switch checked={on} onChange={(next) => setVals((s) => ({ ...s, [f.k]: next ? "On" : "Off" }))} />
</div>
);
}
if (f.kind === "select") {
return (
<Select key={f.k} size="sm" label={f.k} value={vals[f.k]} options={f.opts}
onChange={(e) => setVals((s) => ({ ...s, [f.k]: e.target.value }))} />
);
}
return (
<Input key={f.k} size="sm" label={f.k} value={vals[f.k]} onChange={(e) => setVals((s) => ({ ...s, [f.k]: e.target.value }))} />
);
})}
</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k, v]) => (
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
{g.fields.filter((f) => f.kind !== "switch" && f.viewOk !== false).map((f) => (
<div key={f.k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{f.k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(vals[f.k]) ? mono : {}) }}>{vals[f.k]}</div>
</div>
))}
</div>
@@ -378,6 +437,7 @@
const [tpl, setTpl] = React.useState("std");
const [tplOpen, setTplOpen] = React.useState(false);
const [source, setSource] = React.useState("library"); // library | collections
const [seasonsFor, setSeasonsFor] = React.useState(null); // show whose seasons drill-in is open
// drag state
const [dragLib, setDragLib] = React.useState(null); // library item id being dragged
@@ -414,13 +474,11 @@
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* builder toolbar */}
{/* builder toolbar — the shell's TopBar already renders the "New Channel"
page title, so this row is just the live summary + the two actions. */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Plus" s={19} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>New Channel</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Build from your library <span style={mono}>{lineup.length}</span> in lineup · <span style={mono}>{fmtDur(totalMin)}</span></div>
</div>
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Build from your library <span style={mono}>{lineup.length}</span> in lineup · <span style={mono}>{fmtDur(totalMin)}</span></span>
<span style={{ flex: 1 }} />
<Button variant="ghost">Cancel</Button>
<Tooltip placement="bottom" label={canCreate ? "Creates the channel, schedule & playout" : "Name the channel and add at least one item"}>
<Button variant="primary" disabled={!canCreate} startIcon={<Ico n="Check" s={15} />}>Create Channel</Button>
@@ -461,6 +519,7 @@
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{filtered.map((item) => (
<LibCard key={item.id} item={item} compact added={addedIds.has(item.id)} onAdd={() => add(item)}
onSeasons={item.seasons ? () => setSeasonsFor(item) : undefined}
onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} />
))}
</div>
@@ -468,6 +527,7 @@
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(104px,1fr))", gap: 10 }}>
{filtered.map((item) => (
<LibCard key={item.id} item={item} added={addedIds.has(item.id)} onAdd={() => add(item)}
onSeasons={item.seasons ? () => setSeasonsFor(item) : undefined}
onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} />
))}
</div>
@@ -611,6 +671,34 @@
</ScrollArea>
</aside>
</div>
{/* Seasons drill-in — expands a TelevisionShow into its seasons so one
season can be added instead of the whole show (issue #180). */}
{seasonsFor && (
<div style={{ position: "fixed", inset: 0, zIndex: 40, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,7,9,.6)" }}
onClick={() => setSeasonsFor(null)}>
<div onClick={(e) => e.stopPropagation()}
style={{ width: 420, maxHeight: "70vh", display: "flex", flexDirection: "column", borderRadius: "var(--radius-lg)", background: "var(--surface-card)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-lg)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)" }}>
<span style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Seasons {seasonsFor.title}</span>
<span style={{ flex: 1 }} />
<IconButton size="sm" title="Close" onClick={() => setSeasonsFor(null)}><Ico n="X" s={15} /></IconButton>
</div>
<div style={{ flex: 1, overflow: "auto", padding: 10, display: "flex", flexDirection: "column", gap: 4 }}>
{["Season 1", "Season 2", "Season 3", "Specials"].map((s) => (
<div key={s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "6px 8px", borderRadius: "var(--radius-sm)" }}>
<Poster item={{ title: s, type: seasonsFor.type }} w={40} h={54} mini />
<div style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{s}</div>
<IconButton size="sm" title="Add to lineup"><Ico n="Plus" s={15} /></IconButton>
</div>
))}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<Button variant="secondary" onClick={() => setSeasonsFor(null)}>Done</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,335 @@
// Channel Edit screen — left sub-nav rail (General / Playout / Streaming / Stream
// selection / Music video / Branding) + one focused form pane per section, dense
// label+help / control rows inside a flush Card (mirrors Settings.jsx). A floating
// save bar surfaces validation state and appears only while the draft is dirty.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Card, Button, Badge, ChannelLogo, Input, Select, Switch } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const SECTIONS = [
{ id: "general", label: "General", icon: "Tv", hint: "Number, name, group" },
{ id: "playout", label: "Playout", icon: "ListVideo", hint: "Source & scheduling" },
{ id: "streaming", label: "Streaming", icon: "SlidersHorizontal", hint: "FFmpeg & output" },
{ id: "selection", label: "Stream selection", icon: "AudioLines", hint: "Audio & subtitles" },
{ id: "music", label: "Music video", icon: "Music", hint: "Credits & songs" },
{ id: "branding", label: "Branding", icon: "Image", hint: "Logo & watermark" },
];
const FFMPEG_PROFILES = ["1080p H.264", "1080p HEVC", "720p H.264", "Copy (no transcode)"];
const WATERMARKS = ["(none)", "Station bug — corner", "Station bug — large"];
const FILLER_PRESETS = ["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"];
const LANGUAGES = ["(none)", "English", "Japanese", "Spanish", "French", "German"];
const CREDITS_TEMPLATES = ["(none)", "Classic Credits", "Minimal Credits"];
const STREAM_SELECTORS = ["(none)", "prefer-commentary.py", "anime-dual-audio.py"];
const MIRROR_CHANNELS = ["(none)", "(1.1) - News 24", "(3.1) - Movie Vault", "(9.1) - Kids Classics"];
const SLUG_OPTIONS = ["(none)", "0.5 seconds", "1 second", "2 seconds", "3 seconds", "5 seconds"];
const SAVED = {
number: "5.1", name: "Retro Cartoons", group: "Entertainment", categories: "Animation, Kids",
isEnabled: true, showInEpg: true,
playoutSource: "Generated", playoutMode: "Continuous", mirrorSourceChannel: "(none)", playoutOffsetHours: "0",
idleBehavior: "Stop On Disconnect",
streamingMode: "MPEG-TS", ffmpegProfile: "1080p H.264", slugSeconds: "1 second",
streamSelectorMode: "Default", preferredAudioLanguage: "English", preferredAudioTitle: "",
preferredSubtitleLanguage: "(none)", subtitleMode: "None", streamSelector: "(none)",
musicVideoCreditsMode: "None", musicVideoCreditsTemplate: "(none)", songVideoMode: "Default",
externalLogoUrl: "", watermark: "(none)", fallbackFiller: "Retro Bumpers",
};
const PLAYOUT_LOCKED = true; // this channel already has a generated playout
/* ---------- shared row primitives (mirror Settings.jsx / ChannelEditScreen) ---------- */
function Pane({ title, subtitle, children }) {
return (
<div style={{ maxWidth: 720, display: "flex", flexDirection: "column", gap: 16, animation: "ctv-fade-in 240ms cubic-bezier(0.16,1,0.3,1)" }}>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>
<div style={{ marginTop: 5, font: "var(--text-sm)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>
</div>
{children}
</div>
);
}
function Row({ label, help, children, first, control = 300 }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 20, padding: "13px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-disabled)" }}>{help}</div>}
</div>
<div style={{ flex: `0 0 ${control}px`, display: "flex", justifyContent: "flex-end" }}>{children}</div>
</div>
);
}
/* ---------- panes ---------- */
function GeneralPane({ v, set }) {
return (
<Pane title="General" subtitle="Identity shown in the lineup, guide and IPTV playlist.">
<Card padded={false}>
<Row first control={200} label="Number" help="Displayed channel number, e.g. 5 or 5.1.">
<Input size="sm" fullWidth value={v.number} onChange={(e) => set("number", e.target.value)}
error={v.number.trim() ? null : "Number is required"} />
</Row>
<Row label="Name" help="Channel display name.">
<Input size="sm" fullWidth value={v.name} onChange={(e) => set("name", e.target.value)}
error={v.name.trim() ? null : "Name is required"} />
</Row>
<Row label="Group" help="Groups channels in clients that support it.">
<Input size="sm" fullWidth value={v.group} onChange={(e) => set("group", e.target.value)} />
</Row>
<Row label="Categories" help="Comma-separated list of categories.">
<Input size="sm" fullWidth placeholder="News, Sports" value={v.categories} onChange={(e) => set("categories", e.target.value)} />
</Row>
<Row label="Enabled" help="Whether this channel is served to clients.">
<Switch size="sm" checked={v.isEnabled} onChange={(next) => set(next ? "isEnabled" : ["isEnabled", "showInEpg"], next)} />
</Row>
<Row label="Show in EPG" help="Include this channel in the XMLTV guide.">
<Switch size="sm" checked={v.showInEpg} disabled={!v.isEnabled} onChange={(next) => set("showInEpg", next)} />
</Row>
</Card>
</Pane>
);
}
function PlayoutPane({ v, set }) {
const isMirror = v.playoutSource === "Mirror";
return (
<Pane title="Playout" subtitle="Where the channel's content comes from and how it progresses.">
<Card padded={false}>
<Row first label="Playout source"
help={PLAYOUT_LOCKED ? "Cannot be changed once a generated channel has a playout." : "Generated builds its own schedule; Mirror follows another channel."}>
<Select size="sm" fullWidth disabled={PLAYOUT_LOCKED} value={v.playoutSource}
onChange={(e) => set("playoutSource", e.target.value)} options={["Generated", "Mirror"]} />
</Row>
{isMirror ? (
<React.Fragment>
<Row label="Mirror source channel" help="The generated channel this channel mirrors.">
<Select size="sm" fullWidth value={v.mirrorSourceChannel} onChange={(e) => set("mirrorSourceChannel", e.target.value)} options={MIRROR_CHANNELS} />
</Row>
<Row control={160} label="Playout offset" help="Shift the mirrored playout by this many hours.">
<Input size="sm" fullWidth type="number" style={mono} value={v.playoutOffsetHours}
onChange={(e) => set("playoutOffsetHours", e.target.value)}
trailing={<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>hours</span>} />
</Row>
</React.Fragment>
) : (
<Row label="Playout mode" help="Controls how the generated playout progresses.">
<Select size="sm" fullWidth value={v.playoutMode} onChange={(e) => set("playoutMode", e.target.value)} options={["Continuous", "On Demand"]} />
</Row>
)}
<Row label="Idle behavior" help="How the transcoder behaves once all clients disconnect.">
<Select size="sm" fullWidth value={v.idleBehavior} onChange={(e) => set("idleBehavior", e.target.value)} options={["Stop On Disconnect", "Keep Running"]} />
</Row>
<Row label="Transcode mode" help="When the transcoding process is active. Only On Demand is supported.">
<Select size="sm" fullWidth disabled value="On Demand" options={["On Demand"]} onChange={() => {}} />
</Row>
</Card>
</Pane>
);
}
function StreamingPane({ v, set }) {
const hlsDirect = v.streamingMode === "HLS Direct";
return (
<Pane title="Streaming" subtitle="Output container and the transcoding profile applied to this channel.">
<Card padded={false}>
<Row first label="Streaming mode" help="Delivery container / protocol served to clients.">
<Select size="sm" fullWidth value={v.streamingMode} onChange={(e) => set("streamingMode", e.target.value)}
options={["MPEG-TS", "MPEG-TS (Legacy)", "HLS Direct", "HLS Segmenter"]} />
</Row>
<Row label="FFmpeg profile" help={hlsDirect ? "Not used in HLS Direct mode." : "Transcoding preset for this channel."}>
<Select size="sm" fullWidth disabled={hlsDirect} value={v.ffmpegProfile} onChange={(e) => set("ffmpegProfile", e.target.value)} options={FFMPEG_PROFILES} />
</Row>
<Row label="Slug seconds" help="Black video / silent audio inserted between every playout item.">
<Select size="sm" fullWidth value={v.slugSeconds} onChange={(e) => set("slugSeconds", e.target.value)} options={SLUG_OPTIONS} />
</Row>
</Card>
</Pane>
);
}
function SelectionPane({ v, set }) {
const isDefault = v.streamSelectorMode === "Default";
return (
<Pane title="Stream selection" subtitle="How audio and subtitle tracks are chosen for playback.">
<Card padded={false}>
<Row first label="Stream selector mode" help="Default picks tracks by preference; Custom uses a selector script.">
<Select size="sm" fullWidth value={v.streamSelectorMode} onChange={(e) => set("streamSelectorMode", e.target.value)} options={["Default", "Custom"]} />
</Row>
{isDefault ? (
<React.Fragment>
<Row label="Preferred audio language" help="Blank keeps the file order.">
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)} options={LANGUAGES} />
</Row>
<Row label="Preferred audio title" help="Prefer an audio track whose title contains this text.">
<Input size="sm" fullWidth value={v.preferredAudioTitle} onChange={(e) => set("preferredAudioTitle", e.target.value)} />
</Row>
<Row label="Preferred subtitle language" help="Blank disables preference.">
<Select size="sm" fullWidth value={v.preferredSubtitleLanguage} onChange={(e) => set("preferredSubtitleLanguage", e.target.value)} options={LANGUAGES} />
</Row>
<Row label="Subtitle mode" help="When subtitle tracks are burned in.">
<Select size="sm" fullWidth value={v.subtitleMode} onChange={(e) => set("subtitleMode", e.target.value)} options={["None", "Forced", "Default", "Any"]} />
</Row>
</React.Fragment>
) : (
<Row label="Stream selector" help="Name of a custom stream-selector script in the config folder.">
<Select size="sm" fullWidth value={v.streamSelector} onChange={(e) => set("streamSelector", e.target.value)} options={STREAM_SELECTORS} />
</Row>
)}
</Card>
</Pane>
);
}
function MusicPane({ v, set }) {
const creditsOn = v.musicVideoCreditsMode === "Generate Subtitles";
return (
<Pane title="Music video" subtitle="Overlays and progress behavior for music video and song content.">
<Card padded={false}>
<Row first label="Credits mode" help="Generate on-screen credits for music videos.">
<Select size="sm" fullWidth value={v.musicVideoCreditsMode} onChange={(e) => set("musicVideoCreditsMode", e.target.value)} options={["None", "Generate Subtitles"]} />
</Row>
<Row label="Credits template" help="Name of the credits template. Only used when credits are generated.">
<Select size="sm" fullWidth disabled={!creditsOn} value={v.musicVideoCreditsTemplate} onChange={(e) => set("musicVideoCreditsTemplate", e.target.value)} options={CREDITS_TEMPLATES} />
</Row>
<Row label="Song video mode" help="Optional progress bar overlay for song content.">
<Select size="sm" fullWidth value={v.songVideoMode} onChange={(e) => set("songVideoMode", e.target.value)} options={["Default", "With Progress"]} />
</Row>
</Card>
</Pane>
);
}
function BrandingPane({ v, set }) {
const hlsDirect = v.streamingMode === "HLS Direct";
const urlValid = !v.externalLogoUrl.trim() || /^https?:\/\//i.test(v.externalLogoUrl.trim());
const previewSrc = v.externalLogoUrl.trim() || undefined;
return (
<Pane title="Branding" subtitle="Channel logo and the overlays applied while streaming.">
<Card padded={false}>
<Row first control={340} label="Logo" help="Shown in the guide and as the on-screen bug.">
<div style={{ display: "flex", alignItems: "center", gap: 12, width: "100%" }}>
<ChannelLogo name={v.name} size={48} src={previewSrc} />
<Button size="sm" variant="secondary" startIcon={<Ico n="Upload" s={14} />}>Upload logo</Button>
</div>
</Row>
<Row control={340} label="External logo URL" help="An external image URL wins over an uploaded logo when both are set.">
<Input size="sm" fullWidth placeholder="https://example.com/logo.png" value={v.externalLogoUrl}
onChange={(e) => set("externalLogoUrl", e.target.value)}
error={urlValid ? null : "Enter a valid http(s) URL"} />
</Row>
<Row label="Watermark" help={hlsDirect ? "Not used in HLS Direct mode." : "Overlay applied to the channel."}>
<Select size="sm" fullWidth disabled={hlsDirect} value={v.watermark} onChange={(e) => set("watermark", e.target.value)} options={WATERMARKS} />
</Row>
<Row label="Fallback filler" help="Plays when the playout has nothing scheduled.">
<Select size="sm" fullWidth value={v.fallbackFiller} onChange={(e) => set("fallbackFiller", e.target.value)} options={FILLER_PRESETS} />
</Row>
</Card>
</Pane>
);
}
const PANES = {
general: GeneralPane, playout: PlayoutPane, streaming: StreamingPane,
selection: SelectionPane, music: MusicPane, branding: BrandingPane,
};
/* ---------- screen ---------- */
function ChannelEdit() {
const [section, setSection] = React.useState("general");
const [saved, setSaved] = React.useState(SAVED);
const [draft, setDraft] = React.useState(SAVED);
const [saving, setSaving] = React.useState(false);
const [justSaved, setJustSaved] = React.useState(false);
const set = (key, val) => {
setJustSaved(false);
if (Array.isArray(key)) {
setDraft((d) => ({ ...d, [key[0]]: val, [key[1]]: val ? d[key[1]] : false }));
} else {
setDraft((d) => ({ ...d, [key]: val }));
}
};
const nameValid = Boolean(draft.name.trim());
const numberValid = Boolean(draft.number.trim());
const urlValid = !draft.externalLogoUrl.trim() || /^https?:\/\//i.test(draft.externalLogoUrl.trim());
const valid = nameValid && numberValid && urlValid;
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
const PaneCmp = PANES[section];
return (
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
{/* sub-nav rail */}
<div style={{ width: 224, flex: "0 0 auto", borderRight: "1px solid var(--border-hairline)", overflow: "auto", padding: "12px 8px" }}>
{SECTIONS.map((s) => {
const active = s.id === section;
return (
<button key={s.id} type="button" onClick={() => setSection(s.id)} className="ctv-press"
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 10px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
<Ico n={s.icon} s={16} color={active ? "var(--ctv-accent)" : "var(--text-secondary)"} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: active ? "var(--text-primary)" : "var(--text-secondary)" }}>{s.label}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.hint}</div>
</div>
</button>
);
})}
</div>
{/* pane */}
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: "relative", display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "20px 24px 96px" }}>
<PaneCmp key={section} v={draft} set={set} />
</div>
{/* floating save bar */}
{(dirty || justSaved) && (
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
{justSaved ? (
<React.Fragment>
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel saved</span>
</React.Fragment>
) : (
<React.Fragment>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Unsaved changes</span>
{(!nameValid || !numberValid) && <Badge tone="neutral">Name and number are required</Badge>}
{!urlValid && <Badge tone="neutral">Logo URL must be http(s)</Badge>}
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
<Button size="sm" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button>
<Button size="sm" variant="primary" loading={saving} disabled={!valid || saving} startIcon={<Ico n="Check" s={14} />}
onClick={() => {
setSaving(true);
window.setTimeout(() => {
setSaving(false);
setSaved(draft);
setJustSaved(true);
window.setTimeout(() => setJustSaved(false), 1800);
}, 500);
}}>
Save changes
</Button>
</React.Fragment>
)}
</div>
</div>
)}
</div>
</div>
);
}
window.CTVChannelEdit = ChannelEdit;
})();
@@ -0,0 +1,183 @@
// Collections screen — manual vs. smart collection management, a flush
// browse list per tab, and a drill-in "manage items" detail pane for a
// manual collection (list + reorder-ready detail, mirroring the live SPA).
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, Card, Switch } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const MANUAL = [
{ id: 1, name: "Halloween Marathon", custom: true, count: 18 },
{ id: 2, name: "Saturday Morning Cartoons", custom: true, count: 42 },
{ id: 3, name: "Best of Kurosawa", custom: false, count: 11 },
{ id: 4, name: "90s Music Videos", custom: false, count: 63 },
{ id: 5, name: "Rainy Day Comfort Movies", custom: true, count: 9 },
];
const SMART = [
{ id: 1, name: "Recent Action Movies", query: 'genre:"action" AND released:2015-2026', count: 27 },
{ id: 2, name: "80s Synthpop", query: 'genre:"synthpop" AND released:1980-1989', count: 14 },
{ id: 3, name: "Unwatched Documentaries", query: 'genre:"documentary" AND played:false', count: 38 },
];
const ITEMS = [
{ id: 1, title: "The Cat Concerto", type: "Episode" },
{ id: 2, title: "Puss Gets the Boot", type: "Episode" },
{ id: 3, title: "Jerry and the Lion", type: "Episode" },
{ id: 4, title: "The Night Before Halloween", type: "Movie" },
{ id: 5, title: "Trick or Treat House", type: "Movie" },
{ id: 6, title: "Season 3", type: "Season" },
{ id: 7, title: "Hocus Pocus", type: "Movie" },
{ id: 8, title: "The Great Pumpkin", type: "Episode" },
];
const rowStyle = (first) => ({
display: "flex",
alignItems: "center",
gap: 10,
padding: "0 var(--pad-cell-x)",
height: 46,
borderTop: first ? "none" : "1px solid var(--border-hairline)",
});
const flushMain = { flex: 1, minWidth: 0, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
function Segmented({ view, setView, counts }) {
return (
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{[{ v: "manual", l: "Manual", c: counts.manual }, { v: "smart", l: "Smart", c: counts.smart }].map((o) => {
const on = o.v === view;
return (
<button key={o.v} type="button" onClick={() => setView(o.v)}
style={{ display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
{o.l}<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>{o.c}</span>
</button>
);
})}
</div>
);
}
function ManualItemsPane({ collection, onBack }) {
const [reordering, setReordering] = React.useState(false);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>All collections</Button>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{collection.name}</span>
<span style={{ flex: 1 }} />
{collection.custom && !reordering && (
<Button size="sm" variant="secondary" startIcon={<Ico n="ArrowUp" s={14} />} onClick={() => setReordering(true)}>Reorder</Button>
)}
<Button size="sm" variant="primary" startIcon={<Ico n="Plus" s={14} />} disabled={reordering}>Add items</Button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
<Card padded={false}>
{ITEMS.map((item, i) => (
<div key={item.id} style={rowStyle(i === 0)}>
<Ico n="ListVideo" s={15} color="var(--text-disabled)" />
<span style={flushMain}>{item.title}</span>
<Badge tone="neutral">{item.type}</Badge>
{reordering ? (
<React.Fragment>
<IconButton size="sm" variant="ghost" title={`Move ${item.title} up`} disabled={i === 0}><Ico n="ArrowUp" s={14} /></IconButton>
<IconButton size="sm" variant="ghost" title={`Move ${item.title} down`} disabled={i === ITEMS.length - 1}><Ico n="ArrowDown" s={14} /></IconButton>
</React.Fragment>
) : (
<IconButton size="sm" variant="ghost" title={`Remove ${item.title}`}><Ico n="Trash2" s={14} /></IconButton>
)}
</div>
))}
</Card>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 20px", borderTop: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
{reordering ? (
<React.Fragment>
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}><span style={mono}>{ITEMS.length}</span> items</span>
<div style={{ display: "flex", gap: 8 }}>
<Button size="sm" variant="ghost" onClick={() => setReordering(false)}>Cancel</Button>
<Button size="sm" variant="primary" onClick={() => setReordering(false)}>Save order</Button>
</div>
</React.Fragment>
) : (
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Showing <span style={mono}>{ITEMS.length}</span> of <span style={mono}>{collection.count}</span></span>
)}
</div>
</div>
);
}
function Collections() {
const [view, setView] = React.useState("manual");
const [selected, setSelected] = React.useState(null);
if (selected) {
return <ManualItemsPane collection={selected} onBack={() => setSelected(null)} />;
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
<Segmented view={view} setView={setView} counts={{ manual: MANUAL.length, smart: SMART.length }} />
<span style={{ flex: 1 }} />
{view === "manual" ? (
<Button variant="primary" size="sm" startIcon={<Ico n="Plus" s={14} />}>New collection</Button>
) : (
<Button variant="primary" size="sm" startIcon={<Ico n="Plus" s={14} />}>New smart collection</Button>
)}
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
{view === "manual" ? (
<Card padded={false}>
{MANUAL.map((c, i) => (
<div key={c.id} style={rowStyle(i === 0)}>
<Ico n="FolderTree" s={15} color="var(--ctv-accent)" />
<button type="button" onClick={() => setSelected(c)}
style={{ ...flushMain, textAlign: "left", background: "none", border: "none", padding: 0, cursor: "pointer", font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
{c.name}
</button>
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{c.count} items</span>
<label style={{ display: "inline-flex", alignItems: "center", gap: 7, cursor: "pointer" }} title="Use custom playback order">
<Switch checked={c.custom} size="sm" />
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Custom order</span>
</label>
<IconButton size="sm" variant="ghost" title="Manage items" onClick={() => setSelected(c)}><Ico n="ListVideo" s={14} /></IconButton>
<IconButton size="sm" variant="ghost" title="Rename"><Ico n="Pencil" s={14} /></IconButton>
<IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton>
</div>
))}
</Card>
) : (
<Card padded={false}>
{SMART.map((c, i) => (
<div key={c.id} style={rowStyle(i === 0)}>
<Ico n="Sparkles" s={15} color="var(--ctv-accent)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.name}</div>
<div style={{ ...mono, marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-mono)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.query}</div>
</div>
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{c.count} matches</span>
<IconButton size="sm" variant="ghost" title="Edit"><Ico n="Pencil" s={14} /></IconButton>
<IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton>
</div>
))}
</Card>
)}
<div style={{ display: "flex", alignItems: "flex-start", gap: 10, marginTop: 16, padding: "12px 14px", borderRadius: "var(--radius-md)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>
<Ico n="Info" s={14} color="var(--text-disabled)" />
<span style={{ font: "var(--text-xs)/1.5 var(--font-sans)", color: "var(--text-secondary)" }}>
Multi-collections, rerun collections and playlists aren't available here yet manage them in the Classic UI for now.
</span>
</div>
</div>
</div>
);
}
window.CTVCollections = Collections;
})();
@@ -1,145 +1,194 @@
// Dashboard — spec 4.1: On-air-now cards, stat tiles, Health panel,
// Recent activity, collapsible Release notes.
// Dashboard screen — at-a-glance stat row, on-air-now card grid, and a
// system-health panel. Mirrors DashboardScreen.tsx: a success state (stat
// row + two-column grid) plus the loading/error states the live screen
// falls back to while the API request is in flight or fails.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Card, Stat, Badge, StatusDot, ChannelLogo, ProgressBar, IconButton } = NS;
const { Card, Stat, Badge, StatusDot, ChannelLogo, ProgressBar, Button, Spinner } = NS;
const Ico = window.Ico;
const D = window.CTV_DATA;
const toneColor = { accent: "var(--ctv-accent)", ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)" };
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
// health status -> icon + color
const HEALTH = {
pass: { icon: "Check", color: "var(--status-ok)", label: "Pass" },
info: { icon: "Info", color: "var(--ctv-accent)", label: "Info" },
warning: { icon: "TriangleAlert", color: "var(--status-warn)", label: "Warning" },
fail: { icon: "CircleX", color: "var(--status-error)", label: "Fail" },
};
const STATS = [
{ label: "Channels", value: "24", icon: <Ico n="Tv" s={15} /> },
{ label: "Active playouts", value: "22", icon: <Ico n="ListVideo" s={15} /> },
{ label: "On air", value: "4", icon: <Ico n="Radio" s={15} /> },
{ label: "Libraries", value: "7", icon: <Ico n="Library" s={15} /> },
];
function OnAirCard({ ch, program, pct, remain }) {
const ON_AIR = [
{ num: "1.1", name: "Toon Classics", title: "Tom & Jerry — The Cat Concerto", pct: 48, remain: 14 },
{ num: "2.1", name: "News 24", title: "World Tonight — Evening bulletin", pct: 34, remain: 27 },
{ num: "4.2", name: "Retro Radio", title: "80s Block — Synthpop hour", pct: 51, remain: 19 },
{ num: "9.1", name: "Kids Corner", title: "Sesame Street — Episode 4102", pct: 82, remain: 6 },
];
// status mirrors ErsatzTV.Application/Health/Mapper.cs: pass | fail | warn | info
const HEALTH = [
{ title: "FFmpeg", detail: "7.1.1 detected on PATH", status: "pass" },
{ title: "Media sources", detail: "5 of 5 libraries reachable", status: "pass" },
{ title: "Transcode folder", detail: "/config/transcode is writable", status: "pass" },
{ title: "HDHomeRun tuners", detail: "2 tuners idle, 2 in use", status: "pass" },
{ title: "Disk space", detail: "/config at 88% capacity", status: "warn" },
{ title: "Plex integration", detail: "Not configured", status: "info" },
];
function healthIconStatus(status) {
if (status === "fail") return "error";
if (status === "warn") return "warn";
if (status === "info") return "idle";
return "ok";
}
function healthIconName(status) {
const s = healthIconStatus(status);
if (s === "error" || s === "warn") return "TriangleAlert";
if (s === "idle") return "Info";
return "Check";
}
function summarizeHealth(checks) {
const failed = checks.filter((c) => c.status === "fail").length;
const warned = checks.filter((c) => c.status === "warn").length;
if (failed > 0) return { label: `${failed} failing`, status: "error" };
if (warned > 0) return { label: `${warned} warning${warned === 1 ? "" : "s"}`, status: "warn" };
return { label: "Healthy", status: "ok" };
}
function OnAirCard({ ch }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10, padding: 12, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--ctv-bg-sunken)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ display: "grid", gap: 10, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--ctv-bg-sunken)", padding: 12 }}>
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0,1fr) auto", alignItems: "center", gap: 8 }}>
<ChannelLogo name={ch.name} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--status-live)" }}>{ch.num}</span>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</span>
</div>
<div style={{ minWidth: 0, display: "grid", gap: 2 }}>
<code style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-secondary)" }}>{ch.num}</code>
<strong style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</strong>
</div>
<StatusDot status="live" />
<Badge tone="accent" dot>On air</Badge>
</div>
<div style={{ font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{program}</div>
<div>
<ProgressBar value={pct} tone="accent" height={4} />
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>
<span>{pct}%</span>
<span>{remain} to next</span>
</div>
<p style={{ margin: 0, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.title}</p>
<ProgressBar value={ch.pct} />
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>
<span>{ch.pct}% elapsed</span>
<span>{ch.remain}m to next</span>
</div>
</div>
);
}
function ReleaseNotes() {
const [open, setOpen] = React.useState(false);
const rn = D.releaseNotes;
function HealthPanel() {
const summary = summarizeHealth(HEALTH);
return (
<div style={{ background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
<button type="button" onClick={() => setOpen((o) => !o)}
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "13px 16px", border: "none", background: "transparent", cursor: "pointer", textAlign: "left" }}>
<Ico n="Sparkles" s={15} color="var(--text-secondary)" />
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>What's new</span>
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>{rn.version} · {rn.date}</span>
<span style={{ flex: 1 }} />
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={16} color="var(--text-disabled)" />
</button>
{open && (
<ul style={{ margin: 0, padding: "4px 18px 16px 34px", display: "flex", flexDirection: "column", gap: 7 }}>
{rn.items.map((it, i) => (
<li key={i} style={{ font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{it}</li>
))}
</ul>
)}
</div>
<Card
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>System health</h2>}
subtitle="Backend health checks"
actions={<Button size="sm" variant="secondary" startIcon={<Ico n="RefreshCw" s={14} />}>Refresh health</Button>}
padded={false}
>
<div>
{HEALTH.map((h, i) => {
const rowStatus = healthIconStatus(h.status);
const colorVar = { ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)", idle: "var(--text-secondary)" }[rowStatus];
return (
<div key={h.title} style={{ display: "grid", gridTemplateColumns: "auto auto minmax(0,1fr) auto", alignItems: "center", gap: 8, minHeight: 46, padding: "0 12px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ width: 24, height: 24, display: "inline-flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-xs)", background: "var(--surface-selected)", color: colorVar }}>
<Ico n={healthIconName(h.status)} s={15} />
</span>
<strong style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap" }}>{h.title}</strong>
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.detail}</span>
<StatusDot status={rowStatus} />
</div>
);
})}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", borderTop: "1px solid var(--border-hairline)", padding: "8px 12px" }}>
<StatusDot status={summary.status} label={summary.label} />
</div>
</Card>
);
}
function DashboardLoadingState() {
return (
<Card>
<div style={{ minHeight: 220, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, color: "var(--text-secondary)" }}>
<Spinner size={20} tone="accent" />
<span style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>Loading dashboard</span>
</div>
</Card>
);
}
function DashboardErrorState() {
return (
<Card
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Dashboard unavailable</h2>}
subtitle="Live API request failed"
>
<div style={{ display: "grid", gap: 6, border: "1px solid var(--status-error)", borderRadius: "var(--radius-md)", background: "var(--ctv-error-soft)", padding: 12 }}>
<span style={{ font: "var(--weight-semibold) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--status-error)" }}>API request failed</span>
<strong style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>GET /api/dashboard returned 504 Gateway Timeout</strong>
</div>
</Card>
);
}
function Dashboard() {
const onAir = D.channels.filter((c) => c.live).slice(0, 4);
const progs = [
{ program: "Now: Looney Tunes — Rabbit Fire", pct: 68, remain: "07:41" },
{ program: "Now: World Tonight — Evening bulletin", pct: 34, remain: "19:22" },
{ program: "Now: 80s Block — Synthpop hour", pct: 51, remain: "14:08" },
{ program: "Now: Sesame Street — Episode 4102", pct: 82, remain: "05:12" },
];
const failCount = D.health.filter((h) => h.status === "fail").length;
const warnCount = D.health.filter((h) => h.status === "warning").length;
const [state, setState] = React.useState("success");
const StateSwitcher = () => (
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{[{ v: "success", l: "Live data" }, { v: "loading", l: "Loading" }, { v: "error", l: "Error" }].map((o) => {
const on = o.v === state;
return (
<button key={o.v} type="button" onClick={() => setState(o.v)}
style={{ display: "inline-flex", alignItems: "center", height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
{o.l}
</button>
);
})}
</div>
);
return (
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
{/* stat tiles */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 14 }}>
<Card><Stat label="Channels" value="128" icon={<Ico n="Tv" s={15} />} /></Card>
<Card><Stat label="Active playouts" value="112" icon={<Ico n="ListVideo" s={15} />} /></Card>
<Card><Stat label="Transcodes" value="4" icon={<Ico n="Radio" s={15} />} delta="live" deltaTone="neutral" /></Card>
<Card><Stat label="Libraries" value="5" icon={<Ico n="Library" s={15} />} /></Card>
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
<StateSwitcher />
<span style={{ flex: 1 }} />
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<StatusDot status="live" size={7} /><span style={mono}>4</span> on air
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.55fr 1fr", gap: 16, alignItems: "start" }}>
{/* On air now */}
<Card title="On air now" subtitle={`${onAir.length} channels streaming`} actions={<Badge tone="accent" dot>Live</Badge>}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{onAir.map((c, i) => <OnAirCard key={c.num} ch={c} {...progs[i]} />)}
</div>
</Card>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
{state === "loading" && <DashboardLoadingState />}
{state === "error" && <DashboardErrorState />}
{state === "success" && (
<div style={{ display: "grid", gap: 16 }}>
<section aria-label="At a glance" style={{ display: "grid", gridTemplateColumns: "repeat(4,minmax(0,1fr))", gap: 16 }}>
{STATS.map((s) => (
<Card key={s.label}><Stat label={s.label} value={s.value} icon={s.icon} /></Card>
))}
</section>
{/* Health panel */}
<Card title="System health" padded={false}
actions={<span style={{ display: "inline-flex", gap: 6 }}>
{failCount > 0 && <Badge tone="error" dot>{failCount} fail</Badge>}
{warnCount > 0 && <Badge tone="warn" dot>{warnCount} warn</Badge>}
</span>}>
<div>
{D.health.map((h, i) => {
const s = HEALTH[h.status];
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none", cursor: "pointer" }}>
<Ico n={s.icon} s={15} color={s.color} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", flex: "0 0 auto" }}>{h.title}</span>
<span style={{ font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-disabled)", flex: 1, textAlign: "right", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.detail}</span>
<Ico n="ChevronRight" s={14} color="var(--text-faint)" />
<section style={{ display: "grid", gridTemplateColumns: "minmax(0,2fr) minmax(260px,1fr)", gap: 16, alignItems: "start" }}>
<Card
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>On air now</h2>}
subtitle="Current programmes by channel"
actions={<Badge tone="accent" dot>{ON_AIR.length} on air</Badge>}
>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,minmax(0,1fr))", gap: 10 }}>
{ON_AIR.map((ch) => <OnAirCard key={ch.num} ch={ch} />)}
</div>
);
})}
</div>
</Card>
</div>
</Card>
<div style={{ display: "grid", gridTemplateColumns: "1.55fr 1fr", gap: 16, alignItems: "start" }}>
{/* Recent activity */}
<Card title="Recent activity" padded={false}>
<div>
{D.activity.map((a, i) => (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 24, height: 24, flex: "0 0 auto", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: toneColor[a.tone] }}>
<Ico n={a.icon} s={13} />
</span>
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{a.text}</span>
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)", whiteSpace: "nowrap", marginTop: 2 }}>{a.time}</span>
</div>
))}
<HealthPanel />
</section>
</div>
</Card>
{/* Release notes (collapsible, secondary) */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<ReleaseNotes />
</div>
)}
</div>
</div>
);
}
window.CTVDashboard = Dashboard;
})();
@@ -0,0 +1,204 @@
// Deco Templates screen — group'd list of named time-of-day schedules ("decos" bound to windows
// across a 24h clock), with a group/name editor + add-content row + ordered items table for the
// detail view. Mirrors DecoTemplatesScreen.tsx's list (Card-per-group, flush rows) and editor
// (group/name card, add-content card, items table) states; a small local toggle shows both here.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, Card, Input, Select, Tooltip } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const GROUPS = [
{
id: 1,
name: "Default",
templates: [
{ id: 101, name: "Standard weekday" },
{ id: 102, name: "Standard weekend" },
{ id: 103, name: "Late night bumper-only" },
],
},
{
id: 2,
name: "Holiday",
templates: [
{ id: 104, name: "Halloween week" },
{ id: 105, name: "Christmas Eve — Christmas Day" },
],
},
{
id: 3,
name: "Overnight",
templates: [{ id: 106, name: "Infomercial block 00:00-06:00" }],
},
{ id: 4, name: "Seasonal (unused)", templates: [] },
];
const DECO_GROUP_OPTIONS = [
{ label: "Select a deco group…", value: "" },
{ label: "Bumpers", value: "1" },
{ label: "Lower thirds", value: "2" },
{ label: "Watermarks", value: "3" },
];
const DECO_OPTIONS = [
{ label: "Select a deco…", value: "" },
{ label: "Station ID — short", value: "11" },
{ label: "Up next bumper", value: "12" },
{ label: "Weather watermark", value: "13" },
];
const START_TIME_OPTIONS = [
{ label: "00:00", value: "00:00:00" },
{ label: "06:00", value: "06:00:00" },
{ label: "09:00", value: "09:00:00" },
{ label: "18:00", value: "18:00:00" },
{ label: "22:00", value: "22:00:00" },
];
const DURATION_MINUTE_OPTIONS = [0, 5, 15, 30, 45].map((m) => ({ label: String(m), value: String(m) }));
const DRAFT_ITEMS = [
{ key: "i1", time: "00:00-06:00", deco: "Infomercial block deco" },
{ key: "i2", time: "06:00-09:00", deco: "Morning watermark" },
{ key: "i3", time: "09:00-18:00", deco: "Daytime lower third" },
{ key: "i4", time: "18:00-22:00", deco: "Prime time bumper set" },
{ key: "i5", time: "22:00-24:00", deco: "Late night watermark" },
];
const flushRow = { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 16px", height: 46, borderTop: "1px solid var(--border-hairline)" };
const rowLabel = { font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" };
const linkBtn = { background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" };
const emptyBox = { padding: "22px 16px", textAlign: "center", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" };
const th = { textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", whiteSpace: "nowrap", borderBottom: "1px solid var(--border-hairline)", background: "var(--surface-card)" };
const td = { padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle", borderTop: "1px solid var(--border-hairline)" };
const settingsRowMain = { flex: "1 1 auto", minWidth: 0 };
const settingsRowLabel = { font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" };
const settingsRow = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "0 16px", height: 52, borderTop: "1px solid var(--border-hairline)" };
function ViewToggle({ view, setView }) {
return (
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{[{ v: "list", l: "Groups" }, { v: "editor", l: "Editor" }].map((o) => {
const on = o.v === view;
return (
<button key={o.v} type="button" onClick={() => setView(o.v)}
style={{ height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
{o.l}
</button>
);
})}
</div>
);
}
function DecoTemplateList({ view, setView }) {
const total = GROUPS.reduce((n, g) => n + g.templates.length, 0);
return (
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<Badge tone="neutral">{total} deco templates</Badge>
<span style={{ flex: 1 }} />
<ViewToggle view={view} setView={setView} />
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
</div>
{GROUPS.map((g) => (
<Card
key={g.id}
padded={false}
title={g.name}
actions={
<div style={{ display: "flex", gap: 8 }}>
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New deco template</Button>
<Tooltip label="Delete group">
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
</Tooltip>
</div>
}
>
{g.templates.length === 0 ? (
<div style={emptyBox}>No deco templates in this group.</div>
) : (
g.templates.map((t, i) => (
<div key={t.id} style={{ ...flushRow, borderTop: i === 0 ? "none" : flushRow.borderTop }}>
<button type="button" style={linkBtn} onClick={() => setView("editor")}>{t.name}</button>
<Tooltip label="Delete deco template">
<IconButton size="sm" variant="ghost" title="Delete deco template"><Ico n="Trash2" s={14} /></IconButton>
</Tooltip>
</div>
))
)}
</Card>
))}
</div>
);
}
function DecoTemplateEditor({ view, setView }) {
return (
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={() => setView("list")}>All deco templates</Button>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Default / Edit deco template</span>
<span style={{ flex: 1 }} />
<ViewToggle view={view} setView={setView} />
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save deco template</Button>
</div>
<Card padded={false} title="Deco template">
<div style={{ ...settingsRow, borderTop: "none" }}>
<div style={settingsRowMain}><div style={settingsRowLabel}>Deco template group</div></div>
<div style={{ flex: "0 0 360px" }}><Input disabled size="sm" value="Default" fullWidth /></div>
</div>
<div style={settingsRow}>
<div style={settingsRowMain}><div style={settingsRowLabel}>Deco template name</div></div>
<div style={{ flex: "0 0 360px" }}><Input size="sm" value="Standard weekday" fullWidth /></div>
</div>
</Card>
<Card padded={false} title="Add content">
<div style={{ display: "flex", gap: 12, padding: 16, flexWrap: "wrap", alignItems: "flex-end" }}>
<div style={{ minWidth: 200 }}><Select label="Deco group" options={DECO_GROUP_OPTIONS} value="" /></div>
<div style={{ minWidth: 200 }}><Select label="Deco" options={DECO_OPTIONS} value="" /></div>
<div style={{ minWidth: 140 }}><Select label="Start time" options={START_TIME_OPTIONS} value="18:00:00" /></div>
<div style={{ width: 90 }}><Input label="Hours" type="number" value="1" /></div>
<div style={{ width: 110 }}><Select label="Minutes" options={DURATION_MINUTE_OPTIONS} value="30" /></div>
<Button size="sm" disabled startIcon={<Ico n="Plus" s={14} />}>Add</Button>
</div>
</Card>
<Card padded={false} title="Deco template items">
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead><tr>
<th style={th}>Time</th>
<th style={th}>Deco</th>
<th style={{ ...th, textAlign: "right" }}></th>
</tr></thead>
<tbody>
{DRAFT_ITEMS.map((it) => (
<tr key={it.key}>
<td style={{ ...td, ...mono, width: 130 }}>{it.time}</td>
<td style={td}>{it.deco}</td>
<td style={{ ...td, width: 60, textAlign: "right" }}>
<Tooltip label="Remove"><IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}
function DecoTemplates() {
const [view, setView] = React.useState("list");
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{view === "list" ? <DecoTemplateList view={view} setView={setView} /> : <DecoTemplateEditor view={view} setView={setView} />}
</div>
);
}
window.CTVDecoTemplates = DecoTemplates;
})();
@@ -0,0 +1,341 @@
// Decos screen — group'd deco library (list+card view) with a full-detail editor
// for watermark / graphics / break-content / filler override rules per deco.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, Card, Input, Select, Checkbox } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const GROUPS = [
{ id: 1, name: "Default" },
{ id: 2, name: "Weeknights" },
{ id: 3, name: "Retro Block" },
];
const DECOS = [
{ id: 101, groupId: 1, name: "Standard Bug" },
{ id: 102, groupId: 1, name: "No Overlay" },
{ id: 201, groupId: 2, name: "News Ticker" },
{ id: 202, groupId: 2, name: "Prime Time Bumper" },
{ id: 301, groupId: 3, name: "Retro Bumper" },
{ id: 302, groupId: 3, name: "VHS Overlay" },
{ id: 303, groupId: 3, name: "Static Break Loop" },
];
const WATERMARK_MODE_OPTIONS = [
{ label: "Inherit", value: "Inherit" },
{ label: "Disable", value: "Disable" },
{ label: "Replace", value: "Override" },
{ label: "Merge", value: "Merge" },
];
const BASIC_MODE_OPTIONS = [
{ label: "Inherit", value: "Inherit" },
{ label: "Disable", value: "Disable" },
{ label: "Override", value: "Override" },
];
const FILLER_TYPE_OPTIONS = [
{ label: "Collection", value: "Collection" },
{ label: "Television Show", value: "TelevisionShow" },
{ label: "Television Season", value: "TelevisionSeason" },
{ label: "Artist", value: "Artist" },
{ label: "Multi Collection", value: "MultiCollection" },
{ label: "Smart Collection", value: "SmartCollection" },
];
const PLACEMENT_LABEL = {
BlockStart: "Block Start",
BlockFinish: "Block Finish",
BetweenBlockItems: "Between Block Items",
ChapterMarkers: "At Chapter Markers",
};
const WATERMARKS = [
{ id: 1, name: "Bug — bottom right" },
{ id: 2, name: "Bug — bottom left, translucent" },
{ id: 3, name: "Station ID corner mark" },
];
const GRAPHICS = [
{ id: 1, name: "Now/Next lower third" },
{ id: 2, name: "Weather crawl" },
{ id: 3, name: "Retro scanlines overlay" },
];
const BREAK_ITEMS = [
{ key: "b1", placement: "BlockStart", collectionType: "Playlist", selectionName: "Bumper Playlist — Retro" },
{ key: "b2", placement: "BetweenBlockItems", collectionType: "Collection", selectionName: "Commercial Bumps 90s" },
{ key: "b3", placement: "ChapterMarkers", collectionType: "Playlist", selectionName: "Station ID Loop" },
];
const EDIT_DRAFT = {
groupName: "Retro Block",
name: "Retro Bumper",
watermarkMode: "Override",
watermarkIds: [1, 3],
useWatermarkDuringFiller: true,
graphicsMode: "Merge",
graphicsIds: [3],
useGraphicsDuringFiller: false,
breakContentMode: "Override",
defaultFillerMode: "Override",
defaultFillerType: "Collection",
defaultFillerName: "Retro Filler Loop",
defaultFillerTrimToFit: true,
deadAirMode: "Inherit",
};
function Row({ label, children, control = 360, first = false }) {
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 16,
padding: "12px var(--pad-cell-x)",
borderTop: first ? "none" : "1px solid var(--border-hairline)",
}}
>
<div style={{ flex: 1, minWidth: 0, paddingTop: 6 }}>
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
</div>
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
</div>
);
}
function FlushRow({ children, first = false }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0 var(--pad-cell-x)",
height: 44,
borderTop: first ? "none" : "1px solid var(--border-hairline)",
}}
>
{children}
</div>
);
}
function EmptyNote({ children }) {
return (
<div style={{ padding: "18px var(--pad-cell-x)", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
{children}
</div>
);
}
function MultiSelect({ options, selected, disabled }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{options.map((o) => (
<Checkbox key={o.id} checked={selected.includes(o.id)} disabled={disabled} label={o.name} onChange={() => {}} />
))}
</div>
);
}
function Decos() {
const [screen, setScreen] = React.useState("list"); // "list" | "edit"
const [selectedBreak, setSelectedBreak] = React.useState(BREAK_ITEMS[0].key);
const [draft, setDraft] = React.useState(EDIT_DRAFT);
const patch = (p) => setDraft((d) => ({ ...d, ...p }));
const watermarkActive = draft.watermarkMode === "Override" || draft.watermarkMode === "Merge";
const graphicsActive = draft.graphicsMode === "Override" || draft.graphicsMode === "Merge";
const selectedBreakItem = BREAK_ITEMS.find((b) => b.key === selectedBreak) || null;
const openEditor = () => setScreen("edit");
const backToList = () => setScreen("list");
if (screen === "edit") {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "auto" }}>
{/* action bar */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58, position: "sticky", top: 0, background: "var(--surface-app)", zIndex: 3 }}>
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={backToList}>All decos</Button>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{draft.groupName} / Edit deco</span>
<span style={{ flex: 1 }} />
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save deco</Button>
</div>
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16, maxWidth: 900 }}>
<Card padded={false} title="Deco">
<Row label="Deco group" first>
<Input size="sm" disabled value={draft.groupName} />
</Row>
<Row label="Deco name">
<Input size="sm" value={draft.name} onChange={() => {}} />
</Row>
</Card>
<Card padded={false} title="Watermark">
<Row label="Watermark mode" first>
<Select value={draft.watermarkMode} options={WATERMARK_MODE_OPTIONS} onChange={(e) => patch({ watermarkMode: e.target.value })} />
</Row>
<Row label="Watermarks">
<MultiSelect options={WATERMARKS} selected={draft.watermarkIds} disabled={!watermarkActive} />
</Row>
<Row label="Use watermark during filler">
<Checkbox checked={draft.useWatermarkDuringFiller} disabled={draft.watermarkMode !== "Override"} onChange={(v) => patch({ useWatermarkDuringFiller: v })} />
</Row>
</Card>
<Card padded={false} title="Graphics elements">
<Row label="Graphics elements mode" first>
<Select value={draft.graphicsMode} options={WATERMARK_MODE_OPTIONS} onChange={(e) => patch({ graphicsMode: e.target.value })} />
</Row>
<Row label="Graphics elements">
<MultiSelect options={GRAPHICS} selected={draft.graphicsIds} disabled={!graphicsActive} />
</Row>
<Row label="Use graphics elements during filler">
<Checkbox checked={draft.useGraphicsDuringFiller} disabled={draft.graphicsMode !== "Override"} onChange={(v) => patch({ useGraphicsDuringFiller: v })} />
</Row>
</Card>
<Card
padded={false}
title="Break content"
actions={draft.breakContentMode === "Override" ? <Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add break content</Button> : undefined}
>
<Row label="Break content mode" first>
<Select value={draft.breakContentMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ breakContentMode: e.target.value })} />
</Row>
{draft.breakContentMode === "Override" && (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
{["Placement", "Type", "Name", ""].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 32, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderTop: "1px solid var(--border-hairline)", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{BREAK_ITEMS.map((item) => {
const active = item.key === selectedBreak;
return (
<tr key={item.key} onClick={() => setSelectedBreak(item.key)} style={{ cursor: "pointer", background: active ? "var(--ctv-accent-soft)" : "transparent", borderBottom: "1px solid var(--border-hairline)" }}>
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{PLACEMENT_LABEL[item.placement]}</td>
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{item.collectionType}</td>
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{item.selectionName}</td>
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, textAlign: "right" }} onClick={(e) => e.stopPropagation()}>
<IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</Card>
{draft.breakContentMode === "Override" && selectedBreakItem && (
<Card padded={false} title="Break content item">
<Row label="Placement" first>
<Select value={selectedBreakItem.placement} options={Object.entries(PLACEMENT_LABEL).map(([value, label]) => ({ value, label }))} onChange={() => {}} />
</Row>
{selectedBreakItem.collectionType === "Playlist" ? (
<React.Fragment>
<Row label="Playlist group">
<Select value="retro" options={[{ value: "retro", label: "Retro Bumpers" }, { value: "news", label: "News Break" }]} onChange={() => {}} />
</Row>
<Row label="Playlist">
<Select value="loop1" options={[{ value: "loop1", label: "Bumper Playlist — Retro" }, { value: "loop2", label: "Late Night Loop" }]} onChange={() => {}} />
</Row>
</React.Fragment>
) : (
<Row label="Selection">
<Badge tone="accent">{selectedBreakItem.selectionName}</Badge>
</Row>
)}
</Card>
)}
<Card padded={false} title="Default filler">
<Row label="Default filler mode" first>
<Select value={draft.defaultFillerMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ defaultFillerMode: e.target.value })} />
</Row>
{draft.defaultFillerMode === "Override" && (
<React.Fragment>
<Row label="Collection type">
<Select value={draft.defaultFillerType} options={FILLER_TYPE_OPTIONS} onChange={(e) => patch({ defaultFillerType: e.target.value })} />
</Row>
<Row label={FILLER_TYPE_OPTIONS.find((o) => o.value === draft.defaultFillerType)?.label}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Badge tone="accent">{draft.defaultFillerName}</Badge>
<Input size="sm" placeholder="Type to search…" value="" onChange={() => {}} />
</div>
</Row>
<Row label="Trim to fit">
<Checkbox checked={draft.defaultFillerTrimToFit} onChange={(v) => patch({ defaultFillerTrimToFit: v })} />
</Row>
</React.Fragment>
)}
</Card>
<Card padded={false} title="Dead air fallback">
<Row label="Dead air fallback mode" first>
<Select value={draft.deadAirMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ deadAirMode: e.target.value })} />
</Row>
{draft.deadAirMode === "Override" && (
<Row label="Collection">
<Badge tone="accent">(none)</Badge>
</Row>
)}
</Card>
</div>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* action bar */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
<Badge tone="neutral">{DECOS.length} decos</Badge>
<span style={{ flex: 1 }} />
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
{GROUPS.map((g) => {
const groupDecos = DECOS.filter((d) => d.groupId === g.id);
return (
<Card
key={g.id}
padded={false}
title={g.name}
actions={
<div style={{ display: "flex", gap: 8 }}>
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New deco</Button>
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
</div>
}
>
{groupDecos.length === 0 ? (
<EmptyNote>No decos in this group.</EmptyNote>
) : (
groupDecos.map((d, i) => (
<FlushRow key={d.id} first={i === 0}>
<Ico n="Palette" s={15} color="var(--ctv-accent)" />
<button
type="button"
onClick={openEditor}
style={{ flex: 1, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}
>
{d.name}
</button>
<IconButton size="sm" variant="ghost" title="Delete deco"><Ico n="Trash2" s={14} /></IconButton>
</FlushRow>
))
)}
</Card>
);
})}
</div>
</div>
);
}
window.CTVDecos = Decos;
})();
@@ -1,108 +0,0 @@
// EPG / Guide grid — spec 4.4: channels down the left, time across the top,
// programme blocks, a vertical "now" marker, current programme uses the focal
// (live) color, filler muted, jump-to-now + time scrubber.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, Select, ChannelLogo } = NS;
const Ico = window.Ico;
const D = window.CTV_DATA;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const SLOT_W = 156; // px per 30-min slot
const RAIL_W = 196; // channel rail
const ROW_H = 82;
const HEAD_H = 34;
const NOW_SLOTS = 31 / 30; // 20:31 → just past the first slot
const catTone = { Filler: "var(--text-disabled)" };
function Programme({ p }) {
const filler = p.filler;
const live = p.live;
return (
<div title={`${p.title}${p.sub ? " — " + p.sub : ""}`}
style={{
position: "absolute",
left: p.offset * SLOT_W + 3,
width: p.span * SLOT_W - 6,
top: 5, bottom: 5,
borderRadius: "var(--radius-sm)",
padding: "8px 10px",
overflow: "hidden",
background: live ? "var(--ctv-live-soft)" : filler ? "var(--ctv-bg-sunken)" : "var(--ctv-surface-2)",
border: `1px solid ${live ? "var(--status-live)" : "var(--border-hairline)"}`,
display: "flex", flexDirection: "column", gap: 3,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
{live && <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--status-live)", flex: "0 0 auto" }} />}
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: filler ? "var(--text-disabled)" : "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.title}</span>
</div>
{p.sub && p.span > 1 && (
<span style={{ font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.sub}</span>
)}
{p.span > 1 && (
<span style={{ marginTop: "auto", font: "var(--weight-medium) 9px/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: live ? "var(--status-live)" : "var(--text-disabled)" }}>{p.cat}</span>
)}
</div>
);
}
function Epg() {
const [filter, setFilter] = React.useState("All channels");
const totalW = RAIL_W + D.epgSlots.length * SLOT_W;
const nowX = RAIL_W + NOW_SLOTS * SLOT_W;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* toolbar */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<div style={{ width: 190 }}>
<Select size="sm" value={filter} onChange={(e) => setFilter(e.target.value)} options={["All channels", "Favorites", "News", "Kids", "Movies"]} />
</div>
<div style={{ flex: 1, display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>20:00</span>
<input type="range" min="0" max="100" defaultValue="12" style={{ flex: 1, accentColor: "var(--ctv-accent)", maxWidth: 320 }} />
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>02:00</span>
</div>
<Badge tone="accent" dot>Now 20:31</Badge>
<Button variant="primary" startIcon={<Ico n="Crosshair" s={15} />}>Jump to now</Button>
</div>
{/* grid */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", background: "var(--surface-app)" }}>
<div style={{ position: "relative", width: totalW, minWidth: "100%" }}>
{/* time header */}
<div style={{ display: "flex", position: "sticky", top: 0, zIndex: 3, height: HEAD_H, background: "var(--surface-app)", borderBottom: "1px solid var(--border-hairline)" }}>
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 4, background: "var(--surface-app)", borderRight: "1px solid var(--border-hairline)" }} />
{D.epgSlots.map((t, i) => (
<div key={i} style={{ width: SLOT_W, flex: "0 0 auto", display: "flex", alignItems: "center", padding: "0 10px", borderRight: "1px solid var(--border-hairline)", ...mono, fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{t}</div>
))}
</div>
{/* now marker */}
<div style={{ position: "absolute", top: HEAD_H, bottom: 0, left: nowX, width: 2, background: "var(--status-live)", zIndex: 2, pointerEvents: "none" }}>
<span style={{ position: "absolute", top: -6, left: -4, width: 10, height: 10, borderRadius: "50%", background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }} />
</div>
{/* channel rows */}
{D.epg.map((ch, i) => (
<div key={ch.num} style={{ display: "flex", height: ROW_H, borderBottom: "1px solid var(--border-hairline)" }}>
{/* rail */}
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 1, display: "flex", alignItems: "center", gap: 10, padding: "0 12px", background: "var(--surface-card)", borderRight: "1px solid var(--border-hairline)" }}>
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--status-live)", width: 26, flex: "0 0 auto" }}>{ch.num}</span>
<ChannelLogo name={ch.name} size={30} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</span>
</div>
{/* programme track */}
<div style={{ position: "relative", flex: 1, background: i % 2 ? "rgba(255,255,255,0.008)" : "transparent" }}>
{ch.programmes.map((p, j) => <Programme key={j} p={p} />)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
window.CTVEpg = Epg;
})();
@@ -0,0 +1,335 @@
// FFmpeg Profiles screen — a compact profile list (icon + name + resolution/codec badges +
// copy/delete) that drills into a General/Video/Audio settings-row editor, mirroring the
// ChannelEditScreen form language. Static mockup toggles list ⇄ editor for both states.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, Card, Input, Select, Checkbox, Tooltip } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const PROFILES = [
{ id: 1, name: "1080p30 — NVENC", resolution: "1920x1080", video: "hevc", audio: "aac" },
{ id: 2, name: "1080p30 — Software", resolution: "1920x1080", video: "h264", audio: "aac" },
{ id: 3, name: "720p30 — VAAPI", resolution: "1280x720", video: "h264", audio: "aac" },
{ id: 4, name: "4K HDR — NVENC AV1", resolution: "3840x2160", video: "av1", audio: "ac3" },
{ id: 5, name: "Retro 480i — Copy", resolution: "720x480", video: "mpeg-2 video", audio: "aac" },
{ id: 6, name: "Music Channel — Audio only", resolution: "1280x720", video: "h264", audio: "aac (latm)" },
{ id: 7, name: "QSV Balanced", resolution: "1920x1080", video: "hevc", audio: "aac" },
{ id: 8, name: "Low-bitrate Mobile", resolution: "854x480", video: "h264", audio: "aac" },
];
const SCALING_OPTIONS = [
{ label: "Scale and Pad", value: "ScaleAndPad" },
{ label: "Stretch", value: "Stretch" },
{ label: "Crop", value: "Crop" },
];
const PAD_MODE_OPTIONS = [
{ label: "Hardware If Possible", value: "HardwareIfPossible" },
{ label: "Software", value: "Software" },
];
const RESOLUTION_OPTIONS = ["1920x1080", "1280x720", "3840x2160", "854x480", "720x480"];
const VIDEO_FORMAT_OPTIONS = [
{ label: "h264", value: "H264" },
{ label: "hevc", value: "Hevc" },
{ label: "mpeg-2", value: "Mpeg2Video" },
{ label: "av1", value: "Av1" },
];
const VIDEO_PROFILE_OPTIONS = [
{ label: "main", value: "main" },
{ label: "high", value: "high" },
{ label: "high444p", value: "high444p" },
];
const PRESET_OPTIONS = [{ label: "(none)", value: "" }, { label: "llhp", value: "llhp" }, { label: "llhq", value: "llhq" }];
const BIT_DEPTH_OPTIONS = [
{ label: "8-bit", value: "EightBit" },
{ label: "10-bit", value: "TenBit" },
];
const HWACCEL_OPTIONS = ["None", "Nvenc", "Qsv", "Vaapi", "Amf", "VideoToolbox"];
const TONEMAP_OPTIONS = ["Linear", "Clip", "Gamma", "Reinhard", "Mobius", "Hable"];
const AUDIO_FORMAT_OPTIONS = [
{ label: "aac", value: "Aac" },
{ label: "ac3", value: "Ac3" },
{ label: "aac (latm)", value: "AacLatm" },
];
const LOUDNESS_OPTIONS = [
{ label: "Off", value: "Off" },
{ label: "loudnorm", value: "LoudNorm" },
];
function Row({ label, help, control = 320, first = false, children }) {
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 24,
padding: "13px 20px",
borderTop: first ? "none" : "1px solid var(--border-hairline)",
}}
>
<div style={{ flex: "1 1 auto", minWidth: 0, paddingTop: 5 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 460 }}>{help}</div>}
</div>
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
</div>
);
}
function ActionBar({ children }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
{children}
</div>
);
}
function ProfileList({ onOpen, onAdd }) {
return (
<React.Fragment>
<ActionBar>
<Badge tone="neutral">{PROFILES.length} profiles</Badge>
<span style={{ flex: 1 }} />
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={onAdd}>
New profile
</Button>
</ActionBar>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
<Card padded={false}>
{PROFILES.map((p, i) => (
<div
key={p.id}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "0 16px",
height: "var(--row-h, 52px)",
minHeight: 52,
borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)",
}}
>
<Ico n="SlidersHorizontal" s={15} color="var(--ctv-accent)" />
<button
type="button"
onClick={() => onOpen(p)}
style={{
flex: "1 1 auto",
minWidth: 0,
textAlign: "left",
background: "none",
border: "none",
padding: 0,
cursor: "pointer",
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
color: "var(--ctv-accent)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{p.name}
</button>
<Badge tone="neutral">{p.resolution}</Badge>
<Badge tone="neutral">{`${p.video} / ${p.audio}`}</Badge>
<Tooltip label={`Copy ${p.name}`}>
<IconButton size="sm" variant="ghost" title={`Copy ${p.name}`} onClick={() => onAdd()}>
<Ico n="Copy" s={14} />
</IconButton>
</Tooltip>
<Tooltip label="Delete">
<IconButton size="sm" variant="ghost" title="Delete">
<Ico n="Trash2" s={14} />
</IconButton>
</Tooltip>
</div>
))}
</Card>
</div>
</React.Fragment>
);
}
function ProfileEditor({ profile, isAdd, onBack }) {
const [normalizeVideo, setNormalizeVideo] = React.useState(true);
const [normalizeAudio, setNormalizeAudio] = React.useState(true);
const [hwaccel, setHwaccel] = React.useState(profile ? (profile.video === "hevc" ? "Nvenc" : "None") : "None");
const [loudness, setLoudness] = React.useState("Off");
return (
<React.Fragment>
<ActionBar>
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>
All profiles
</Button>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
{isAdd ? "New FFmpeg profile" : "Edit FFmpeg profile"}
</span>
<span style={{ flex: 1 }} />
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>
{isAdd ? "Add profile" : "Save profile"}
</Button>
</ActionBar>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
<Card padded={false} title="General">
<Row first label="Name" control={360}>
<Input size="sm" value={isAdd ? "" : profile.name} placeholder="e.g. 1080p30 Software" />
</Row>
<Row label="Thread count" control={200}>
<Input size="sm" type="number" trailing="threads" value="0" />
</Row>
<Row label="Normalize audio" control={200}>
<Checkbox checked={normalizeAudio} onChange={setNormalizeAudio} />
</Row>
<Row label="Normalize video" control={200}>
<Checkbox checked={normalizeVideo} onChange={setNormalizeVideo} />
</Row>
<Row label="Preferred resolution" control={360}>
<Select value={isAdd ? RESOLUTION_OPTIONS[0] : profile.resolution} options={RESOLUTION_OPTIONS} />
</Row>
{normalizeVideo && (
<React.Fragment>
<Row label="Scaling behavior" control={360}>
<Select value="ScaleAndPad" options={SCALING_OPTIONS} />
</Row>
<Row control={360} label="Pad mode" help="Hardware padding only applies with VAAPI; otherwise software padding is used.">
<Select disabled={hwaccel !== "Vaapi"} value="Software" options={PAD_MODE_OPTIONS} />
</Row>
</React.Fragment>
)}
</Card>
<Card padded={false} title="Video">
{normalizeVideo ? (
<React.Fragment>
<Row first label="Format" control={360}>
<Select value={isAdd ? "H264" : "Hevc"} options={VIDEO_FORMAT_OPTIONS} />
</Row>
<Row label="Profile" control={360}>
<Select value="high" options={VIDEO_PROFILE_OPTIONS} />
</Row>
<Row label="Preset" control={360}>
<Select disabled={hwaccel === "None"} value={hwaccel === "None" ? "" : "llhq"} options={PRESET_OPTIONS} />
</Row>
<Row label="Allow B-frames" control={200}>
<Checkbox checked={false} />
</Row>
<Row label="Bit depth" control={360}>
<Select value="EightBit" options={BIT_DEPTH_OPTIONS} />
</Row>
<Row label="Bitrate" control={220}>
<Input size="sm" type="number" trailing="kBit/s" value="2000" />
</Row>
<Row label="Buffer size" control={220}>
<Input size="sm" type="number" trailing="kBit" value="4000" />
</Row>
<Row label="Hardware acceleration" control={360}>
<Select value={hwaccel} options={HWACCEL_OPTIONS} onChange={(e) => setHwaccel(e.target.value)} />
</Row>
{hwaccel === "Vaapi" && (
<React.Fragment>
<Row label="VAAPI driver" control={360}>
<Select value="Default" options={["Default", "iHD", "i965", "RadeonSI", "Nouveau"]} />
</Row>
<Row label="VAAPI display" control={360} help="Rendering display, e.g. drm.">
<Input size="sm" value="drm" />
</Row>
</React.Fragment>
)}
{(hwaccel === "Vaapi" || hwaccel === "Qsv") && (
<Row label={hwaccel === "Vaapi" ? "VAAPI device" : "QSV device"} control={360} help="Render device, e.g. /dev/dri/renderD128.">
<Input size="sm" value="/dev/dri/renderD128" />
</Row>
)}
{hwaccel === "Qsv" ? (
<Row label="QSV extra hardware frames" control={200}>
<Input size="sm" type="number" value="64" />
</Row>
) : (
<Row label="Tonemap algorithm" control={360}>
<Select value="Linear" options={TONEMAP_OPTIONS} />
</Row>
)}
<Row label="Normalize frame rate" control={200}>
<Checkbox checked={false} />
</Row>
<Row label="Normalize colors" control={200}>
<Checkbox checked={true} />
</Row>
<Row label="Auto deinterlace video" control={200}>
<Checkbox checked={true} />
</Row>
</React.Fragment>
) : (
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: 16, padding: "10px 12px", borderRadius: "var(--radius-sm)", background: "var(--ctv-warn-soft)", border: "1px solid var(--border-hairline)" }}>
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
Video will be copied as-is, including timestamps, which will cause issues with most clients.
</span>
</div>
)}
</Card>
<Card padded={false} title="Audio">
{normalizeAudio ? (
<React.Fragment>
<Row first label="Format" control={360}>
<Select value="Aac" options={AUDIO_FORMAT_OPTIONS} />
</Row>
<Row label="Bitrate" control={220}>
<Input size="sm" type="number" trailing="kBit/s" value="192" />
</Row>
<Row label="Buffer size" control={220}>
<Input size="sm" type="number" trailing="kBit" value="384" />
</Row>
<Row label="Channels" control={200}>
<Input size="sm" type="number" value="2" />
</Row>
<Row label="Sample rate" control={220}>
<Input size="sm" type="number" trailing="kHz" value="48" />
</Row>
<Row label="Normalize loudness" control={360}>
<Select value={loudness} options={LOUDNESS_OPTIONS} onChange={(e) => setLoudness(e.target.value)} />
</Row>
{loudness === "LoudNorm" && (
<Row label="Target loudness" control={220}>
<Input size="sm" type="number" trailing="LUFS" value="-23" />
</Row>
)}
</React.Fragment>
) : (
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: 16, padding: "10px 12px", borderRadius: "var(--radius-sm)", background: "var(--ctv-warn-soft)", border: "1px solid var(--border-hairline)" }}>
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
Audio will be copied as-is, including timestamps, which will cause issues with most clients.
</span>
</div>
)}
</Card>
</div>
</React.Fragment>
);
}
function FFmpegProfiles() {
const [screen, setScreen] = React.useState({ kind: "list" });
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{screen.kind === "list" ? (
<ProfileList onOpen={(p) => setScreen({ kind: "edit", profile: p })} onAdd={() => setScreen({ kind: "add" })} />
) : (
<ProfileEditor
profile={screen.profile}
isAdd={screen.kind === "add"}
onBack={() => setScreen({ kind: "list" })}
/>
)}
</div>
);
}
window.CTVFFmpegProfiles = FFmpegProfiles;
})();
@@ -0,0 +1,146 @@
// Filler Presets screen — a flat list of reusable pre/mid/post-roll filler
// rules with a slide-in editor (kind/mode/collection form), mirroring the
// live SPA's list⇄editor route pair inside one static mockup.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, Card, Input, Select, Checkbox, Tooltip } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const PRESETS = [
{ id: 1, name: "Network Bumper — Short", kind: "Pre-Roll", mode: "Count", detail: "3 clips", collection: "Bumpers / Short" },
{ id: 2, name: "Station ID", kind: "Pre-Roll", mode: "Duration", detail: "0:00:15", collection: "Station IDs" },
{ id: 3, name: "Ad Break — Standard", kind: "Mid-Roll", mode: "Count", detail: "2 clips", collection: "Commercials 2024" },
{ id: 4, name: "Ad Break — Long Form", kind: "Mid-Roll", mode: "RandomCount", detail: "13 clips", collection: "Commercials 2024" },
{ id: 5, name: "Coming Up Next", kind: "Post-Roll", mode: "Count", detail: "1 clip", collection: "Promos" },
{ id: 6, name: "Sign-Off Reel", kind: "Tail", mode: "—", detail: "fills remainder", collection: "Late Night Bumpers" },
{ id: 7, name: "Pad to Top of Hour", kind: "Mid-Roll", mode: "Pad", detail: "pad 15m", collection: "Filler Loops" },
{ id: 8, name: "Dead Air Fallback", kind: "Fallback", mode: "—", detail: "always available", collection: "Please Stand By" },
{ id: 9, name: "Retro Bumper Pack", kind: "Pre-Roll", mode: "Count", detail: "4 clips", collection: "Retro Bumpers" },
];
const KIND_TONE = { "Pre-Roll": "accent", "Mid-Roll": "accent", "Post-Roll": "neutral", Tail: "neutral", Fallback: "warn" };
const KIND_OPTIONS = ["Pre-Roll", "Mid-Roll", "Post-Roll", "Tail", "Fallback"];
const MODE_OPTIONS = ["Duration", "Count", "Pad", "Random Count"];
const PAD_OPTIONS = ["5", "10", "15", "30"];
const COLLECTION_TYPE_OPTIONS = ["Collection", "Television Show", "Television Season", "Artist", "Multi Collection", "Smart Collection", "Playlist"];
function Row({ label, help, control = 320, first = false, children }) {
return (
<div style={{ display: "flex", alignItems: "flex-start", gap: 24, padding: "14px var(--pad-card)", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<div style={{ flex: 1, minWidth: 0, paddingTop: 6 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-faint)", maxWidth: 460 }}>{help}</div>}
</div>
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
</div>
);
}
function FillerPresets() {
const [view, setView] = React.useState("list"); // "list" | "edit" | "add"
const [active, setActive] = React.useState(PRESETS[2]);
const [hover, setHover] = React.useState(null);
const [kind, setKind] = React.useState("Mid-Roll");
const [fmode, setFmode] = React.useState("Count");
const openEdit = (p) => { setActive(p); setKind(p.kind); setFmode(p.mode === "—" ? "Count" : p.mode); setView("edit"); };
const openAdd = () => { setActive(null); setKind("Pre-Roll"); setFmode("Count"); setView("add"); };
const modeLocked = kind === "Tail" || kind === "Fallback";
if (view !== "list") {
const isEdit = view === "edit";
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={() => setView("list")}>All filler presets</Button>
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{isEdit ? "Edit filler preset" : "New filler preset"}</span>
<span style={{ flex: 1 }} />
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>{isEdit ? "Save filler preset" : "Add filler preset"}</Button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
<Card padded={false} title="Filler preset">
<Row control={360} first label="Name">
<Input size="sm" value={active ? active.name : ""} placeholder="e.g. Ad Break — Standard" />
</Row>
<Row control={360} label="Kind" help={isEdit ? "Kind cannot be changed after creation." : undefined}>
<Select disabled={isEdit} value={kind} onChange={(e) => setKind(e.target.value)} options={KIND_OPTIONS} />
</Row>
<Row control={360} label="Mode">
<Select disabled={modeLocked} value={modeLocked ? "" : fmode} onChange={(e) => setFmode(e.target.value)} options={modeLocked ? [{ value: "", label: "(n/a)" }] : MODE_OPTIONS} />
</Row>
<Row control={360} label="Duration">
<div style={{ display: "flex", gap: 8 }}>
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "0" : ""} trailing="h" />
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "0" : ""} trailing="m" />
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "15" : ""} trailing="s" />
</div>
</Row>
<Row control={200} label="Count">
<Input size="sm" type="number" disabled={fmode !== "Count" && fmode !== "Random Count"} value={fmode === "Count" || fmode === "Random Count" ? "2" : ""} />
</Row>
<Row control={360} label="Pad to nearest minute">
<Select disabled={fmode !== "Pad"} value={fmode === "Pad" ? "15" : ""} options={[{ value: "", label: "(none)" }, ...PAD_OPTIONS]} />
</Row>
<Row control={200} label="Allow watermarks">
<Checkbox checked={false} onChange={() => {}} />
</Row>
<Row control={360} label="Collection type">
<Select value="Collection" options={COLLECTION_TYPE_OPTIONS} />
</Row>
<Row control={360} label="Collection">
<Select value={active ? active.collection : ""} options={[active ? active.collection : "(none)", "Commercials 2024", "Bumpers / Short", "Promos"]} />
</Row>
<Row control={200} label="Use chapters as media items" help="Schedule individual chapters instead of entire files.">
<Checkbox checked={false} disabled={kind === "Fallback"} onChange={() => {}} />
</Row>
<Row control={360} label="Expression" help="Mid-roll only: add filler only when this expression is true for a mid-roll point.">
<Input size="sm" disabled={kind !== "Mid-Roll"} value={kind === "Mid-Roll" ? "chapter_index % 2 == 0" : ""} />
</Row>
</Card>
</div>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
<Badge tone="neutral">{PRESETS.length} presets</Badge>
<span style={{ flex: 1 }} />
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={openAdd}>New filler preset</Button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
<Card padded={false}>
{PRESETS.map((p, i) => {
const isHover = hover === p.id;
return (
<div key={p.id} onMouseEnter={() => setHover(p.id)} onMouseLeave={() => setHover(null)}
style={{ display: "flex", alignItems: "center", gap: 12, padding: "0 var(--pad-cell-x)", height: 52, borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)", background: isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}>
<Ico n="Film" s={15} color="var(--ctv-accent)" />
<button type="button" onClick={() => openEdit(p)}
style={{ flex: 1, minWidth: 0, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>
{p.name}
</button>
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-faint)", minWidth: 160, textAlign: "right" }}>{p.collection}</span>
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)", minWidth: 90, textAlign: "right" }}>{p.detail}</span>
<Badge tone={p.mode === "—" ? "neutral" : "neutral"}>{p.mode}</Badge>
<Badge tone={KIND_TONE[p.kind]}>{p.kind}</Badge>
<div style={{ display: "flex", alignItems: "center", opacity: isHover ? 1 : 0.4, transition: "opacity var(--dur-fast)" }}>
<Tooltip label="Delete"><IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
</div>
</div>
);
})}
</Card>
</div>
</div>
);
}
window.CTVFillerPresets = FillerPresets;
})();
@@ -0,0 +1,181 @@
// Guide screen — a horizontally-scrolling EPG grid: sticky channel rail down
// the left, a 30-min time ruler across the top, a live "now" marker, and
// programme blocks (current-programme highlight, filler dimming, empty rows).
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Badge, Button, ChannelLogo, Select, StatusDot } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const SLOT_MIN = 30;
const SLOT_W = 156;
const RAIL_W = 196;
const ROW_H = 82;
const HEAD_H = 34;
const WINDOW_HOURS = 13;
const SLOT_COUNT = (WINDOW_HOURS * 60) / SLOT_MIN;
const WINDOW_START = new Date(2026, 0, 1, 12, 0, 0);
const WINDOW_END = new Date(WINDOW_START.getTime() + WINDOW_HOURS * 60 * 60000);
const NOW = new Date(WINDOW_START.getTime() + 312 * 60000); // 5:12 PM — mid-window
const nowInWindow = NOW >= WINDOW_START && NOW <= WINDOW_END;
const nowMin = (NOW.getTime() - WINDOW_START.getTime()) / 60000;
function formatTime(d) { return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
function pxFromMin(min) { return (min / SLOT_MIN) * SLOT_W; }
const slots = Array.from({ length: SLOT_COUNT }, (_, i) => new Date(WINDOW_START.getTime() + i * SLOT_MIN * 60000));
const nowOffset = pxFromMin(nowMin);
const totalWidth = RAIL_W + slots.length * SLOT_W;
const windowPct = nowInWindow ? Math.round((NOW.getTime() - WINDOW_START.getTime()) / (WINDOW_END.getTime() - WINDOW_START.getTime()) * 100) : 0;
function buildProgrammes(segments) {
let t = 0;
return segments.map((seg) => {
const startMin = t;
t += seg.dur;
return { title: seg.title, subTitle: seg.sub || null, category: seg.cat || null, filler: !!seg.filler, startMin, stopMin: t };
});
}
const NEWS_TITLES = ["Midday Report", "World Markets", "Capitol Watch", "Global Desk", "Primetime Analysis", "Evening Edition", "Nightly Wrap", "Overnight Desk", "World Tonight", "Morning Preview", "Early Edition", "Business Brief", "Weather & Traffic"];
const CHANNELS = [
{
number: "1.1", name: "ChicoryTV Family", onAir: true,
programmes: buildProgrammes([
{ dur: 90, title: "Tom & Jerry — Marathon", cat: "Kids" },
{ dur: 60, title: "Bluey — Season 3", cat: "Kids", sub: "The Sleepover" },
{ dur: 120, title: "Afternoon Movie: Starlight Express", cat: "Movie" },
{ dur: 90, title: "World Tonight — Evening Bulletin", cat: "News", sub: "Live from the newsroom" },
{ dur: 120, title: "Prime Time Drama Block", cat: "Drama" },
{ dur: 90, title: "Late Night Talk", cat: "Talk" },
]),
},
{
number: "2.1", name: "World News Network", onAir: true,
programmes: buildProgrammes(NEWS_TITLES.map((t) => ({ dur: 60, title: t, cat: "News" }))),
},
{
number: "3.1", name: "Sitcom Vault", onAir: false,
programmes: buildProgrammes([
{ dur: 30, title: "The Office — Casino Night", cat: "Sitcom" },
{ dur: 30, title: "Parks & Rec — Flu Season", cat: "Sitcom" },
{ dur: 30, title: "Community — Modern Warfare", cat: "Sitcom" },
{ dur: 30, title: "Brooklyn 99 — Halloween", cat: "Sitcom" },
{ dur: 30, title: "The Office — Diversity Day", cat: "Sitcom" },
{ dur: 30, title: "Parks & Rec — The Set Up", cat: "Sitcom" },
]),
},
{
number: "4.2", name: "Synthpop Radio", onAir: true,
programmes: buildProgrammes([
{ dur: 180, title: "Retro Hits Mix", cat: "Music", filler: true },
{ dur: 180, title: "Live DJ Set — Nova", cat: "Music", sub: "Synthwave hour" },
{ dur: 180, title: "Overnight Mix", cat: "Music", filler: true },
]),
},
{
number: "5.1", name: "Kids Zone", onAir: false,
programmes: buildProgrammes([
{ dur: 60, title: "Sesame Street — Episode 4102", cat: "Kids" },
{ dur: 30, title: "Bluey — Marathon", cat: "Kids", sub: "Back-to-back episodes" },
{ dur: 90, title: "Afternoon Cartoons", cat: "Kids" },
]),
},
{ number: "9.1", name: "Retro Cartoons", onAir: false, programmes: [] },
];
function ProgrammeBlock({ p, live }) {
const left = pxFromMin(p.startMin) + 3;
const width = Math.max(24, pxFromMin(p.stopMin) - pxFromMin(p.startMin) - 6);
return (
<div title={p.subTitle ? `${p.title} - ${p.subTitle}` : p.title}
style={{
position: "absolute", left, width, top: 5, bottom: 5,
display: "flex", flexDirection: "column", gap: 3, minWidth: 0, overflow: "hidden",
border: `1px solid ${live ? "var(--status-live)" : "var(--border-hairline)"}`,
borderRadius: "var(--radius-sm)",
background: live ? "var(--ctv-live-soft)" : p.filler ? "var(--ctv-bg-sunken)" : "var(--surface-raised)",
padding: "var(--space-4) var(--space-5)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", minWidth: 0 }}>
{live && <StatusDot status="live" size={6} />}
<strong style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: p.filler ? "var(--text-disabled)" : "var(--text-primary)" }}>{p.title}</strong>
</div>
{p.subTitle && <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)" }}>{p.subTitle}</span>}
{p.filler ? (
<Badge tone="neutral">Filler</Badge>
) : p.category && (
<small style={{ marginTop: "auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) 9px/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: live ? "var(--status-live)" : "var(--text-disabled)" }}>{p.category}</small>
)}
</div>
);
}
function GuideRow({ channel, index }) {
return (
<div style={{ display: "flex", height: ROW_H, borderBottom: "1px solid var(--border-hairline)" }}>
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 3, display: "flex", alignItems: "center", gap: "var(--space-5)", padding: "0 var(--space-6)", background: "var(--surface-card)", borderRight: "1px solid var(--border-hairline)" }}>
<code style={{ ...mono, width: 30, flex: "0 0 auto", color: "var(--status-live)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)" }}>{channel.number}</code>
<ChannelLogo name={channel.name} size={30} />
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{channel.name}</span>
{channel.onAir && <StatusDot status="live" size={7} />}
</div>
<div style={{ position: "relative", flex: 1, minWidth: 0, background: index % 2 ? "color-mix(in srgb, var(--surface-raised) 18%, transparent)" : "transparent" }}>
{channel.programmes.length === 0 ? (
<span style={{ position: "absolute", top: "50%", left: "var(--space-6)", transform: "translateY(-50%)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No programmes in this window</span>
) : channel.programmes.map((p, i) => (
<ProgrammeBlock key={i} p={p} live={channel.onAir && nowInWindow && nowMin >= p.startMin && nowMin < p.stopMin} />
))}
</div>
</div>
);
}
function Guide() {
return (
<div style={{ display: "grid", gridTemplateRows: "auto minmax(0, 1fr)", gap: "var(--space-6)", height: "100%", minHeight: 0, padding: "var(--space-6) 20px" }}>
{/* toolbar */}
<section style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: "var(--space-5)", padding: "var(--space-5) 0", borderBottom: "1px solid var(--border-hairline)" }} aria-label="Guide controls">
<div style={{ width: 190, flex: "0 1 190px" }}>
<Select disabled label="Channel group" options={["All channels"]} size="sm" value="All channels" />
</div>
<div style={{ minWidth: 0, maxWidth: 420, flex: "1 1 240px", display: "flex", alignItems: "center", gap: "var(--space-4)" }}>
<span style={{ ...mono, color: "var(--text-disabled)", fontSize: "var(--text-2xs)" }}>{formatTime(WINDOW_START)}</span>
<input aria-label="Guide window position" disabled max="100" min="0" type="range" value={windowPct} readOnly style={{ minWidth: 0, flex: 1, accentColor: "var(--action-primary)" }} />
<span style={{ ...mono, color: "var(--text-disabled)", fontSize: "var(--text-2xs)" }}>{formatTime(WINDOW_END)}</span>
</div>
<Badge tone={nowInWindow ? "accent" : "neutral"} dot={nowInWindow}>Now {formatTime(NOW)}</Badge>
<Button variant="secondary">Previous</Button>
<Button variant="secondary">Next guide window</Button>
<Button variant="primary" startIcon={<Ico n="Crosshair" s={15} />}>Jump to now</Button>
</section>
{/* grid */}
<section style={{ minHeight: 420, overflow: "hidden", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-sm)", background: "var(--surface-app)" }} aria-label="Guide timeline">
<div style={{ width: "100%", height: "100%", overflow: "auto" }}>
<div style={{ position: "relative", width: totalWidth, minWidth: "100%" }}>
<div style={{ position: "sticky", top: 0, zIndex: 4, display: "flex", height: HEAD_H, background: "var(--surface-app)", borderBottom: "1px solid var(--border-hairline)" }}>
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 5, background: "var(--surface-app)", borderRight: "1px solid var(--border-hairline)" }} />
{slots.map((slot, i) => (
<div key={i} style={{ width: SLOT_W, flex: "0 0 auto", display: "flex", alignItems: "center", padding: "0 var(--space-5)", borderRight: "1px solid var(--border-hairline)", ...mono, color: "var(--text-secondary)", fontSize: "var(--text-xs)" }}>{formatTime(slot)}</div>
))}
</div>
{nowInWindow && (
<div aria-hidden="true" style={{ position: "absolute", top: HEAD_H, bottom: 0, left: RAIL_W + nowOffset, width: 2, background: "var(--status-live)", zIndex: 2, pointerEvents: "none" }}>
<span style={{ position: "absolute", top: -5, left: -4, width: 10, height: 10, borderRadius: "var(--radius-pill)", background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }} />
</div>
)}
{CHANNELS.map((channel, i) => <GuideRow channel={channel} index={i} key={channel.number} />)}
</div>
</div>
</section>
</div>
);
}
window.CTVGuide = Guide;
})();

Some files were not shown because too many files have changed in this diff Show More