Compare commits

...
Author SHA1 Message Date
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
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 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
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
83 changed files with 6329 additions and 3561 deletions
+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" />
@@ -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);
@@ -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");
@@ -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,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);
+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>
@@ -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,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();
}
@@ -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?>
@@ -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">
@@ -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)
@@ -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>
+176
View File
@@ -18196,6 +18196,157 @@
]
}
},
"/api/v1/settings/iptv": {
"get": {
"tags": [
"Settings"
],
"summary": "Get IPTV output settings",
"operationId": "GetIptvSettings",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/IptvSettingsResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/IptvSettingsResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/IptvSettingsResponseModel"
}
}
}
},
"401": {
"description": "API key missing or invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{ }
]
},
"put": {
"tags": [
"Settings"
],
"summary": "Update IPTV output settings",
"operationId": "UpdateIptvSettings",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/UpdateIptvSettingsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateIptvSettingsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/UpdateIptvSettingsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/UpdateIptvSettingsRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/IptvSettingsResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/IptvSettingsResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/IptvSettingsResponseModel"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"400": {
"description": "Request validation failed (model binding or FluentValidation).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
{ }
]
}
},
"/api/v1/settings/scanner": {
"get": {
"tags": [
@@ -25948,6 +26099,17 @@
}
}
},
"IptvSettingsResponseModel": {
"required": [
"baseUrl"
],
"type": "object",
"properties": {
"baseUrl": {
"type": "string"
}
}
},
"LanguageCodeResponseModel": {
"required": [
"code",
@@ -30988,6 +31150,20 @@
}
}
},
"UpdateIptvSettingsRequest": {
"required": [
"baseUrl"
],
"type": "object",
"properties": {
"baseUrl": {
"type": [
"null",
"string"
]
}
}
},
"UpdateLocalLibraryPathRequest": {
"required": [
"id",
+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>
+1
View File
@@ -45,6 +45,7 @@ COPY *.sln .
# local/CI builds. Directory.Packages.props is REQUIRED here: with CPM the csproj
# carry no versions, so restore fails without the central manifest.
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json .editorconfig ./
COPY eng/analyzers/sdk-all-suggestion.globalconfig ./eng/analyzers/
COPY artwork/* ./artwork/
COPY ErsatzTV/*.csproj ./ErsatzTV/
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
+2 -1
View File
@@ -23,7 +23,8 @@ Read in this order at session start:
the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent
(or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory.
9. **`docs/decisions.md`** — append-only "why" log. Check here before challenging an existing
convention.
convention. Start from its **Index**, which links the four topic files under `docs/decisions/`
(large same-topic clusters) and lists the remaining in-file entries.
10. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
Also present in `docs/`:
+3 -1
View File
@@ -625,7 +625,9 @@ OpenAPI generation, so the spec can't drift). When you add an endpoint:
- **Sensitive-read GETs** that disclose secrets/paths or trigger work must carry `[RequiresAuthentication]`
(renamed from `[RequiresApiKey]`) so they stay gated even if an operator sets `Api:RequireKeyForReads=false`.
A valid session satisfies this tier just as the key does. Current tier: `Troubleshoot`/`Logs`/`Settings`/
`Maintenance`. `ApiControllerSecurityTests` asserts this reflectively.
`Maintenance`, plus local-library detail (`GET /api/v1/libraries/local/{id}`), whose response contains
server filesystem paths. The ordinary local-library list remains in the catalog-read opt-out tier.
`ApiControllerSecurityTests` asserts these boundaries reflectively.
- **Internal loopback callbacks** (the scanner's `/api/v1/scan/*`) and the **`/api/v1/auth/*` surface itself** use
`[SkipApiAuthorization]` (renamed from `[SkipApiKeyAuthorization]`). The scanner adds `[LocalhostOnly]`; the
auth surface must be reachable before a caller is authenticated, and its one sensitive action
+72 -16
View File
@@ -21,6 +21,7 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t
| `v26.4.0` | First fork release carrying application changes. Later 2026 releases continue `26.5.0`, `26.6.0`, …; a new year resets to `27.1.0`. |
| `v26.7.0` | Blazor-removal release: ChicoryTV became the only UI. |
| `v26.8.0` | Secured/versioned ChicoryTV SPA + REST API go-live release (#335). |
| `v26.9.0` | Configurable advertised IPTV base URL for M3U/XMLTV (#340) + SPA shell/routing + playouts modularization (#247/#245); on-air/Plex/library-path fixes (#99/#345/#371); coverage + functional-E2E CI (#15/#299). |
**Before cutting a release — consolidate `docs/decisions.md`.** The log is append-only between releases
(ersatztv#303 H9), so a release boundary is where superseded entries get pruned/merged and the Index
@@ -95,6 +96,18 @@ long builds (observed 31 min) stalling every PR run.
Docker build) → `dotnet build -c Release` → `dotnet test -c Release --no-build`. Gates
the image build.
- **Code coverage** (ersatztv#15): `dotnet test` runs with `--collect:"XPlat Code Coverage"
--settings coverlet.runsettings --results-directory ./coverage`, so `coverlet.collector`
(referenced by every `*.Tests` project) emits a Cobertura report per project. A follow-up
**Coverage summary** step merges them with ReportGenerator (`TextSummary` to the log,
`MarkdownSummaryGithub` to the job step summary). No floor is enforced yet ("decide on a
floor later" — #15); the step is `continue-on-error: true`, so a missing report or a
transient tool install never blocks a build.
- **`coverlet.runsettings` excludes generated EF migration code** (`**/Migrations/*.cs`,
~2.59M generated lines vs ~200k authored). Instrumenting it OOM-killed the shared `test`
job (exit 137); excluding it cuts the instrumented surface ~126× (2.5M→20k coverable
lines in the whole-solution `Architecture.Tests` process) and makes the percentage reflect
authored code.
- **Shallow checkout**: `fetch-depth: 1` (ersatztv#190) — this job never runs `git
describe`/`git log`, only `build` needs full history/tags for version computation, so
`test` and `migrations` both check out shallow. `build`'s checkout stays `fetch-depth: 0`.
@@ -123,6 +136,33 @@ the image build.
cleanup; dumps container logs on failure. Catches routing / base-URL (#1) / migration
regressions that leave the app "up" but serving broken output.
### `functional-e2e` job (advisory; PR + main)
Boots the app **from source** and drives the manual live-E2E curl flows sessions have historically
re-run by hand, turning them into a CI regression net (ersatztv#299). It is the automatable half of
`docs/e2e-local.md`; the two scripts it chains run identically locally and in CI:
1. `npm ci` + `npm run build` (SPA), `dotnet build ErsatzTV.sln -c Release`, ensure `ffmpeg` is on PATH.
2. `ETV_BUILD_CONFIG=Release scripts/e2e-local.sh <fresh-config>` — copies `wwwroot`, launches
`dotnet ErsatzTV.dll` in the background (logging to a file so the launch step returns once the app
is ready), prints `PID`/`CONFIG_DIR`.
3. `scripts/e2e-functional.sh http://localhost:8409 <config>` — asserts, all curl-only and
deterministic (no seeded media, no browser): the **legacy→SPA redirect sweep** (+ the `/api`,
`/artwork` never-redirect exemption), the **auth/CSRF/security-stamp** flow (setup-claim →
read-gate 401/200 → re-claim 409 → CSRF 403 → login 401/200 → logout 403/204 → post-logout
stamp-revocation 401), the **library-scan status contract** (404 unknown / 202 queued /
`scan-status` 200), and the **If-Match/412** round-trip on `rerun-collections`. A `trap` kills the
instance on step exit.
**Advisory, by design** (the issue's "keep it a separate job so a functional-E2E flake can't block the
unit-test gate"): it is **not** a `needs:` of `build` and **not (yet) a required check**, so a flake
blocks nothing. Promote it to a required check / `build` dependency once it's proven reliable — the
same staged rollout the `migrations` job used. SQLite is the default provider, so unlike `migrations`
it needs **no** DB service container. Runs on PRs and on `main` (regression net); skipped for `v*` tag
builds. Out of scope for this first cut (need the scanner subprocess + seeded media, or a browser, to
be deterministic — tracked as ersatztv#299 follow-ups): the racy 409 "already-scanning" re-trigger,
the playout-build lock 409, and the genuinely UI-interactive Playwright flows.
### `docs-reminder` job (non-blocking, PR-only)
A lightweight nudge that enforces the CLAUDE.md "docs-update is part of done" rule for the
@@ -260,27 +300,43 @@ running product from outside our C#/review stack.
## Static analysis & formatting
**Analyzer packs** — `Directory.Build.targets` references **Roslynator**, **SonarAnalyzer.CSharp**,
**Meziantou.Analyzer**, and **AsyncFixer** for every project (versions central via CPM; guarded on
`ManagePackageVersionsCentrally` so the gitignored `.mcp` tool isn't pulled in). They are introduced
**incrementally** (ersatztv#15): `.editorconfig` sets `dotnet_analyzer_diagnostic.severity = suggestion`
so the packs surface findings without failing the `TreatWarningsAsErrors` (TWAE) build. **Promotion is
the enforcement** — raising a rule to `warning` makes it a CI-blocking error via the existing TWAE
build, so no separate lint step is needed.
**Analyzers** — `Directory.Build.props` enables the SDK analyzers at `latest-All` and turns on
`Microsoft.VisualStudio.Threading.Analyzers` for every centrally managed project.
`Directory.Build.targets` also references
**Roslynator**, **SonarAnalyzer.CSharp**, **Meziantou.Analyzer**, and **AsyncFixer** repo-wide (versions
central via CPM). All analyzer package references are guarded on `ManagePackageVersionsCentrally`, so the
gitignored `.mcp` tool—which deliberately uses inline package versions—does not inherit versionless
references.
They are introduced **incrementally** (ersatztv#15). `eng/analyzers/sdk-all-suggestion.globalconfig`
enumerates the .NET 10 SDK `All` inventory at `suggestion`; this exact-ID baseline is necessary because
the SDK's generated `latest-All` severities outrank `.editorconfig` bulk settings. `.editorconfig` keeps
the threading and curated-pack baselines at `suggestion`. Diagnostics remain visible to IDEs and
`dotnet format analyzers`, but do not create a wall of failures (a direct `latest-All` trial activated
455 existing errors in the TWAE projects).
**Promotion is the enforcement** — set a reviewed rule to `warning` in `.editorconfig` and append its ID
to the central `WarningsAsErrors` list in `Directory.Build.props`. The explicit list makes the rule block
in every project, including test projects that do not otherwise use TWAE. On a major SDK upgrade,
regenerate the checked-in SDK baseline from `analysislevel_<major>_all.globalconfig`, preserve SDK `none`
entries, and review newly introduced rules before accepting the snapshot.
Promoted rules are recorded here so the blocking subset stays intentional and reviewable:
- **Sonar `S3981` — `warning` + `WarningsAsErrors`** (ersatztv#15): rejects collection-count comparisons that are constant
regardless of collection size. Its first finding exposed `Workers.Count >= 0`, which permanently
classified scheduled memory releases as busy and skipped the intended aggressive idle collection.
- **StyleCop.Analyzers is intentionally excluded**: its latest stable (1.1.118) crashes (`AD0001`) on
C# `record` declarations, and its rules overlap the existing `.editorconfig`/Roslynator. Revisit via
the record-compatible `1.2.0-beta` only if specifically wanted.
- **Blazor `.razor` caveat**: editorconfig severity overrides don't reach analyzer diagnostics in Razor
`@code` (source-generator limitation — `dotnet format` can't fix them either), so the currently-firing
SonarAnalyzer rules are temporarily `NoWarn`-ed in `ErsatzTV.csproj` and burned down rule-by-rule in
**ersatztv#25**. The same rules run at `suggestion` on `.cs`.
- **The former Blazor `.razor` caveat is retired**: Blazor removal deleted the Razor sources and their
temporary Sonar `NoWarn` list. The `.razor`/`.cshtml` suggestion scopes remain in `.editorconfig` only
as a defensive default if server-rendered view code is ever reintroduced.
**Formatting** — the tree isn't yet `dotnet format`-clean (mixed UTF-8 BOM + whitespace inherited from
upstream: ~1,500 BOM files + ~480 whitespace). A one-time normalization lands as its **own dedicated
PR** (kept out of the analyzer work to stay reviewable); afterwards `dotnet format whitespace
--verify-no-changes` (+ `style`) joins the `test` job so drift can't return. `.gitattributes` already
pins line endings.
**Formatting** — the inherited tree still contains legacy UTF-8 BOM/whitespace debt, so the standing
policy is **format as you touch**, not a mass rewrite (ersatztv#311). The Husky pre-commit hook runs
`dotnet format --verify-no-changes` for staged C# files, and the blocking `format` CI job repeats that
check for C# files changed by the PR. Untouched legacy files remain outside the gate; `.gitattributes`
pins line endings. A one-time full-tree normalization remains a separate, unmade decision.
## Migration integrity (EF Core, both providers)
+7 -3
View File
@@ -111,9 +111,13 @@ talks exclusively to the REST API. Every former Blazor route now 302-redirects t
- **`TreatWarningsAsErrors=true`** in the app projects — a warning fails the build. `NoWarn` carries a
small, documented exemption list (e.g. `VSTHRD200`, `CA1873`); NuGet-audit `NU1901-1903` are demoted
to warnings in `Directory.Build.props` while `NU1904` (critical) blocks.
- **Static-analysis packs** (Roslynator, SonarAnalyzer, Meziantou, AsyncFixer) run at `suggestion` and
are promoted to `warning`/`error` rule-by-rule; promotion is the enforcement (the TWAE build). New
rules start at suggestion — never flip a wall of rules to error at once. (ersatztv#15)
- **Static analysis is centralized**: `Directory.Build.props` enables the SDK analyzers at
`latest-All` plus the threading analyzer for every centrally managed project; `Directory.Build.targets`
adds Roslynator, SonarAnalyzer, Meziantou, and AsyncFixer. Analyzer package references are CPM-guarded
so the gitignored `.mcp` tool retains its inline-version dependency model. The SDK globalconfig and
`.editorconfig` keep the broad baseline at `suggestion`. Promote one reviewed rule at a time by setting
it to `warning` and adding its ID to the central `WarningsAsErrors` list; never flip a wall of rules to error at once.
Refresh the SDK globalconfig deliberately when moving to a new .NET SDK major. (ersatztv#15)
## 8. Testing
+86 -869
View File
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
# API auth & security posture (#197, #206, #279, #283, #292, #295, #301, #319, #330)
Why ErsatzTV's REST/SPA surface is gated the way it is: the #197 cold-review remediation and
its bundles, the Blazor-removal auth sign-off, the artwork stored-XSS fix, fail-closed API
auth, browser-session auth + CSRF, the side-effecting-GET POST-ification, and the response
security headers (CSP/Permissions-Policy/CORP). Rationale relocated from the append-only
`docs/decisions.md` at the v26.9.0 consolidation; enforcement/mechanics cross-link to
`docs/api-conventions.md` §5/§9 and `docs/spa-conventions.md` §5e.
Issue trail: #197 (Phase-0 headers/PR #279; Bundle A fail-closed/PR #292; Bundle C
contract-freeze), #206 (Blazor-removal auth posture), #283 (artwork content-type), #295
(PR1 server session auth + PR2 SPA cutover), #301 (side-effecting GETs), #319 (CSP), #330 (CORP).
## Contents
- [2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)](#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206)
- [2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)](#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279)
- [2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)](#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss)
- [2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)](#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292)
- [2026-07-12 (#197 Bundle C — contract-freeze honesty)](#2026-07-12-197-bundle-c--contract-freeze-honesty)
- [2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)](#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only)
- [2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification](#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification)
- [2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)](#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline)
- [2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)](#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330)
---
## 2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)
Sign-off for the #91 phase (b) removal-gate item #206 ("deleting the last challenged Blazor page leaves
only the open SPA"). The actual authorization wiring in `ErsatzTV/Startup.cs` + `ErsatzTV/Pages` was
enumerated in code (not assumed) before clearing the gate.
**What is gated today**
- **OIDC** (`OidcHelper.IsEnabled` — active only when `Authority`/`ClientId`/`ClientSecret` are configured):
`AddAuthentication` (cookie default, `oidc` challenge) + `AddAuthorization` `DefaultPolicy =
RequireAuthenticatedUser` + **`AddRazorPages(… AuthorizeFolder("/"))`** (Startup.cs:379-385) +
`blazor.UseAuthentication()/UseAuthorization()` inside the Blazor `MapWhen` branch (Startup.cs:764-770).
`AuthorizeFolder("/")` gates **Razor Pages only**, and the sole user-facing Razor Page is
`Pages/_Host.cshtml` — the Blazor Server host (the other `.cshtml`, `Shared/_Favicons.cshtml`, is a
cosmetic partial). **So the OIDC challenge protects exactly the Blazor UI and nothing else.**
- **`/app` (SPA)** is served by its own `MapWhen(path=/app)` static-file branch (Startup.cs:701-714) with
**no authentication/authorization middleware** — open since phase (a) (`/``/app`, PR #148).
- **`/api/*` controllers** carry no `[Authorize]` (verified: zero attributes in `Controllers/`); the
Razor-Pages `AuthorizeFolder`/`DefaultPolicy` never reach them. Their only optional gate is the
per-endpoint `ApiKeyAuthorizationFilter` (API-key on mutating JSON endpoints), independent of OIDC/Blazor.
- **`/iptv/*`** is gated by `ConditionalIptvAuthorizeFilter` (JWT `JwtOnlyScheme`, active only when
`JwtHelper.IsEnabled`) in its own `MapWhen` branch (Startup.cs:797-803) — independent of Blazor.
**Posture after Blazor removal.** Removing `Pages/_Host.cshtml`, `AddRazorPages`/`AuthorizeFolder("/")`,
`blazor.UseAuthentication/UseAuthorization`, `MapBlazorHub`, and `MapFallbackToPage("/_Host")` deletes the
OIDC challenge's **only attachment point** — no user-facing surface remains challenged. **No capability is
lost:** every Blazor-served capability already has an open SPA equivalent (the #91 parity effort), and the
SPA was already the unauthenticated path since phase (a), so removal exposes nothing a user could not already
reach via `/app`.
**The one honest caveat (not a regression introduced by removal):** an OIDC-configured operator's *Blazor*
admin UI sits behind a login today; after removal there is no login-gated admin UI at all (the SPA admin UI
is open). That exposure delta already happened at **phase (a)** (the open SPA became the default admin
surface); removal only deletes the now-redundant challenged duplicate. Designing real SPA/API authentication
is deliberately deferred to **#197** (cold API security review — a HARD GATE before any remote exposure).
**Removal-PR must-not-break (independent gates that survive):** `ConditionalIptvAuthorizeFilter` (`/iptv/*`
JWT), `ApiKeyAuthorizationFilter` (mutating `/api/*`), and `JwtHelper` access_token query support. **Leave
the OIDC service registrations in place** (conditional on config, inert once no Razor Page consumes them) —
ripping OIDC out is a #197 decision, not a removal-PR one. The removal PR removes only the Blazor-attached
pieces above; `MapControllers()` + `/docs` (Scalar), currently co-hosted in the Blazor `MapWhen` branch, must
survive the surgical reduction.
## 2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)
Phase-0 of the #197 remediation — the posture-**independent** safe subset, shipped ahead of the
fail-closed/CORS/versioning posture work tracked in #280#289.
- **Baseline security headers on every response.** New `ErsatzTV/Middleware/SecurityHeadersMiddleware`,
registered **first** in the pipeline (before the `/iptv` `MapWhen` branch and `UseCors`), so it covers
`/api`, `/iptv`, `/artwork`, static, the SPA fallback, and filter-produced 4xx alike — which is why it's
middleware, not an MVC filter. It sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and
`Referrer-Policy: strict-origin-when-cross-origin`. `nosniff` is the standing backstop for the artwork
content-type MIME-sniffing risk (#283). **CSP and HSTS are deliberately NOT included here**: CSP must be
validated against the ChicoryTV SPA's inline assets, and HSTS is a proxy/TLS-termination decision — both
belong to the #197 posture design (#284/roadmap), not this baseline. Headers are set eagerly (not via
`Response.OnStarting`); safe today because the pipeline has no `UseExceptionHandler`/`UseStatusCodePages`
that would `Response.Clear()` — switch to `OnStarting` if one is ever added.
- **Constant-time API-key comparison.** `ApiKeyAuthorizationFilter` compares `X-Api-Key` with
`CryptographicOperations.FixedTimeEquals` (over UTF-8 bytes) instead of ordinal `string.Equals`, removing
the response-timing oracle on the write key. Accept/reject behavior is otherwise identical.
- **Playout pagination clamped.** `GET /api/playouts` and `GET /api/playouts/{id}/items` now clamp
`Math.Clamp(pageSize, 1, 100)` + `Math.Max(0, pageNum)` before the query — applying the api-conventions §1
clamp convention the other paged endpoints already follow (these two were passing the raw client value
straight to EF `Take()`).
The larger #197 posture (fail-closed writes, sensitive-read auth tier, CORS lockdown, `/api/v1` versioning,
the OpenAPI security scheme) is decomposed into #280#289 with the phased roadmap on #197; those PRs will
append their own decisions here as they land.
## 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)
The artwork upload/serve path trusted client-supplied content types at both ends, giving a stored-XSS
chain on **unauthenticated** GET sinks: upload `<script>` bytes declared `image/png`
`GET /iptv/logos/{hash}?contentType=text/html` served them as HTML in the ErsatzTV origin. The #279
`nosniff` header is not a fix here — the server was *explicitly declaring* `text/html`, which the browser
honors regardless of `nosniff`. The trust was the bug; the fix removes it at both ends.
- **Upload derives the content type from the bytes, never the declared value.** `UploadArtworkHandler`
buffers the (size-bounded) upload and calls `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType`,
which uses SkiaSharp's `SKCodec` to identify the format from the image header only — pixels are **not**
decoded, so this can't be turned into a decompression-bomb vector. A payload that isn't one of the
accepted raster formats (png/jpeg/gif/webp) is rejected 422; the declared `Content-Type` is no longer
read at all (the field was dropped from the `UploadArtwork` command).
- **Serve sniffs the stored file; the `?contentType=` reflection is gone.** `GetCachedImagePath` no longer
carries a `ContentType`, and `GetImage` (`/iptv/logos`) / `GetWatermark` (`/artwork/watermarks`) dropped
their `[FromQuery] contentType` binding. `GetCachedImagePathHandler` always derives the MIME type from the
file (`MimeTypes.GetMimeTypeFromFile`) and **clamps it to the image allow-list** (`ImageContentTypes.IsAccepted`),
serving `application/octet-stream` for anything else — so a file whose bytes are not an accepted image (a
legacy cache entry poisoned before the upload sniff landed, or a hypothetical polyglot) is a non-renderable
download, never HTML/script. The removal is **structural** — there is no longer any request path that lets a
client choose the served `Content-Type`. `ArtworkContentTypeModel.UrlWithContentType` now returns the bare path, and the SPA
watermark/logo previews no longer append the query.
- **Defense-in-depth on the persisted JSON DTOs.** The `{path, contentType}` bodies (channel logo, watermark)
run their content type through `ArtworkContentTypeModel.Sanitized()`, which blanks anything outside the
image allow-list before it is stored — so a stale/hostile value can't be reflected by any future code path
even though the serve route already ignores it.
- **S9 upload-size DoS.** Kestrel `Limits.MaxRequestBodySize` is now set from `ETV_MAXIMUM_UPLOAD_MB`, so an
oversized body is rejected as it is read rather than only after the controller's post-binding `file.Length`
check (kept as the friendly-error backstop). This is a global bound; the app has no other large inbound
body (streaming is outbound GET).
`ImageContentTypes` is the single source of truth for the accepted image types (the allow-list previously
duplicated in `UploadArtworkHandler`). Both serve sinks are `[ApiExplorerSettings(IgnoreApi = true)]`, so
none of this changes the OpenAPI document.
## 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)
Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure.
Owner decisions (confirmed this session): **single API key** (no read/write split), and
**`Api:RequireKeyForReads` defaults `true`** (the whole `/api` surface requires the key). This does
**not** affect Jellyfin/streaming: `/iptv/*` (playlist/guide/streams/logos) and `/artwork/*` are outside
the filter's `/api` scope and keep their own optional access-token; only the management API the SPA talks
to is gated.
- **Fail-closed writes (#280, S1).** The empty-key "open" branch is deleted; there is no open mode. New
`IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`, singleton, resolved once at startup) yields a
never-empty key: `Api:WriteKey` if set, else a key persisted at `FileSystemLayout.ApiKeyPath`
(`/config/api.key`, `0600`, path logged not value), else a generated 256-bit hex key. Every mutating
`/api` request now requires `X-Api-Key`.
- **Sensitive-read tier (#282, S3/S5).** Reads are gated by `Api:RequireKeyForReads` (default true) OR a
new `[RequiresApiKey]` marker (mirror of `[SkipApiKeyAuthorization]`) applied to
`Troubleshoot`/`Logs`/`Settings`/`Maintenance`, so that tier stays gated even if an operator opts reads
open. `OPTIONS` preflight is exempt (CORS middleware owns it). `ApiControllerSecurityTests` asserts the
tier reflectively.
- **Delete dead non-`/api` mutation surfaces (#281, S2).** `SortController`
(`POST media/collections/{id}/items`, dead Blazor SortableJS residue — the SPA uses
`PUT /api/collections/{id}/custom-order`) and `AccountController` (`POST account/logout`, dead OIDC)
bypassed the key because they sat outside `/api`. Removed rather than guarded.
- **CORS opt-in (#284, S6).** `AllowAnyOrigin/Method/Header` is replaced by the `ApiCors` policy: an
exact-origin allowlist from `Api:CorsAllowedOrigins` (semicolon list) that permits `X-Api-Key`/`If-Match`
and exposes `ETag`; with no origins configured there is no cross-origin access (the SPA is same-origin).
- **ForwardedHeaders trust + scanner loopback (#285, S7/S10).** `GET /api/maintenance/gc``POST`
(crawler-triggerable GC; spec regenerated). `ForwardedHeaders` trust is configurable via
`ForwardedHeaders:KnownProxies`/`KnownNetworks` — **unconfigured preserves the current trust-all
behavior but logs a warning** (flipping the default to loopback-only would break reverse-proxy scheme/host
detection and thus M3U/XMLTV absolute URLs — the operator must name their proxy network). `ScannerController`
gains `[LocalhostOnly]` (the scanner always calls back over `http://localhost:{UiPort}/api/scan/...`), which
is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock. `search/all-items`
DoS-paging is **deferred** (it feeds the SPA "add all" flow and needs coordinated pagination; the unauth
exposure is already closed by read-gating).
- **SPA (`web/`).** The client sends the stored key (`ctv-api-key`) on **every** method (not just
mutations); a new keyless **API Key** screen (`/app/api-key`) lets the user paste the generated key, and a
shell-level banner points there on any 401. See spa-conventions §5e. **First-run/upgrade UX:** with reads
gated by default, the SPA shows no data until the key (from `/config/api.key`) is entered — an intended
consequence of the strict default.
Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and `/api/v1`
versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, `search/all-items`
paging, per-key rate limiting.
## 2026-07-12 (#197 Bundle C — contract-freeze honesty)
**#287 — OpenAPI contract honesty by construction.** The "v1" document now emits the `ApiKey` security
scheme plus per-operation `security`/`401` derived from the *same*
`ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime filter enforces, so declared auth
can never drift from enforced auth. Every operation also gets a synthesized stable `operationId` (the
framework only assigned one when `Name=` was set — ~90 were missing), and body/param-binding operations
get the documented `400 ValidationProblemDetails` they actually return. `DayOfWeek` is now a string enum
in the schema (added to `Startup.UseStringEnumSchemas`), removing the SPA's `WithDayNames` wart. Pinned by
in-process document generation in tests (`OpenApiContractHonestyTests`) rather than the committed `v1.json`.
**#288 — Wrap the last raw ViewModels; reverse the §7a "intentional `version` leak."** Minted
`MediaCollectionResponseModel`, `ProgramScheduleResponseModel`, and `ChannelDetailResponseModel` (all
`#nullable enable`) and routed `CollectionController` / `ScheduleController` / `SmartCollectionController` /
`ResolutionController.GetResolutionByName` / the channel detail GET+writes through ResponseModels, so no
`/api/*` action returns an Application VM. This reverses the earlier §7a judgment that a ResponseModel
"purely to hide one field was disproportionate": `Version` is now header-only (ETag) on every aggregate
body — confirmed safe by grepping `web/src` (the SPA reads `version` from the ETag header, never the
response body). `ChannelDetailResponseModel` is the *full editable* field set the channel editor needs
(distinct from the lean list `ChannelResponseModel`; drops only the derived `webEncodedName`). Also flipped
`#nullable enable` onto the remaining 24 lagging `ErsatzTV.Core/Api/` files for schema honesty, and added
`pageNum` paging to `GET /api/search`.
**Channel REST resources are keyed by database `Id`, never by `Number`.** `Channel.Number` is user-mutable
(editable on update, bulk-renumbered via `/api/channels/bulk/renumber`, transiently invalid mid-renumber),
so the immutable int PK is the canonical key for all `/api/channels/*` single-item routes, sub-resources
(including `playout/reset`, re-keyed from `{channelNumber}` to `{id:int}` in Bundle C), and `Location`
headers. `Number` remains the identity on broadcast surfaces only (IPTV/M3U/XMLTV), a separate contract. A
number-based lookup endpoint may be added additively later; `UniqueId` (Guid) stays out of the REST
contract absent a federation requirement.
## 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)
Implements the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Supersedes the #206
"OIDC wiring stays inert until #197" note: the retained OIDC service registration is now **revived**, and a
cookie session becomes a first-class `/api` credential alongside the machine `X-Api-Key`. **PR1 is
server-only and backward compatible** — the SPA keeps sending its stored key; the SPA login flow, the
`ApiKeyScreen`→machine-key repurpose, and `spa-conventions §5e` land in **PR2**.
**One gate, evolved (not `[Authorize]`-per-controller).** `ApiKeyAuthorizationFilter``ApiAuthorizationFilter`,
same fail-closed-by-omission logic (a forgotten `[Authorize]` fails *open* — the #280 failure mode — so the
global filter stays the gate). It now accepts a request when a valid `X-Api-Key` matches **OR** the principal
is an authenticated session; the "does this endpoint need auth?" decision is still the single shared
`EndpointRequiresKey(...)` predicate (also drives OpenAPI, so the spec can't drift). Attributes renamed to
match the widened meaning: `[RequiresApiKey]``[RequiresAuthentication]`, `[SkipApiKeyAuthorization]`
`[SkipApiAuthorization]`. `IApiKeyProvider`, the `X-Api-Key` header, and `Api:WriteKey`/`Api:RequireKeyForReads`
are unchanged — **machine/key behavior is byte-identical** (verified: no OpenAPI drift, existing filter tests
still green).
**CSRF (session only).** The machine key is CSRF-immune (a browser can't set a custom header cross-origin
without a credentialed CORS grant we never issue). A cookie session is not: a session-authenticated **mutation**
must carry the `X-CSRF` header (presence-only — a custom header forces a CORS preflight a cross-site page can't
satisfy) or is rejected **403**. Reinforced by `SameSite=Lax` + CORS without `AllowCredentials` (cross-origin
cookie auth is impossible by design). No antiforgery-token machinery.
**Cookie `ctv-session`.** Always registered (local login works with no IdP); OIDC handler added only when
`OIDC:*` is configured. `HttpOnly`, `SameSite=Lax`, `SecurePolicy=SameAsRequest` (so a plain-HTTP LAN isn't
bricked), 14-day sliding. `/api` XHR gets **401/403, not a redirect** (`OnRedirectToLogin`/`AccessDenied`).
The `UseAuthentication`/`UseAuthorization` middleware — deleted with Blazor in #91b — is **revived in the
`legacy` `MapWhen` branch only** (hosts `/api` + OIDC `/callback` + `/docs`; `/iptv` and `/app` untouched).
**Local store = `ConfigElement` rows, single admin, NO migration** (owner ruling F2):
`AuthLocalAdminUsername`, `AuthLocalAdminPasswordHash` (ASP.NET `PasswordHasher`, PBKDF2, via
`Microsoft.Extensions.Identity.Core`), `AuthSecurityStamp`. A password change rotates the stamp; the cookie
`OnValidatePrincipal` (`CookieSecurityStampValidator`) compares the claim to the stored stamp and rejects a
stale session (revocation). OIDC sessions carry an `etv:auth_method=oidc` claim and skip the stamp check
(governed by the IdP).
**Fail-closed out of the box + recovery.** An unconfigured instance keeps `/api` gated (the key still works);
first-run is a **setup-claim** (`POST /api/auth/setup`, first-claim-wins, only valid while unconfigured —
owner ruling F1). Recovery without the browser: `Auth:LocalAdmin:Password` env seed (`LocalAdminSeedService`,
overwrites + rotates the stamp on startup) or the machine key. Login hardening: per-IP rate limit
(`[EnableRateLimiting("auth")]`, 10 / 5 min) on login/setup/password, dummy-hash verify on unknown/unconfigured
user (no enumeration).
**Authelia = app-owned OIDC session; never trust proxy identity headers** (owner ruling F3): the container is
LAN-reachable bypassing the proxy, so `Remote-User`/`Remote-Email` header trust is spoofable. OIDC→Authelia
gives SSO without a double login. **`ForwardedHeaders` behaviour is kept unchanged from #285** (trust any peer
with a warning; restrict via `KnownProxies`/`:KnownNetworks`). A stricter "ignore `X-Forwarded-*` unless a proxy
is configured" default was implemented and then **reverted** after review (cold fork M1): the forwarded
scheme/host feed `/iptv` M3U/XMLTV/HLS absolute-URL generation (`Request.Scheme` in `GetChannelGuideHandler`/
`IptvController`), so ignoring them would regress stream URLs to `http`/internal-host for a proxied deployment
that hasn't set `KnownProxies`. **Deployment coordination:** operators behind a proxy should set
`ForwardedHeaders:KnownProxies`/`:KnownNetworks` — it gives the login rate limiter an unspoofable client IP and
marks the session cookie `Secure` behind TLS. The residual (a direct LAN peer can spoof `X-Forwarded-For` to
evade the per-IP login limit when unrestricted) is accepted defense-in-depth loss, mitigated by PBKDF2 +
no-enumeration.
**Review hardening (fork + independent Codex pass, folded into PR1).** Codex caught concurrency defects the
fork missed — folded in: (a) **atomic first-claim-wins** — setup writes the three credential rows in one
transaction guarded by the unique `ConfigElement.Key` index (a lost race → `DbUpdateException` → 409), so a
concurrent claim can't produce a mixed-state credential; (b) **consistent login snapshot** — login reads the
hash + stamp in one query and no longer rehashes-on-verify, so a login racing a password change can't capture a
newer stamp than the hash it verified (a concurrent change either fails the old password or leaves the issued
cookie carrying the pre-change stamp → revoked next request); (c) **env-seed waits on
`SystemStartup.WaitForDatabase`** (the migrator is a `BackgroundService`, so registration order alone didn't
guarantee the schema existed) — moved to `Services/RunOnce/`. Also: **logout + password require `X-CSRF`**
(the `[SkipApiAuthorization]` auth surface isn't covered by the filter's CSRF check → forced-logout CSRF), and
input length caps on username/password. **Logout rotates the security stamp** when called from a local session
(E2E-caught: `SignOutAsync` alone only clears the *client* cookie, leaving the stateless encrypted ticket
replayable server-side) — so signing out actually ends the session server-side; for the single admin this
revokes all local sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated
caller can't force-revoke the admin. **Deferred with a tracked gate:** side-effecting `[RequiresAuthentication]`
GETs (troubleshoot playback/archive) aren't CSRF-covered — **#301**, gating PR2 (latent in PR1: the SPA still
uses the machine key). OIDC-session revocation lever (no local stamp) noted for PR3 operator docs.
A **fix-commit re-review** (Codex, #242 discipline) then confirmed the above resolved and caught a second round:
(a) **HIGH — env-seed vs. setup race**: an attacker could claim admin in the startup window before
`LocalAdminSeedService` runs, and the seed's insert would then be swallowed (attacker's credential persists,
defeating the env recovery path). Fixed structurally: **the setup-claim endpoint is closed whenever
`Auth:LocalAdmin:Password` is configured** — the env seed owns the credential, so there is no claim to race
(this also strengthens the setup-claim TOFU posture: an operator on an untrusted network sets the env password
and browser setup is disabled). (b) **LOW**: a concurrent setup race-loser now returns **409** (not 422), and
`ClaimLocalAdmin`'s `DbUpdateException` catch re-checks existence and **rethrows genuine/transient DB errors**
rather than masking them as "already configured". (c) **MEDIUM — accepted**: two *simultaneous* authenticated
password changes are a non-serializable lost-update (last-write-wins; the loser's cookie may be immediately
revoked). Accepted for a **single-admin** system: it needs two concurrent authenticated sessions both submitting
the correct current password at the same instant, and the outcome is self-healing (re-login). Adding EF
optimistic concurrency to the credential rows is disproportionate here.
**OpenAPI = `ApiKey`-only; `/api/auth/*` excluded** (owner ruling F4): the spec's audience is machine/MCP
clients, and a browser-interactive cookie login isn't something a generated client drives, so the cookie path
is an additional accepted credential the doc needn't express. `AuthController` is `[ApiExplorerSettings(IgnoreApi
= true)]`. Verified: no `v1.json`/`v1.d.ts`/`endpoint-index` drift from this PR.
**Phasing.** PR1 = this (server only, no migration). PR2 = SPA (drop the key header for browser calls + add
`X-CSRF`, `AuthContext` + boot gate, login/setup screens, `ApiKeyScreen`→machine-key management, E2E,
`spa-conventions §5e`). PR3 = key rotation + operator docs (Authelia client + env reference). Rollout: PR1→PR2
same release, then a manual Authelia round-trip checklist before the prod pin bump.
## 2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification
PR1 shipped the server side (previous entry): `/api` accepts a session cookie OR the machine `X-Api-Key`, with
`X-CSRF` required on session-authenticated mutations. **PR2 is the SPA cutover** — the browser now authenticates
with the session only — plus **#301** (a session-cookie CSRF hole in side-effecting GETs).
**Browser is cookie-only; the machine key is external/MCP-only.** `web/src/api/client.ts` no longer attaches
`X-Api-Key`; it relies on the same-origin session cookie and sets `X-Csrf: '1'` on every mutating verb centrally.
The former "paste your key" `ApiKeyScreen` is repurposed to **machine-key management**: it reads the server key
from the new `GET /api/auth/machine-key` (session-gated; masked with Reveal + Copy) so an operator can hand it to
MCP / external REST clients — the browser itself never sends it again. *Why:* one credential per audience (the
ratified #295 model); leaving a browser key path alive would keep a CSRF-immune bypass around and defeat the
point.
**Boot gate, not a route** (`web/src/AuthGate.tsx`, wrapping `<App/>` in `main.tsx`): on load it calls the public
`GET /api/auth/config` then `GET /api/auth/session` and renders Setup (first-run local-admin claim) / Login
(local form + an OIDC "Sign in with SSO" button when `oidcEnabled`) / the app. Login and Setup mint **no URL**
the gate renders them at whatever `/app/*` path was requested, so a deep link survives login for free and **no
`blazor-route-parity.md`/`domain-model.md` route rows are added**. It publishes `AuthContext`
(`{ username, method, signOut, requireLogin }`); the 401 signal (`notifyUnauthorized`) now drives re-login via a
passive shell banner (never yanks a dirty draft — it consults the navigation guard first). Auth flows that expect
a 401 inline (login, change-password) pass `suppressUnauthorizedSignal`.
**#301 — POST-ify, don't gate-the-GET.** A side-effecting GET is a CSRF vector once a `SameSite=Lax` cookie is a
normal credential (it rides a cross-site top-level navigation). The three offenders became mutating verbs so the
existing filter CSRF gate covers them with zero new machinery: `GET /api/troubleshoot/playback.m3u8`
**`POST /api/troubleshoot/playback/start`** returning `200 { url }` (the open `/iptv` manifest the player then
loads — so hls.js/native-HLS needs no header injection, strictly better than X-CSRF-on-GET); the archive and
sample GETs → **POST** (SPA downloads them via a fetch-blob helper, never `window.open`). Removing the HEAD
variants also fixed a latent bug: a HEAD opened the `DeleteOnClose` stream and destroyed the artifact. Standing
rule added to `api-conventions.md §9`: **never add a side-effecting GET/HEAD under `/api`.**
**Machine-key GET discloses the key to any authenticated session** — deliberate: the session principal is the
single admin (local or OIDC), same-origin policy blocks a cross-site page from reading the response body, and it
is how the "copy the key for MCP" UX works without a rotation endpoint (rotation is a later PR). **Accepted
residual (OIDC logout):** `POST /api/auth/logout` ends the *app* cookie but not the IdP session, so an OIDC user
who clicks "Sign out" then "Sign in with SSO" returns without re-entering credentials — a `returnUrl`/RP-initiated
logout is a future nicety. Docs: `spa-conventions.md §5e` (SPA seams), `api-conventions.md §9`, `e2e-local.md`
(browser setup/login flow). Refs #295 #301 #197.
## 2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)
Completes the CSP that the #279 baseline-headers entry deferred ("CSP must be validated against the ChicoryTV
SPA's inline assets"). Surfaced by the #314 out-of-ecosystem ZAP baseline (missing CSP/Permissions-Policy WARNs);
a **#197 exit item**. `SecurityHeadersMiddleware` now also sets `Permissions-Policy` (deny-all for
camera/microphone/geolocation/payment/usb) and an **enforcing** `Content-Security-Policy`.
- **Enforce, not report-only.** Report-only was the issue's acceptable fallback, but the SPA's asset graph is
small and fully knowable, so we ship an enforcing policy (report-only leaves the ZAP WARN and provides no real
protection). The policy: `default-src 'self'`; `script-src 'self' '<sha256 of the inline theme-bootstrap
script>'` (**no** `'unsafe-inline'`/`'unsafe-eval'` — the real XSS win); `style-src 'self' 'unsafe-inline'
https://fonts.googleapis.com`; `img-src 'self' data: blob:`; `font-src 'self' data: https://fonts.gstatic.com`;
`connect-src 'self'`; `object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'none'`; `form-action 'self'`.
- **Why each relaxation.** The SPA is a static file, so a per-response nonce is impossible → the one inline
theme-bootstrap `<script>` is allow-listed **by hash**; `SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_
Match_The_Spa_Index` hashes the built `wwwroot/app/index.html` when present (else the committed `web/index.html`
source, since the built artifact is gitignored/absent in CI — Vite copies the inline script verbatim) and fails
if it drifts from the middleware constant. `style-src 'unsafe-inline'` covers
React's inline `style=""` attributes (no CSS-in-JS lib to hash). The **Google Fonts** hosts are required — the
SPA CSS `@import`s the Geist web font (caught by **live-E2E**, which the static grep missed); self-hosting the
font to drop the Google dependency is a follow-on hardening, not this issue. `img-src data: blob:` covers
favicon/generated-image data URIs and object-URL upload previews.
- **Scoped: `/docs` (Scalar) and `/openapi` are excluded.** The Scalar API-reference UI relies on inline bootstrap
scripts/styles a strict CSP would break; it keeps the baseline headers (nosniff/frame/referrer) but no CSP.
Hardening that admin surface (self-hosted Scalar or a Scalar-tuned CSP) is a #197 follow-up. Everything else —
SPA, `/api`, `/artwork`, `/iptv` — gets the CSP (non-HTML responses simply never exercise the script/style
directives). Verified by live-E2E (SPA renders clean, zero CSP violations) + curl (CSP present on `/app`/`/api`,
absent on `/docs`/`/openapi`). HSTS remains out (proxy/TLS decision). Refs #319 #314 #197.
## 2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)
The authenticated #314 ZAP scan found that ErsatzTV's baseline response posture omitted
`Cross-Origin-Resource-Policy`. `SecurityHeadersMiddleware` now sends
`Cross-Origin-Resource-Policy: same-origin` on every response, including `/docs` and `/openapi`.
Those two paths remain exempt only from the strict CSP that would break Scalar's inline bootstrap;
CORP has no equivalent rendering conflict and belongs with the middleware's path-independent baseline
headers.
`same-origin` requires the browser request and response to share the exact scheme, host, and port. It
blocks cross-origin `no-cors` loads, so direct browser embedding of ErsatzTV artwork or media from an
alternate origin is deliberately unsupported. It does not reject an allowed CORS-mode API fetch, so the
explicit `Api:CorsAllowedOrigins` machine-client path continues to work. It is also not enforced by
server-side HTTP clients, so Jellyfin's `/iptv/*` requests are unaffected; same-origin SPA artwork and
IPTV requests remain allowed. This is defense in depth for browser embedding and does not replace CORS
or authentication. Refs #330 #319 #314.
+256
View File
@@ -0,0 +1,256 @@
# Optimistic concurrency — ETag / If-Match / Version (#253, #259, #265, #269)
Why the replace-all and config-bearing aggregates carry optimistic concurrency, and how the
contract evolved (including the reversals). The **mechanics** — headers, status codes, the
recipe — live in `docs/api-conventions.md` §7a–§7c; this file preserves the *decision
rationale*, relocated from the append-only `docs/decisions.md` at the v26.9.0 consolidation.
Issue trail: #253 (PR1 infra + PR3 Diff/Scalar fan-out), #259 (stable child identity for
schedule-item replace), #265 (RFC 7232 If-Match semantics), #269 (non-If-Match force-write +
cross-editor ETag rotation). Refs #197.
## Contents
- [2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)](#2026-07-11--optimistic-concurrency-contract-for-replace-all-puts-253-pr1-infra--block-reference)
- [2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)](#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection)
- [2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)](#2026-07-11--stable-child-identity-for-schedule-item-replace-259-split-from-252253)
- [2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)](#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump)
- [2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)](#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269)
- [2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)](#2026-07-12--if-match-evaluates-per-rfc-7232-valid-but-non-matching--412-only-grammar-violations--400-265)
---
## 2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)
Replace-all aggregate PUTs had **no** optimistic concurrency — a stale second tab silently overwrote a
fresher edit (200, no signal) across ~10 aggregate surfaces. PR1 lands the shared contract on the Block
reference aggregate; PRs 24 fan it out. The full ratified design + independent-review hardening is
[#253#issuecomment-8472](http://192.168.1.95:3000/timothy/ersatztv/issues/253#issuecomment-8472);
the mechanics live in `api-conventions.md` §7a. Decisions frozen here:
- **Token = uniform plain `int Version`** on each root implementing `IVersionedAggregate`, EF-mapped
`.IsConcurrencyToken()`, one dual-provider migration (`AddAggregateVersions`, `defaultValue: 0`). **Not**
a reused `DateUpdated` (tick-collision, SQLite TEXT precision, couples UI cosmetics to correctness) and
**not** a MySQL-native rowversion (portability over provider-native).
- **412 Precondition Failed**, not 409 — 409 stays the §3a EntityLocker "build in progress" guard;
distinct codes → distinct SPA UX. New `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`.
- **Pre-check AND EF token both required.** The handler pre-check (a standalone `Either` introduced AFTER
the validation pipeline — never via `Apply`, which `Join()`-flattens the subtype to 422) gives a clean
412; the unconditional `root.Version++` + `IsConcurrencyToken` UPDATE-guard + a `SaveChangesWithConcurrencyGuard`
backstop closes the residual load→save TOCTOU (`DbUpdateConcurrencyException` → 412).
- **Unconditional bump** (not "only when a child changed"): EF writes the root row only when a scalar
differs, so a no-op PUT-back must still bump to fire the token and rotate every other client's ETag.
- **Config-only aggregate boundary**: every mutating handler of a root's *editor-visible config state*
bumps `Version` (incl. bulk `ExecuteUpdate/Delete` writers via `.SetProperty`); regenerated build output
(playout items/history) is outside the token — neither bumped nor guarded.
- **Header-only ETag**, strong tag of the decimal `Version`; parsed/emitted by `ConcurrencyHeaders`. The
successful PUT returns the new ETag (else a same-tab second save 412s against its own write).
- **Phasing**: Phase 1 (this arc) = a missing `If-Match` force-writes (zero breakage) while the SPA starts
echoing; Phase 2 (a later PR) flips missing → **428** after every editor echoes and one release soaks.
`If-Match: *` stays the scripted force-write escape hatch.
- **Child stable-identity is OUT of #253** (the "moved fill-group item inherits the wrong slot's state"
concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to **#259**.
- **If-Match status semantics** (non-canonical/weak/list → 400) are fail-safe; the stricter RFC 7232
"valid-but-non-matching → 412" refinement is deferred to #197 (**#265**).
## 2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)
**Context.** PR3 of the #253 optimistic-concurrency arc fans the frozen Block recipe (api-conventions §7a)
across the five Diff/Scalar aggregates. Three judgment calls beyond the mechanical copy:
**H1 — Playout `catch(Exception)`→422.** The two Playout replace handlers wrap `SaveChangesAsync` in a
`catch(Exception)` that maps any exception to a bare `BaseError` (→ 422). Rather than let the guard's
concurrency failure be reshaped into a 422, the guarded save (`SaveChangesWithConcurrencyGuard`) returns a
`PreconditionFailedError` **Left as a value** and the handler returns it before the post-commit block —
so it never reaches the catch. Proven by the pre-check-subtype tests (a `.Apply` flatten would fail
`ShouldBeOfType<PreconditionFailedError>`) plus a non-vacuous Playout racing-save test.
**M2 — the `SaveChangesAsync() > 0` gates.** RerunCollection and Collection-custom-order run their
playout-refresh **unconditionally** on a successful save (the unconditional `Version++` makes the old gate
always-true; the "nothing changed" branch is dead). MultiCollection is the exception: it saved the name
first specifically so a name-only change wouldn't rebuild playouts, so we bump `Version` on that **first**
save and leave the **second** (items) save's `> 0` gate intact — a name-only edit still bumps + rotates the
ETag but does not rebuild. Enumerating every behavior the gate provided before reworking it (the #232 lesson).
**Sibling-writer scope (deferred).** §7a's config-only boundary says every writer of an aggregate's
editor-visible config bumps `Version`. PR3 ships the five primary endpoints' full contract + the one
design-named bulk writer (`UpdateDefaultDecoHandler`, safe via `.SetProperty`). It **defers** the other
same-root non-bulk config writers (`UpdateCollectionHandler`, `RemoveItemsFromCollectionHandler`,
`UpdatePlayoutHandler`, the `ScheduleFile` handlers) and the repository-mediated `Add*ToCollection` family.
Rationale: the primary endpoints' own bump+guard fully cover the two-tab lost-update the issue targets;
the deferred writers only affect cross-editor ETag *rotation*, and adding an unconditional bump to a handler
that uses plain `SaveChangesAsync` (not the guard) converts a latent lost-update into a **new 500**
(`DbUpdateConcurrencyException`) — doing it safely needs a uniform guard+bump+412 pass of its own, better
done with the #197 contract work. Tracked as a follow-up issue.
**VMs.** `Playout.Version` surfaces via `PlayoutNameViewModel` (required arg); the three collection VMs
(`MediaCollectionViewModel`, `MultiCollectionViewModel`, `RerunCollectionViewModel`) carry `int Version = 0`
(defaulted — 0 for the selection-placeholder constructions, real value from the Mapper projection).
Header-only via ETag, never echoed in a response body (the Block precedent).
**Post-merge addendum (PR3 review, #269).** Activating the `Version` token means EF guards *every* root
UPDATE, so non-participating root-scalar writers that use plain `SaveChangesAsync` (playout settings /
schedule-file / on-demand-checkpoint, collection name) would 500 on a concurrent bump. The realistic
UPDATE writers were fixed in-PR with `ConcurrencyExtensions.SaveChangesForcingVersion` (Phase-1
force-write on conflict: adopt the stored token, retry, never revert the concurrent bump). The deferral
above is re-scoped to the DELETE handlers + repository `Add*` writers only (→ #269).
## 2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)
`PUT /api/schedules/{id}/items` now reconciles by an optional round-tripped child id, not by array
position, so an item's persisted fill-group/shuffle state (`PlayoutScheduleItemFillGroupIndex`, FK
`OnDelete(Cascade)`) follows the logical item across reorders/inserts instead of being inherited by
whatever previously held its new slot. Contract + rules in **api-conventions §7c**. Key decisions:
- **`ScheduleItemRequest.Id` (`int?`)**: null/absent/`0` ⇒ new item (controller normalizes `0`→null so the
handler is two-state). Any id present ⇒ id-based reconcile; a fully id-less payload keeps the verbatim
positional fallback (legacy; retires with the §7a Phase-2 `If-Match`→428 flip).
- **Unknown or duplicate id ⇒ 422, nothing persisted**; the guards live in the handler **after** §7a
`CheckVersion`, so **412 precedes 422** — a client that is both version-stale and id-stale gets the reload
signal, not a payload-bug signal. Rationale for reject-not-insert on an unknown id: under Phase-1
force-write a stale id is a live lost-update signal, so silently inserting-as-new would duplicate the item
and return a different id than the client sent (the exact class §7a exists to surface). This is also the
correct #197 posture — never honor an unrecognized identifier.
- **Scope = schedule items only.** Blocks/templates/deco-templates/playlists stay positional: their children
are stateless config rows (no FK'd state to misattribute; #3/#4 have no GET child id). Child ids are added
only where a child row anchors server-side state; the contract can be retrofitted per-endpoint later
(field stays optional) — so this is not #197 ossification pressure.
- **TPT subtype change at a matched id** stays delete+insert (EF can't retype in place); state resets and a
new id is returned, so the SPA must re-seed item state from the PUT response (a stale id on a second save
now 422s).
## 2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)
**Routing the aggregate delete handlers + `UpdateProgramScheduleHandler` through `SaveChangesForcingVersion`.**
Once #253 made each replace-all root's `Version` an `IsConcurrencyToken`, EF started guarding *every*
UPDATE **and DELETE** of that row with `WHERE Version=@orig` — so any writer that is not part of the
If-Match contract but still saves via plain `SaveChangesAsync` throws an unhandled
`DbUpdateConcurrencyException`→**500** if a replace-all editor bumps the row in its narrow load→save window.
PR3 already force-wrote the exposed *UPDATE* siblings (Playout settings/`ScheduleFile`/checkpoint,
`UpdateCollectionHandler`); a completeness sweep for #269 found the gap was wider than reported —
**18 writers** in total, all on plain `SaveChangesAsync`. **The correct exposure filter is "any handler
that leaves a versioned root `Modified` or `Deleted`", NOT just `Version`-bumpers + deletes** — an early
sweep used the narrower filter and a review of PR #302 caught what it missed (`ErasePlayoutHistory` below):
- the **nine versioned-root delete handlers** (`DeletePlayout`/`DeleteCollection`/`DeleteMultiCollection`/
`DeleteRerunCollection`/`DeletePlaylist`/`DeleteBlock`/`DeleteTemplate`/`DeleteDecoTemplate`/
`DeleteProgramSchedule`) — a DELETE is now token-guarded too;
- `UpdateProgramScheduleHandler` (bumps `Version` then saved plainly — the ProgramSchedule case PR3 only
*suspected*);
- the **seven item add/remove bumpers** that PR2 wired to bump their root but left on plain save —
`AddProgramScheduleItem`/`DeleteProgramScheduleItem` and the five
`Add{Items,Movie,Show,Season,Episode}ToPlaylist` handlers;
- **`ErasePlayoutHistoryHandler`** — modifies Playout root **scalars** (`Seed`/`Anchor`/`OnDemandCheckpoint`)
**without** bumping `Version`, inside an explicit transaction with no try/catch → the one the bumper-only
filter missed; reachable via `POST /api/playouts/{id}/erase-items-and-history`.
All now save through `ConcurrencyExtensions.SaveChangesForcingVersion`.
**Two deliberate boundaries (documented, not gaps):** (1) the background build/time-shift Playout-scalar
writers (`BuildPlayoutHandler` via `PlayoutBuilder`'s `Anchor`/`Seed`; `PlayoutTimeShifter`'s
`OnDemandCheckpoint`) are token-guarded too but **intentionally left on plain save** — they never surface a
request-path 500 (`BuildPlayoutHandler` catches → a build-failure `BaseError`; `PlayoutTimeShifter` runs only
via the background worker), and force-writing would be *wrong*: a concurrent config edit that bumped
`Version` also enqueues a rebuild, so failing the in-flight build and letting the rebuild redo it with fresh
config is correct (force-writing would persist output built from stale config). (2)
Item-add force-write can leave a duplicate/gap `Index` (accepted Phase-1 effect): the handler computes the
new index from its stale child list, so if a concurrent replace-all grew the list the item lands at a
now-colliding index (no unique constraint on `PlaylistItem.Index`/`ProgramScheduleItem.Index`) — non-
corrupting, self-correcting on the next edit, still strictly better than the pre-#269 500; a
reload-and-recompute-on-conflict refinement is a candidate for #197. Decision:
**force-write, not 412** — these endpoints take no `If-Match` (an unconditional DELETE/settings-edit should
win over a concurrent editor), matching the Phase-1 force-write posture. A delete has no ETag to rotate, so
it needs only the force-write, not a `Version` bump. A genuine row-deletion race (two concurrent deletes)
still surfaces as a `DbUpdateConcurrencyException` — accepted (rare, non-corrupting, the resource is already
gone). **Still deferred to #197:** *cross-editor ETag rotation* for the non-bumping config siblings and the
scanner-shared `Add*ToCollection` family (they don't 500 — they insert children / `ExecuteDelete`, neither
of which is token-guarded — they just don't rotate an open editor's ETag). Non-vacuously tested by racing a
bump *through the handler* via a pre-tracked context (`RootWriterForceVersionTests`), plus an explicit
negative control proving the plain-save path throws.
## 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)
The #253 optimistic-concurrency contract (§7a) had a documented tail: the non-If-Match config-sibling
writers of a versioned root mutated editor-visible state **without** bumping `Version`, so editing through
them did not rotate an open editor's ETag (a cross-editor invalidation gap — never a lost-update or a 500,
which the primary endpoints' bump+guard already cover). #269's first slice (PR #302) removed the 500 exposure
by routing those writers through `SaveChangesForcingVersion`; this slice completes the **rotation**.
Handlers now bumping `Version` (all via `SaveChangesForcingVersion`, since they take no `If-Match` → a
concurrent replace-all bump force-writes, never 412/500): the Collection `Add*ToCollection` family (11
handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`; `UpdateCollectionHandler`
(name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three `ScheduleFile` writers
(`UpdateSequential`/`UpdateScripted`/`UpdateExternalJsonPlayout`) — which already force-wrote — now also bump.
Decisions frozen (ratified with Fable before implementation, feeding the #197 contract freeze):
- **Rotate on every editor-visible config change, no per-aggregate carve-outs.** §7a's config-only boundary
("every mutating handler of a root's editor-visible config bumps `Version`") already held for Playlist
`Add*`/schedule item writers; the Collection/Playout siblings were an inconsistency, not a judgment call. A
membership add rotating an open custom-order editor's ETag (→ 412 → reload) is correct: its list is genuinely
stale. Blast radius of the aggressive-but-safe rotation is a reload, never data loss.
- **No-op idempotence — the trap Fable caught.** These handlers gate their reindex/`BuildPlayout` fan-out on
`SaveChanges() > 0`. An *unconditional* bump makes that gate always-true, so an idempotent re-add / same-value
re-submit would fire spurious rebuilds across every playout using the aggregate. Fix: short-circuit a genuine
no-op **before** the bump — the Add handlers by an explicit membership check (which also fixes the latent
duplicate-`CollectionItem` insert on a *sequential* re-add; two *concurrent* same-item adds can still both
pass the check and the loser 500s on the composite-PK unique violation — `SaveChangesForcingVersion` catches
only `DbUpdateConcurrencyException`, not `DbUpdateException`. That race is narrow and pre-existing, deferred
to #308), the scalar writers (`UpdateCollection`, `UpdatePlayout`, the
three `ScheduleFile` writers) by `ChangeTracker.HasChanges()`. A no-op neither bumps nor rebuilds nor rotates
the ETag — which is itself correct (nothing changed).
- **The `Add*ToCollection` family is not repository-mediated.** #269's original framing ("repository-mediated,
shared with the scanner hot path") was wrong: `IMediaCollectionRepository` is read-only; each handler loads
the `Collection` into its own `dbContext` and writes directly. So the rotation bump is a pure API-layer
concern and the scanner's separate membership-write path is untouched — a background scan does **not** rotate
the editor ETag (correct: background indexing is not an editor action).
- **Force-write rebases the bump, never adopts the stored token verbatim (Codex review of this PR).**
`SaveChangesForcingVersion` originally resolved a conflict by setting current=original=stored — which
silently *discarded* a sibling's pending `Version++` when a versioned writer committed in its load→save
window (sibling loads 1, bumps to pending 2, concurrent PUT commits 2 → retry wrote 2, so the concurrent
writer's ETag "2" stayed valid and the rotation was lost under exactly the race it exists for). Fixed in
this PR (it affects all 25 bumpers routed through the helper, including the pre-existing playlist/schedule
ones): the retry now rebases — original = stored, current = stored + (pending current pending original) —
so a bumper lands at stored+1 and a non-bumper (delta 0, e.g. `ErasePlayoutHistory`) adopts stored unchanged.
The race tests assert the post-race Version (3, not 2) and fail against the verbatim-adopt implementation.
- **No new status codes.** These endpoints take no `If-Match` and force-write, so they never 412; no
`[ProducesResponseType(...412...)]` and no OpenAPI regen (response types unchanged). Only §7a prose changes.
Tests: `CollectionEtagRotationTests` (rotation + no-op-without-bump-or-rebuild + force-write-past-concurrent-bump
for Add/Remove/Update) and `PlayoutScheduleFileEtagRotationTests` (ScheduleFile rotation + no-op-without-refresh),
the no-op guard proven non-vacuous by inverting the membership check. The `#265` RFC-7232 If-Match parser
refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate** PR (disjoint surface: the shared
parser + `CheckVersion`, not the handler saves). Refs #253 #269 #197 · `api-conventions.md` §7a.
## 2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)
Closing the last #253 concurrency-contract piece. `ConcurrencyHeaders.ParseIfMatch` previously classified
**any** non-canonical/weak/list `If-Match` value as `Malformed → 400` (a deliberate fail-safe: reject rather
than risk a stale write, deferred from the reference-aggregate PR). That was RFC-incorrect. Per **RFC 7232
§3.1**, a syntactically-valid entity-tag that simply doesn't strong-match must return **412 Precondition
Failed**, and **400** is reserved for a genuine grammar violation.
**What changed.** The parser is now a real RFC 7232 entity-tag/list parser (`If-Match = "*" / 1#entity-tag`).
It **scans** the list (it does *not* `Split(',')` — a comma is a valid `etagc`, so it can appear inside a quoted
opaque-tag: `"3,5"` is ONE tag, and a comma separates members only outside the quotes), trims only RFC OWS
(SP/HTAB — not `string.Trim()`, which would strip NBSP and let `" * "` masquerade as the `*` force-write),
validates each member as `[ "W/" ] DQUOTE *etagc DQUOTE`, and collects the versions of the **strong** members
whose opaque text is the exact canonical decimal we emit. Outcomes:
- **weak** (`W/"3"`), **empty** (`""`), **non-canonical** (`"03"`, `"3.0"`, `"+3"`), **out-of-range**
(`"99999999999999999999"`) → valid tags that contribute no version → **412** (a `Version`-kind with an
*empty* candidate set is a guaranteed no-match).
- **list** (`"3", "5"`) → any strong member that matches proceeds; weak/non-canonical members drop out.
- genuine grammar violations (unquoted `3`, SP inside the tag `" 3 "`, unterminated `"3`, `garbage`, a
separator-only header) → **400**.
**Type reshape.** `IfMatchCondition.ExpectedVersion : Option<int>``ExpectedVersions : Option<Seq<int>>`
(`None` = force-write; `Some(set)` = strong-match against the set, empty ⇒ always 412), and
`VersionedAggregateExtensions.CheckVersion(Option<int>)``CheckVersion(Option<Seq<int>>)` = set membership.
This threads through all 10 replace/update commands + handlers + request mappers + 9 controllers uniformly; no
wire-contract change (400 and 412 were already declared on every PUT; the field is header-derived and internal,
so no OpenAPI/DTO change).
*Why now, not #197:* it is the shared parser all replace-all PUTs copy, and the 412-vs-404 ordering the issue
worried about was already correct (each handler loads/validates → 404 before `CheckVersion`). *Why safe:* the
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
§7a. Refs #265 #253 #197.
+221
View File
@@ -0,0 +1,221 @@
# Release, CI & merge-governance decisions (#303, #311, #314, #315, #335)
Why the merge/release process is enforced by hooks and CI gates rather than prose: the #303
methodology-hardening waves (state-derived merge-consent, API-contract CI gate, append-only
decisions log, review-verdict gate), formatting-as-you-touch + rebase discipline, the
auto-grant fix, migration rehearsal on a prod-DB copy, and release promotion. Rationale
relocated from the append-only `docs/decisions.md` at the v26.9.0 consolidation; operational
detail cross-links to `docs/ci-cd.md` and CLAUDE.md → Task Completion Protocol.
Issue trail: #303 (H4/H5 api-docs gate, H6 Done-when, H9/H3 append-only + root-png, H10
review-verdict), #311 (H11 formatting/rebase), #314 (merge-gate auto-grant), #315 (migration
rehearsal), #335 (release promotion). The whole hook program's throughline: make each process
rule a derivation/hook, not prose to remember (#303 methodology review).
## Contents
- [2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)](#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h4h5)
- [2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)](#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6)
- [2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)](#2026-07-12--decisionsmd-is-append-only-enforced-root-screenshot-guard-303-h9h3)
- [2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)](#2026-07-12--review-verdict-merge-gate-latest-commit-must-be-reviewed-303-h10)
- [2026-07-12 — Formatting-as-you-touch, enforced; rebase-not-merge for PR branches (#311 H11 + format CI)](#2026-07-12--formatting-as-you-touch-enforced-rebase-not-merge-for-pr-branches-311-h11--format-ci)
- [2026-07-12 — Merge-consent gate auto-grants when satisfied (no redundant prompt); state IS the consent (#314)](#2026-07-12--merge-consent-gate-auto-grants-when-satisfied-no-redundant-prompt-state-is-the-consent-314)
- [2026-07-12 — Release path rehearses migrations on a prod-DB copy before promoting (#315)](#2026-07-12--release-path-rehearses-migrations-on-a-prod-db-copy-before-promoting-315)
- [2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)](#2026-07-13--release-promotion-floating-prod-exact-image-scan-before-manual-deploy-335)
---
## 2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)
**A PR whose diff touches `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship the
regenerated OpenAPI artifacts in that same diff, enforced by a blocking `api-docs` CI job.** It rebuilds
`ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, and `docs/endpoint-index.md` from
source and fails on any drift. This mechanizes the previously prose-only "docs-update in the same PR"
rule for the API contract (the `docs-reminder` job stays a non-blocking nudge for the route-parity doc).
Path-gated *inside* the job (not via a top-level `if:`) so it always reports a status on every PR — API-free
PRs skip the expensive regen and pass trivially, so it is safe as a required check. Rationale: generation
is deterministic from a **fresh** build (verified — a clean checkout reproduces the committed spec exactly,
including the 244 auth `security`/`401` blocks), so CI can trust regenerate-and-diff. The one caveat is
local-only: `update-openapi.sh` runs `dotnet-getdocument` against the already-built assembly, so a stale
`bin/` silently yields a stale spec — always `dotnet build` first (see `api-conventions.md` §5). CI is
immune (no `bin/` on a fresh checkout).
## 2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)
**An issue's `## Done-when` checklist (in the issue body) is the machine-readable source of truth for whether
its PR may merge; consent is *derived*, not asserted.** Rationale: DONE/OPEN status used to live in
append-only prose that lags live Gitea state (the queue-drift #303 fixes) — so the completion gate moves out
of memory and into a checklist two hooks read. Convention: the issue body carries a `## Done-when` section
(always an "adversarial review passed" box, plus per-issue criteria); a merge is allowed only when the PR's CI
is green **and** every box on the linked issue (`fixes #N`) is ticked.
Enforcement (both fail *safe*, never a silent pass):
- `pretooluse-merge-consent.sh` — Claude PreToolUse on `mcp__gitea__pull_request_write` merge: **deny** on an
unticked box or non-green CI; **allow** when both satisfied; **ask** (human prompt) when state isn't
derivable (no linked issue, no `## Done-when`, no creds, Gitea unreachable). Docs-only PRs exempt.
- `.husky/pre-push` → `prepush-donewhen.sh` — backstop for a direct `git push origin main`; fail-*open* (a
git hook has no "ask"), blocks only on a positively-proven unticked box.
Both authenticate to Gitea from env only (`ETV_GITEA_BASICAUTH` / `ETV_GITEA_TOKEN`, `ETV_GITEA_URL`) — no
creds committed; without them the gate degrades to today's manual confirmation. Rollout is non-breaking: until
issues adopt `## Done-when`, the merge hook simply *asks* rather than auto-allowing. See CLAUDE.md → Task
Completion Protocol. (H6 lives with H1/H2/H8 in `.claude/settings.json`; H7 worktree-owner guard is its
sibling Wave-2 hook.)
## 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)
**This log is append-only by construction, not just by convention.** A commit or PR that deletes or
modifies an existing line of `docs/decisions.md` is blocked — by the Husky `commit-msg` hook
(`.claude/hooks/decisions-guard.sh staged`) locally and the blocking `decisions-guard` CI job (same
script, `range` mode) on PRs. Shared detection, deliberately different granularity: the Husky hook
gates **each commit** (its own message must carry the token); CI gates the **PR-wide** net diff
(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the
push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line
near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is
`git diff --numstat` deleted-count > 0, which is robust to markdown `-` list markers (a byte-level `-`
prefix would false-match). The block is lifted only by the literal **`[decisions-edit]`** token in the
commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision
(add the new entry, prepend a `> **Superseded …**` banner to the old one, tag its Index line
`(superseded)` — keep the old rationale, never silently rewrite). **Consolidation** of superseded
entries is a release-checklist step (`docs/ci-cd.md` → Versioning & releases), backstopped by a
non-blocking 1800-line **size floor** in the `decisions-guard` job (the read-cost point past which the
log no longer fits one default agent Read), so append-only doesn't accrete contradictory *or
unreadably-large* history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal,
consolidate at each milestone, size-floor backstop).
Companion guard **H3**: the Husky `pre-commit` hook refuses a staged **root-level `*.png`** (a
review/debug screenshot dropped at the repo root) — belt-and-suspenders with the `.gitignore` rule, so
a forced `git add -f` still can't land one. Nested `*.png` (real assets) are unaffected. Rationale for
both: the methodology review (#303) — make the process rules derivations/hooks, not prose to remember.
## 2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)
**A PR may not merge until a `Review-verdict:` comment on it references the PR's CURRENT head sha** —
so the *latest* commit is proven-reviewed, not a stale earlier diff. This mechanizes the ersatztv#242
lesson ("re-review the fix commit, not just the initial PR diff": a review of an earlier revision does
not license merging a head that carries un-reviewed follow-up commits). It folds into the existing H6
`pretooluse-merge-consent.sh` as condition (c), reusing its PR fetch, docs-only exemption, and
Gitea-auth-from-env (no second hook → no detection drift, per the #303 methodology review).
Convention: after reviewing a PR (or its latest fix commit), post a PR **comment** (issue-style, not a
Gitea formal-review body — the gate reads `issues/{pr}/comments`) whose line **starts with** the marker:
`Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>` (short ≥7-char or full sha).
The gate counts a line as a verdict only when the marker is at line-start (after optional indent) —
a comment that merely *quotes* the template mid-sentence (an instruction "please post: Review-verdict:
MERGEABLE @ …", or the gate's own suggestion text echoed back) does **not** self-approve the merge
(adversarial re-review false-open, folded pre-merge). It then classifies each verdict line by the sha
in its `@ <sha>` field, matched to the head by **git short-sha prefix semantics** (head *begins with*
the token, token ≥7 chars) — NOT a loose substring test, so an older sha that merely contains the head
prefix, or the head prefix appearing in an unrelated URL on the line, does not count:
- a MERGEABLE/APPROVED/LGTM verdict whose `@ <sha>` is the current head → **allow**;
- a **negative** verdict (BLOCKED/NOT-MERGEABLE) *on the head* → **deny**, and it *wins over* a positive
one on the same head (a later BLOCKED retracts an earlier MERGEABLE; to retract, re-review head and
post BLOCKED @ head). Staleness is **symmetric on purpose**: a negative for an *older* commit is stale
exactly like a positive for an older commit, and does NOT override a fresh head-positive — otherwise a
pre-fix `BLOCKED @ oldsha` would block forever even after the fix changes the sha and earns a fresh
`MERGEABLE @ head` (the normal flow). So a genuine block must reference head, per the convention;
- verdict comment(s) exist but reference only *older* commits → **deny** — the stale-review case #242
targets;
- a `Review-verdict:` marker with **no `@ <sha>`** at all → **ask** (a lazy/quoted marker; not
mislabelled as stale);
- no `Review-verdict:` comment at all → **ask** (graceful adoption, mirrors H6's "no Done-when →
ask": surface, don't hard-block a PR that hasn't adopted the convention yet);
- comments unfetchable / head sha unresolvable → **ask**.
Scope: the Claude PreToolUse gate on the Gitea merge tool only. A direct `git push origin main` has no
PR comments to check, so the `.husky/pre-push` backstop is not extended for H10 (the merge tool is the
real merge path; docs-only PRs remain exempt via H6's file-set exemption). Rationale, as with the whole
Wave-1/2/3 hook set: make the process rule a derivation/hook, not prose to remember (#303).
## 2026-07-12 — Formatting-as-you-touch, enforced; rebase-not-merge for PR branches (#311 H11 + format CI)
Two coupled process decisions, prompted when a stale docs branch *merged main in*, dragged ~17
legacy-BOM `.cs` files it never touched into the merge commit, and the pre-commit `dotnet format`
hook then blocked on code that wasn't the author's (#309 session; the BOM backlog is #310).
**1. Formatting-as-you-touch is the standing rule, and it is now *enforced* (not just prose).** ~2500
of ~3900 `.cs` files carry a legacy UTF-8 BOM that violates `.editorconfig`'s `charset=utf-8`. We do
**not** mass-reformat (a repo-wide normalization stays an unmade, separate decision). Instead, a file
you modify for other work must be normalized (`dotnet format`, incl. BOM strip) in that same PR.
Enforcement — previously only the `--no-verify`-bypassable pre-commit hook, which is how #269 landed 17
BOM files (CI never checked charset):
- a **blocking `format` CI job** runs `dotnet format --verify-no-changes` **scoped to the PR's changed
`.cs`** (vs the merge-base) — so it demands conformance only of files the PR touched, never the
untouched legacy 2500; a `.cs`-free PR skips the expensive steps and passes (always reports a status,
safe as a required check). This closes the "CI never verifies charset/format" gap.
- `docs/contributing.md` §7 documents the rule.
**2. Keep a PR branch current by REBASING on `origin/main`, never merging main in (H11).** A merge
commit pulls in *every* file main changed — including files the author never touched — which then trip
the format hook/CI on code that isn't theirs; rebasing keeps the diff to exactly what changed.
Enforced by `.claude/hooks/prepush-rebase-check.sh` wired into `.husky/pre-push`: a push from a branch
that is behind `origin/main` (origin/main not an ancestor of HEAD) is **blocked** with
`git rebase origin/main` guidance. Fail-open (offline / no origin/main / not a repo → allow, since a
git hook has no "ask"); deliberate escape `ETV_SKIP_REBASE_CHECK=1`. This supersedes the old lore
guidance to "merge main into your PR branch." (After a rebase that conflicts in *generated* artifacts —
v1.json/v1.d.ts/endpoint-index — regenerate, don't hand-resolve; `npm run check:api` guards.)
Rationale, as with the whole hook program: make the process rule a derivation/hook, not prose to
remember (#303 methodology review). Tracked: #311; sibling #312 (H12 issue-qualification audit).
## 2026-07-12 — Merge-consent gate auto-grants when satisfied (no redundant prompt); state IS the consent (#314)
Completes the #303 H6/H10 intent — *derive merge-consent from state* — which the original hook only
half-delivered. The rule the user set: **merge permission is auto-granted for the session when the
linked issue's `## Done-when` boxes are all ticked, a fresh positive `Review-verdict` references the
current head, and CI is green** — no separate confirmation, conversational or mechanical.
**Root cause of the bug this fixes:** `pretooluse-merge-consent.sh`'s satisfied path did a bare
`exit 0`. A PreToolUse hook that exits 0 with no JSON does **not** auto-approve — it only declines to
block, so control falls through to the normal permission system and the raw MCP permission prompt
still fires (the merge tool isn't allow-listed). So the gate only ever *added* a deny/ask net; it never
*removed* the baseline prompt on the happy path. Net effect for the operator: a ready-to-merge PR was
confirmed twice — once conversationally (the per-session merge-consent norm) and again by a redundant
mechanical prompt the gate was supposed to have subsumed.
**Fix:** ONLY the genuinely-satisfied merge path (a+b+c all true) now emits
`{"hookSpecificOutput":{"permissionDecision":"allow", ...}}` (a new `grant` decision), which actually
suppresses the prompt. Deny (unticked/red/negative/stale) and ask (non-derivable: no creds, Gitea
down, no linked issue, no `## Done-when`, no verdict) are unchanged — the gate still fails closed, not
open. Two paths deliberately do **not** auto-grant and keep the bare `exit 0` **passthrough** (normal
permissioning → one prompt): non-merge `pull_request_write` methods (auto-grant is scoped to
method=merge only), and the **docs/process-only exemption**. The exemption is a file-TYPE bypass, not
the a+b+c "provably reviewed & ready" proof, so it must not *silently* self-merge — critically, its set
includes `.claude/`/`.gitea/`/`.husky/` (the gate, CI workflows, and git hooks themselves), so a PR
that weakens the gate still gets a human prompt (ersatztv#317 review nit). Verified by 8 pipe tests
(satisfied→allow, docs-only→passthrough, unticked→deny, stale→deny, red-CI→deny, no-verdict→ask,
no-creds→ask, non-merge→passthrough).
**Process consequence:** the state-derived gate *is* the consent on the satisfied path — do **not**
also ask conversationally to merge a PR whose gate auto-grants. A separate human confirmation is still
warranted only when the gate **asks** (state not derivable). This supersedes the "always confirm merge
consent in-conversation per session" phrasing in the kickoff HARD CONSTRAINTS (updated in the same PR).
## 2026-07-12 — Release path rehearses migrations on a prod-DB copy before promoting (#315)
The CI `migrations` job proves a migration is well-formed against a **fresh, empty** DB (model-drift +
apply-to-fresh, per provider). That is necessary but not sufficient: it never exercises the migration —
or ErsatzTV's startup data steps (`DatabaseMigratorService` → `DbInitializer` + `PopulatePathHashes`
over the real `MediaFile` table) — against the **accumulated prod SQLite**, where row volume and
historical values differ. A migration green on a fresh DB can still fail or corrupt on prod, discovered
only mid-deploy after the container recreates.
Decision: before promoting a migration-bearing release, **rehearse** the new image's migrations against
a **throwaway copy of the latest prod backup** via `scripts/migration-smoke.sh` — boot the new image
against the copy, gate PASS on the `Done applying database migrations` log line (the migrator is a
`BackgroundService` running concurrently with Kestrel, so HTTP-readiness alone does *not* prove
migrations finished), FAIL on early container exit / a migration exception / timeout / not serving
afterwards. Always operates on a copy, never the live DB. Home: the script + docs are ours; wiring it
into the Komodo **pre-deploy** step (which already produces the backup) is a server-management concern.
Rationale: data-plane rigor — catch a bad migration on a disposable copy, not on live prod data.
See `docs/ci-cd.md` → Migration-on-prod-copy smoke. Cross-repo wiring tracked in server-management.
## 2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)
Prod keeps the floating `:prod` image reference; PR #191's workflow-driven immutable pin bump is
closed as superseded. server-management#585 proved that automatic and manual promotions share
`DeployStack` and made a changed `:prod` digest trigger the fail-closed backup; #589 added the
prod-copy migration smoke before live recreation. Tagging and promotion remain separate: scan the
tag build's immutable `:<version>` image, then deploy manually. Daily auto-update is only a fallback,
so cut tags with enough runway before 03:00 to prevent an unscanned promotion. Refs #335 and
server-management#585/#589.
+98
View File
@@ -0,0 +1,98 @@
# SPA modularization — App.tsx screen/shell extraction epic (#243)
Why the ChicoryTV SPA was decomposed out of a monolithic `App.tsx`, and the conventions the
extraction froze (single-file screens, screen-owned route wrappers, explicit primary-action
ownership). Pure structural moves — no API/route/CSS/behavior change — so the rationale is
about *boundaries*, relocated from the append-only `docs/decisions.md` at the v26.9.0
consolidation. Cross-links: `docs/spa-conventions.md` §2/§6/§10.
Issue trail: epic #243 — phase 1 #244 (Channels), phase 2 #245 (Playouts), phase 4 #247
(shell/routing + primary actions). Refs #238 #230.
## Contents
- [2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)](#2026-07-11--channels-screen-extraction-244-single-file-screen-no-sibling-helper-dir-epic-243-phase-1)
- [2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)](#2026-07-14--playouts-screen-extraction-245-screen-owned-route-wrapper-epic-243-phase-2)
- [2026-07-15 — App shell/routing extraction + explicit primary-action ownership (#247)](#2026-07-15--app-shellrouting-extraction--explicit-primary-action-ownership-247)
---
## 2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)
First bounded extraction under the App.tsx modularization epic (#243): the Channels domain moved
verbatim out of `web/src/App.tsx` into `web/src/screens/ChannelsScreen.tsx` (zero-prop, self-sufficient,
mirroring the SchedulesScreen extraction), with its behavior tests moved to a colocated
`ChannelsScreen.test.tsx` that owns its own scoped fetch mock (spa-conventions §6). Pure structural
move: no API/route/CSS/visual change; `App.tsx` retains only the import + the `<ChannelsScreen />`
dispatch clause.
**Decision — no `web/src/channels/` sibling helper directory** (unlike Schedules' `web/src/schedules/`).
Channels' pure logic (`stateByChannelId`, `sortedChannels`, `groupedChannels`, `progressFromChannelState`,
`formatChannelNumber`) totals ~30 lines with no independent business-rule layer comparable to Schedules'
`itemRules.ts` (~470 lines, separately unit-tested). Keeping it inside the single screen file matches the
Blocks/Decos/Templates precedent. Revisit only if a later #243 phase adds substantial pure Channels logic
worth isolating.
**One cross-domain helper inlined, not shared:** the Dashboard-owned `progressFromNowPlaying` (still in
`App.tsx`, used by `OnAirCard`) was structurally reused by the Channels `progressFromChannelState`. Rather
than export it from App or create a shared module, its ~5-line start/finish/now percentage math was inlined
into the moved `progressFromChannelState` (behavior-identical — `ChannelState.nowPlaying` carries the same
`startUtc`/`finishUtc` shape), so `ChannelsScreen.tsx` has no import back into `App.tsx`.
**#238 (TopBar `primaryAction` dead button) left untouched** — the `channels` route's inert `ctv:primary-action`
dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign
is deferred to #247 (epic phase 4).
## 2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)
Second bounded extraction under the App.tsx modularization epic (#243): the Playouts domain moved from
`web/src/App.tsx` into `web/src/screens/PlayoutsScreen.tsx`, including its loading/error/empty states,
dialogs, mutations, timeline/filter helpers, and the existing `PlayoutsRouteScreen`. The dedicated
`PlayoutScheduleEditors.tsx` modules remain separate. This is a pure structural move: no API, route, CSS,
or runtime behavior changed; `App.tsx` retains only the import and `<PlayoutsRouteScreen />` dispatch.
**The unguarded route wrapper moves with the screen.** Playouts owns two sibling sub-path editors and,
per `spa-conventions.md` §2, must keep its local pathname state plus `popstate` listener because App's
allow-sub-path route object is reference-stable. Colocating the wrapper keeps that screen-specific route
ownership beside the base screen while App-level tests retain the cross-route navigation assertions.
**Temporal mutation behavior stays verbatim.** `mutatingRef`, `runMutation`, and the explicit
`query.refresh()` after a 409 moved as one unit. The extraction deliberately does not add mount/current
guards to these pre-existing promise completions; changing those semantics belongs to a separate issue.
Detailed behavior tests now render `PlayoutsScreen` directly with a scoped fetch mock, including the
zero-playout Add Playout affordance, lock/409 handling, refresh/poll ownership, action and kind gates, and
dialog flows. Refs #245 #243.
## 2026-07-15 — App shell/routing extraction + explicit primary-action ownership (#247)
Final phase of the App.tsx modularization epic (#243). `web/src/App.tsx` is now only the composition
root: it owns `activeRoute`, `currentPathRef`, the `navigate`/`popstate` pair that consults the dirty
guard, the approved `librariesSubPath`, and the global theme/health inputs, then composes the shell with
the matched screen. Route metadata/matching/sidebar groups moved to `web/src/app/routes.tsx`; shell chrome
(Sidebar, TopBar, Connect menu, version and theme controls) moved to `app/AppShell.tsx`; exhaustive screen
dispatch plus the Media/Libraries wrappers moved to `app/ScreenContent.tsx`. No route, styling, API, or
dependency changed.
**Route identity is deliberate infrastructure.** `routes.tsx` contains the ONE stable module-level array
of shared `ScreenRoute` objects. `routeFromLocation()` returns those same references for an
`allowSubPaths` base path and every owned sub-path, so React's `Object.is` state bailout remains part of
the contract rather than an accidental implementation detail. The three existing sub-path mechanisms stay
distinct: Playouts and Media self-own `pathname`/`popstate`; guarded Libraries receives only the path App
approved after its dirty guard; the remaining route screens retain their established self-owned or keyed
remount behavior. The extraction does not unify them into a speculative router/framework. `ScreenContent`
uses an exhaustive `ScreenId` switch, so a future route added without a dispatch branch fails typecheck
instead of silently falling through to a placeholder.
**Primary actions are one explicit screen-owned registration, not a global event.** The old
`ctv:primary-action` window `CustomEvent` and string-keyed dispatcher are removed. `PrimaryActionProvider`
holds exactly one `{ routeId, handler, owner }` registration shared by the active screen and TopBar;
`usePrimaryAction` registers it, keeps the latest handler behind a ref without ownership churn, and clears
it only when the same opaque owner unmounts. A stale cleanup therefore cannot erase a newer screen's
action. TopBar renders the Plus action only when the route has a non-empty label AND that active route owns
a matching registration, so metadata alone can no longer create a dead button. This is intentionally not a
generic action bus: one declared action per screen is the whole contract.
Tests pin stable route-object identity (including sibling sub-paths and query-only URLs), composition-level
sub-path rendering and dirty-popstate restoration, and primary-action matching/latest-handler/route-change/
cleanup/stale-owner behavior. Detailed Builder and Settings behavior is colocated with those screens while
`App.test.tsx` remains shell/routing/composition coverage. Refs #247 #243 #238 #230.
+3 -1
View File
@@ -71,6 +71,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel` |
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
| **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) |
| **Aggregate `Version`** | Optimistic-concurrency token (issue #253): a plain `int Version` on the 9 replace-all roots — `ProgramSchedule`, `Block`, `Template`, `DecoTemplate`, `Playlist`, `Collection`, `Playout`, `MultiCollection`, `RerunCollection` — implementing `IVersionedAggregate`, EF-mapped `.IsConcurrencyToken()`. Surfaced as a strong ETag on the aggregate's GET and checked against `If-Match` on the PUT (mismatch → 412). See `api-conventions.md` §7a. | `IVersionedAggregate` | (not user-edited) |
## Where things are edited (SPA routes)
@@ -102,7 +103,8 @@ These replace the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages o
`docs/blazor-route-parity.md` Section 2 for the full per-route mapping.
System nav: `/app/settings` (sub-tabs: streaming/system/logging/playout/scanner/general/xmltv —
all mapped 1:1 from legacy `/settings/*` Blazor routes), `/app/logs`, `/app/troubleshooting` (+
all mapped 1:1 from legacy `/settings/*` Blazor routes — plus a new **IPTV** section, #340, that
edits the advertised IPTV base URL via `GET`/`PUT /api/v1/settings/iptv`), `/app/logs`, `/app/troubleshooting` (+
`/app/troubleshooting/blocks` block-playout history, `/app/troubleshooting/yaml` sequential-schedule
validator; playback troubleshooting still gated on API #145), `/app/ffmpeg-profiles`,
`/app/watermarks`.
+41 -1
View File
@@ -120,11 +120,51 @@ Usage:
scripts/e2e-local.sh [CONFIG_DIR]
```
- `CONFIG_DIR` defaults to a fresh `mktemp -d` if omitted.
- Copies `ErsatzTV/wwwroot` → `ErsatzTV/bin/Debug/net10.0/wwwroot`.
- Copies `ErsatzTV/wwwroot` → `ErsatzTV/bin/<config>/net10.0/wwwroot`.
- Launches `dotnet ErsatzTV.dll` in the background with `ETV_CONFIG_FOLDER` set.
- Waits (up to 120s) for the `Done migrating search index` log line.
- Prints the PID and port, then **exits leaving the server running** — the caller is responsible
for killing the PID when done (`kill <PID>`).
- **`ETV_BUILD_CONFIG`** selects which build output to launch (`Debug` default for local dev; the
CI `functional-e2e` job sets `Release`). It must match the `dotnet build --configuration` you ran
first — the script only copies `wwwroot` + launches; it does not build.
## Functional-E2E harness: `scripts/e2e-functional.sh`
The ad-hoc curl scenarios sessions run against a live instance (redirect sweeps, auth flows, the scan
status contract, `If-Match`/412) are codified into a single harness so they run identically by hand
and in CI (the advisory `functional-e2e` job — `docs/ci-cd.md`). It does **not** boot the app; pair it
with `scripts/e2e-local.sh` above (or point it at any running instance):
```bash
CFG=$(mktemp -d)
eval "$(scripts/e2e-local.sh "$CFG" | sed -n 's/^\(PID\|PORT\)=/\1=/p')" # launch, capture PID/PORT
scripts/e2e-functional.sh "http://localhost:${PORT}" "$CFG" # assert; exit 1 on any failure
kill "$PID"
```
`CONFIG_DIR` (arg 2) is **required** — the harness reads the instance's machine key from
`$CONFIG_DIR/api.key` and sends `X-Api-Key` on every `/api` call (the surface is fail-closed). It runs
each assertion even after a failure and prints a pass/fail summary, exiting non-zero if any failed.
What it covers (all curl-only, deterministic, no seeded media / ffmpeg-transcode / browser needed):
- **Legacy→SPA redirects**: a sweep of representative `LegacyUiRedirects` routes 302→`/app/*`, plus
the `/api` + `/artwork` never-redirect exemption (asserted as "did not 302 to `/app`", since those
4xx from their own handlers — `/api/*` 404s, `/artwork/*` 400s).
- **Auth/CSRF/security-stamp** (mirrors the #295 manual set): fresh `auth/config` `setupRequired:true`
→ read-gate 401-without-key / 200-with-key → setup-claim 200 → re-claim 409 → session mutation
403-without-`X-CSRF` → login 401-then-200 → logout 403-without-CSRF / 204-with → post-logout the
same cookie is 401 (rotated security stamp).
- **Library-scan status contract**: create an empty local library → scan `202`, unknown-library scan
`404`, `scan-status` `200`.
- **Optimistic concurrency**: create a collection + a `rerun-collections` targeting it → `GET` emits
an `ETag` → `PUT` with a stale `If-Match` `412`, current `200`, malformed `400`.
**Deliberately deferred** (need the scanner subprocess + seeded media, or a browser, to be
deterministic — ersatztv#299 follow-ups): the 409 "already-scanning" re-trigger, the playout-build
lock 409, and the genuinely UI-interactive Playwright flows. When you extend the harness, add the
observed contract here and keep the assertions deterministic (probe the real instance first — the
initial cut caught that `/artwork/*` 400s where a guess said 404).
## Seeding a local TV library for E2E
+3 -1
View File
@@ -2,7 +2,7 @@
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
160 endpoints, 242 operations.
161 endpoints, 244 operations.
## Artists
@@ -354,6 +354,8 @@
| PUT | `/api/v1/settings/ffmpeg` | UpdateFfmpegSettings | Update global FFmpeg settings |
| GET | `/api/v1/settings/hdhr` | GetHdhrSettings | Get HDHomeRun emulation settings |
| PUT | `/api/v1/settings/hdhr` | UpdateHdhrSettings | Update HDHomeRun emulation settings |
| GET | `/api/v1/settings/iptv` | GetIptvSettings | Get IPTV output settings |
| PUT | `/api/v1/settings/iptv` | UpdateIptvSettings | Update IPTV output settings |
| GET | `/api/v1/settings/logging` | GetLoggingSettings | Get per-area minimum log levels |
| PUT | `/api/v1/settings/logging` | UpdateLoggingSettings | Update per-area minimum log levels |
| GET | `/api/v1/settings/playout` | GetPlayoutSettings | Get global playout settings |
+39 -4
View File
@@ -8,10 +8,10 @@ The original live local E2E run (branch `feat/202-media-sources`, worktree
`/Users/timothy/etv-worktrees/202-int`) automated everything that a disposable local instance could
exercise (local library CRUD, scan, move-path, delete, dirty-guard, apiKey contract, remote-screen
render). The two flows below genuinely need a live Jellyfin/Emby server and a live Plex account.
Results from the first homelab run on 2026-07-13 are recorded under each flow. Jellyfin validation is
complete. Plex account authorization and cleanup were exercised, but the test account returned no
eligible Plex servers, so server/library discovery and sync-preference persistence remain outstanding.
Do not mark #333 done until those remaining Plex steps are exercised against an account with a server.
Results from the homelab runs are recorded under each flow. **Both flows are now complete.** Jellyfin
validation finished on 2026-07-13. The Plex flow finished on 2026-07-16 against a disposable test Plex
Media Server: authorization, server/library discovery, sync-preference persistence, and sign-out
cleanup all passed. #333 is satisfied.
## 1. Jellyfin/Emby real-server connect
@@ -88,6 +88,41 @@ but no sign-out action. Cleanup therefore required the API. The exact request/st
root-cause analysis are captured in
[#345](https://gitea.tblindustries.be/timothy/ersatztv/issues/345).
### Result — 2026-07-16: completed against a live Plex server
The Plex-server prerequisite from the 2026-07-13 run was resolved: a disposable test Plex Media
Server (`plex` container in the `media-servers` stack, reachable from ErsatzTV at `http://plex:32400`
and on the LAN at `http://192.168.1.99:32400`, claimed to the account and seeded with a **Movies**
library) was stood up specifically for this validation (#333 comment). Run against `ersatztv-test`
(`:latest`, host port 8410); API state verified out-of-band with the machine key against the
in-container port 8409.
- [x] Real plex.tv pin flow completed. Server log recorded `Successfully authenticated with plex`
(21:33:27) with no discovery exception, and the SPA transitioned to the signed-in state without a
manual refresh.
- [x] **Server discovery succeeded** (the 2026-07-13 gap): `GET /api/v1/media-sources/plex` reported
`isAuthorized: true`, `isLocked: false`, and `servers: [{ id: 3, name: "8d720a58d5d6",
address: "http://192.168.1.99:32400" }]`.
- [x] Libraries populated for the discovered server:
`GET /api/v1/media-sources/plex/3/libraries` returned the **Movies** library
(`id: 16`, `mediaKind: Movies`, initial `shouldSyncItems: false`).
- [x] Sync-preference persistence: toggled **Sync** on for Movies and saved in the SPA; the API then
reported `shouldSyncItems: true`, and the value survived a full SPA reload.
- [x] Sign-out cleanup: signed out from the SPA (the **Sign out** affordance was present in the
with-server state, unlike the zero-server case in #345). The API returned to `isAuthorized: false`,
`isLocked: false`, `servers: []`; `GET /api/v1/media-sources/plex/3/libraries` then returned **404**
(no stale server/library data); the SPA showed **Not signed in** with no stale rows.
No unexpected page errors. This completes the outstanding Plex steps and the #202 real-server
integration validation. Note: #345 (authorized/zero-server SPA state) was not re-exercised here — this
run always had a discovered server — so that specific edge case remains as filed.
**Test-environment note:** the `ersatztv-test` local-admin credential (set by an earlier session and
unrecorded) was reset to `admin` / the homelab default for this run by clearing the three
`auth.local_admin.*` / `auth.security_stamp` `ConfigElement` rows and re-running first-run setup. The
disposable `plex` container should be removed from the `media-servers` stack now that this flow is
validated (server-management-owned compose change).
## Notes for whoever runs this
- Use a throwaway/test Jellyfin or Emby API key if possible — the SPA's `hasApiKey` contract means the
+49 -23
View File
@@ -65,16 +65,29 @@ silently expanding orchestrator reconnaissance. Global Codex hook enforcement is
`timothy/server-management#592`; the wider Claude-hook port is tracked in `timothy/server-management#593`.
**When the user has not named an issue, queue selection is mechanical fast/small work, never orchestrator
work.** Dispatch exactly one selector on the cheapest suitable model at `low` effort. Give it only
#237's body, last ~6 comments, every OPEN milestone and its OPEN issues, OPEN `review`-labeled
issues, and the reviewer-repo candidate list plus each candidate's claim/deliverable comments; it
returns a ranked shortlist of at most three issue IDs with one-line rationales and live-state evidence. The
orchestrator receives only that compact packet, then performs a focused live recheck of the winner before
claiming. Do not load implementation docs or issue bodies into the selector. If the client cannot route a
work.** Dispatch exactly one selector on the cheapest suitable model at `low` effort. Give it #237's
body and last ~6 comments, then have it query live candidates as a cascade in this exact tier order:
**arc → OPEN issues assigned to OPEN milestones → open `review` → unmilestoned/unreviewed
`priority: high``priority: medium``priority: low`**. Every candidate row represents an issue
(or eligible reviewer audit): milestone records provide tier metadata and are NEVER pickup candidates.
An OPEN milestone with zero eligible OPEN issues contributes zero candidates. Deduplicate candidates,
apply the live eligibility exclusions at each tier (including automatic exclusion of `parked` issues),
and stop as soon as five eligible issues have been accumulated (or all tiers are exhausted). It returns
only that ranked top-five shortlist, with one-line rationales and live-state evidence; a small result
packet is the desired behavior, but an empty or undersized higher tier must fall through to the next tier.
The orchestrator receives only that compact packet, then performs a focused live recheck of the winner
before claiming. If the cheap worker lacks a repository-scoped issue-list tool, it must immediately return
the literal result `TOOL_LIMITATION`; it must not rank issue IDs mentioned in tracker prose, turn milestone
records into candidates, or infer that any tier is empty. The orchestrator then performs only the mechanical
tier queries (repository + state + milestone/label), passes at most the bounded raw ISSUE rows back to the
cheap worker, and leaves all filtering/ranking to that worker. Never substitute an owner-wide/global search
or treat that tool limitation as an empty tier.
Do not load implementation docs or issue bodies into the selector. If the client cannot route a
cheaper subagent, run the selector in a separate low-cost session before starting or resuming the orchestrator
and pass in its packet. **Do not fall back to inline sorting or an equally expensive selector.** If no cheaper
route or session is available, pause and request the selector packet rather than consuming orchestrator tokens
on queue ranking. If the user names an issue, skip selection and only verify that issue's live claimability.
on queue ranking. If the user names an issue, skip selection and only verify that issue's live claimability;
this explicit user choice is the sole path by which a `parked` issue may be worked.
FIRST read `AGENTS.md` and `CLAUDE.md` when present, then `docs/README.md`, the convention docs it
indexes, and the Lessons below. Apply both client instruction files; where they differ, follow the
@@ -108,9 +121,16 @@ Then work the queue:
item in #237's arc list**; its open children are the gate cluster (query them by the `review`
label). Cross-check every arc / "recommended" item's real open/closed state (issue **and**
milestone) before trusting it — do NOT hardcode which issue is the frontier; read it. Candidate
discovery MUST enumerate every OPEN issue in every OPEN milestone plus OPEN `review`-labeled
issues; do not limit the pool to tracker prose or recent comments. Treat an umbrella/epic as a
container rather than a pickup when #237 names its eligible children. ALSO list
discovery is a bounded cascade, not a full inventory: query **arc → OPEN issues assigned to OPEN
milestones → open `review` → unmilestoned/unreviewed `priority: high``priority: medium`
`priority: low`**, carrying eligible unique results forward until the shortlist contains five issues
or all tiers are exhausted. Milestone records are tier metadata, NEVER pickup candidates; an OPEN
milestone with zero eligible OPEN issues contributes zero candidates.
This priority-label fall-through is mandatory: an empty arc/milestone/review pool is never evidence
that the queue is empty. Each priority tier queries all OPEN `timothy/ersatztv` issues carrying that
label, not only IDs mentioned in #237 or recent comments, and excludes pull requests. Treat an
umbrella/epic as a container rather than a pickup when #237 names its eligible children. In the
review tier, ALSO list
open `ersatztv`-labeled issues in **timothy/adversarial-reviewer** — unclaimed audits there are
pickup candidates too (read-only, parallel-safe; see the tracker's "Pending adversarial reviews"
section). Reviewer audits are claimed by comment: a claim remains active until a later comment
@@ -120,23 +140,29 @@ Then work the queue:
print them):
`curl -u <user>:<pass> http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/issues/237`.
**The authoritative pickup + ranking protocol lives in #237's "Session protocol" section — this is a summary; if the two ever disagree, #237 wins.**
2. The selector picks the highest-ranked OPEN, un-`in-progress` candidate and returns at most two
fallbacks. **Rank by labels, not just arc position** (labels are live state; prose is not):
(1) arc order in #237, then
(2) **milestone/review tier vs backlog** — an item with the `review` label OR membership in an OPEN
milestone outranks anything unmilestoned; then (3) **`priority:`** label — `high` > `medium` >
`low` within a tier. Pick order across the three pools: the **arc frontier** (lowest-numbered open
arc item) first; an unclaimed Blocker/High **priority-pickup** (`review` + `priority: high`, e.g.
#253) beats a *non-frontier* arc item; reviewer-repo **audits** are read-only and run in parallel.
2. The selector returns the highest-ranked eligible candidate plus up to four fallbacks. Apply this
strict tier order: **(1)** open arc items in #237 arc order; **(2)** OPEN issues assigned to an OPEN
milestone;
**(3)** OPEN `review`-labeled issues and eligible reviewer-repo audits; **(4)** remaining
unmilestoned/unreviewed `priority: high` issues; **(5)** `priority: medium`; **(6)** `priority: low`.
Within milestone and review tiers, order by `priority: high` > `medium` > `low`; for otherwise equal
candidates preserve #237's explicit eligible order, then use lowest issue number. At every tier exclude
pull requests, CLOSED issues, `in-progress` claims, `parked` issues, issues blocked by a CLOSED milestone,
containers whose children are the pickups, and reviewer audits that are already claimed or have a posted
deliverable. `parked` means excluded from every automatic tier; work it only when the user explicitly
names it.
Query the next tier only while fewer than five eligible unique candidates have been collected; do not
enumerate the remainder after the shortlist is full.
The orchestrator then makes one focused Gitea read to confirm the proposed winner is still OPEN,
unclaimed, and not blocked by a closed milestone; if it changed, check the next supplied fallback.
**An empty arc frontier is not a stopping condition.** If the selector returns any eligible
candidate, claim its top-ranked winner; do not ask the user to choose merely because candidates
belong to different workstreams.
Do not reread the full tracker or comments for selection. If the prose says "recommended next / now
unblocked" but the issue (or its milestone) is already CLOSED, it is done — skip it and fix the
stale line in your session comment. Prose lags live state; live state wins; milestone + `priority:`
labels decide gate-vs-backlog, not the prose. For an equal milestone/review tier and equal priority,
preserve #237's explicit eligible order, then use lowest issue number as the deterministic fallback;
never invent a fix-size, recency, or perceived-relevance tiebreaker. (This mirrors #237's
Session-protocol ranking — #237 is canonical.)
stale line in your session comment. Prose lags live state; live state wins; tier + `priority:` labels
decide order, not the prose. Never invent a fix-size, recency, or perceived-relevance tiebreaker.
(This mirrors #237's Session-protocol ranking — #237 is canonical.)
3. **Claim it**: add the `in-progress` label + a "claiming" comment on the issue(s);
reviewer-repo audits are claimed by comment only. Treat that claim as live until a later comment
explicitly releases or abandons it, and exclude audits with a posted deliverable even while the
+19 -7
View File
@@ -104,7 +104,7 @@ This is therefore **not a code bug in the M3U/XMLTV generators**. The fix is ope
### Architecture: absolute-URL generation (relevant for future work)
**ErsatzTV has no "advertised/public/base URL" setting.** Every absolute URL it emits is derived from the *incoming request's* `Scheme` + `Host` + `PathBase`:
**ErsatzTV now has an optional "advertised base URL" setting for IPTV (`iptv.base_url`, #340 — see "IPTV base URL" below).** When it is **unset** (the default), every absolute URL ErsatzTV emits is derived from the *incoming request's* `Scheme` + `Host` + `PathBase`, exactly as described below; when it is **set**, the two IPTV generators (M3U + XMLTV) use the configured scheme/host/base instead:
| Output | Source of host |
|--------|----------------|
@@ -113,8 +113,8 @@ This is therefore **not a code bug in the M3U/XMLTV generators**. The fix is ope
| FFmpeg watermark overlay logo | `ChannelLogoGenerator.GenerateChannelLogoUrl()` — hardcoded `http://localhost:{StreamingPort}` (correct: FFmpeg runs in-container). **Not** part of M3U/XMLTV. |
Implications for future work:
- Any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) gets that host echoed back into stream + logo + artwork URLs. This affects the planned REST API (#2) and any reverse-proxy fronting (server-management) too.
- **Settings are stored in the `ConfigElement` table as simple key/value** (`ConfigElementKey` in `ErsatzTV.Core/Domain/ConfigElementKey.cs`; read/write via `IConfigElementRepository.GetValue<T>` / upsert). Adding a new setting needs **no EF migration**.
- With `iptv.base_url` **unset**, any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) still gets that host echoed back into stream + logo + artwork URLs. Setting `iptv.base_url` pins the M3U + XMLTV URLs to a fixed public origin regardless of the request `Host`, which is the mitigation for a reverse-proxy / split-DNS deployment (server-management). The HDHomeRun lineup URLs are **not** covered by `iptv.base_url` (deliberately out of scope for #340) and still echo the request host.
- **Settings are stored in the `ConfigElement` table as simple key/value** (`ConfigElementKey` in `ErsatzTV.Core/Domain/ConfigElementKey.cs`; read/write via `IConfigElementRepository.GetValue<T>` / upsert). Adding a new setting needs **no EF migration** — this is exactly how `iptv.base_url` (`ConfigElementKey.IptvBaseUrl`) was added.
### Resolution (2026-06-27) — Gitea #1 closed as config/topology, no code change
@@ -138,13 +138,25 @@ Evidence gathered (all read-only):
> Note — the forwarded-headers theory (`KnownProxies.Clear()` in `Startup.cs` making `X-Forwarded-*` be *ignored*) is **incorrect**: clearing *both* `KnownProxies` and `KnownNetworks` sets `checkKnownIps = false`, which *trusts all* proxies, not none. And there is no proxy between Jellyfin and ErsatzTV. Red herring.
### Latent fragility (not currently broken)
### Latent fragility (not currently broken) — now configurable via `iptv.base_url` (#340)
The EPG Jellyfin consumes still contains 3095 absolute `http://ersatztv:8409/...` programme-icon URLs. They work **only** because Dispatcharr fetches ErsatzTV with a Jellyfin-resolvable host, and Jellyfin shares the Docker network. ErsatzTV still has no advertised/base-URL setting — every absolute URL is hostage to the request `Host`. If a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns.
The EPG Jellyfin consumes still contains 3095 absolute `http://ersatztv:8409/...` programme-icon URLs. They work **only** because Dispatcharr fetches ErsatzTV with a Jellyfin-resolvable host, and Jellyfin shares the Docker network. By default every absolute URL is still hostage to the request `Host`, so if a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns.
### Optional future hardening (NOT committed)
**The mitigation is now built-in:** set the IPTV base URL (`iptv.base_url`, see below) to a fixed public origin and the M3U + XMLTV URLs stop depending on the request `Host` entirely. It stays **opt-in** — unset behaviour is byte-for-byte the request-derived behaviour described above, so nothing changes for a deployment that doesn't need it.
If that fragility is ever worth removing: add an optional `iptv.base_url` ConfigElement (key/value, **no EF migration**) + a "Base URL" settings field; when set, `GetChannelPlaylistHandler` / `GetChannelGuideHandler` parse it into scheme/host/base instead of the request values; when blank, behaviour is unchanged. Cover with unit tests on `ChannelPlaylist.ToM3U()`. Deliberately **not** built for #1 — the problem no longer manifests, so this stays a documented option rather than committed work.
### IPTV base URL (#340)
Originally logged here as "optional future hardening (NOT committed)"; **implemented in #340**. An optional advertised base URL for the IPTV surface:
- **Config**: stored as a single `ConfigElement` under `ConfigElementKey.IptvBaseUrl` (key `iptv.base_url`, key/value, **no EF migration**). Distinct from the `ETV_BASE_URL` environment variable, which only sets the ASP.NET Core `PathBase` (request routing) and does not advertise a host.
- **Helper**: a pure Core helper, `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`.
- `TryParse(string)` validates the configured value and returns `Option<(scheme, host, baseUrl)>` — it must be an **absolute http(s)** URL with **no credentials, query, or fragment**; the **port** and any **path prefix** are preserved, and a trailing slash is normalized off. Anything that fails these rules parses to `None`.
- `Resolve(configured, requestScheme, requestHost, requestBaseUrl)` returns the parsed override when the configured value is present and valid, and otherwise **falls back to the request-derived** scheme/host/base. So a blank or malformed value is transparently ignored.
- **Where it's applied**: resolution happens **inside the two generation handlers**, not in the controller — `GetChannelPlaylistHandler` (M3U guide/logo/stream URLs, via `ChannelPlaylist`) and `GetChannelGuideHandler` (both XMLTV `{RequestBase}` substitution sites). Because unset/invalid falls back to the request values, the golden tests (`ChannelPlaylistGoldenTests`, `ChannelGuideGoldenTests`) are unchanged.
- **Scope**: M3U + XMLTV only. Deliberately **NOT** applied to the HDHomeRun lineup URLs (out of scope for #340).
- **API + UI**: managed through a new "IPTV" settings group — `GET`/`PUT /api/v1/settings/iptv` on `SettingsController` (tier `[RequiresAuthentication]`), body `{ baseUrl }`. A **blank** value clears the key; a **non-blank malformed** value → **422**. A new "IPTV" section on the SPA Settings screen edits it.
Covered by unit tests on `AdvertisedBaseUrl` (parse/validate/resolve) plus the unchanged M3U/XMLTV goldens proving unset = today's output.
### Historical Workaround (pre-Dispatcharr)
+41 -19
View File
@@ -12,22 +12,34 @@ Companion to `api-conventions.md` (the API surface the SPA talks to) and `docs/c
Vite + React + TypeScript, builds to `ErsatzTV/wwwroot/app` (see `web/vite.config.ts`:
`base: '/app/'`, `build.outDir: '../ErsatzTV/wwwroot/app'`), served by the ASP.NET host at `/app`.
- **Routes + nav**: `web/src/App.tsx` — one big route table of `ScreenRoute` objects (`path`,
`label`, `title`, `kicker`, `icon`, etc.) plus an `allowSubPaths?: boolean` flag.
- **Composition root**: `web/src/App.tsx` — owns the active route, approved Libraries sub-path,
navigation guard integration, and theme; it composes the shell with the matched screen.
- **Routes + nav**: `web/src/app/routes.tsx` — the ONE stable module-level array of `ScreenRoute`
objects (`path`, `label`, `title`, `kicker`, `icon`, etc.) plus `allowSubPaths?: boolean`, the
matcher, and sidebar group definitions. Route objects must never be rebuilt per render.
- **Shell + screen dispatch**: `web/src/app/AppShell.tsx` owns Sidebar/TopBar/Connect/version/theme
chrome; `web/src/app/ScreenContent.tsx` exhaustively maps a matched route to its screen and owns
the Media/Libraries route wrappers.
- **Screens**: `web/src/screens/*.tsx`, one file per top-level screen, generally with a colocated
`*.test.tsx`.
- **API clients**: `web/src/api/<domain>.ts` (see §4).
- **Styling**: `web/src/shell.css` (+ `web/src/components/components.css`) — utility classes with a
`ctv-` prefix (~690 occurrences across those two files). Reuse an existing `ctv-*` class before
inventing a new one.
inventing a new one. `shell.css` carries the only base reset — `html, body { margin: 0 }` plus
`body { background: var(--surface-app) }` (the 8px default body margin otherwise frames every
full-viewport layout with a light border, #373). **There is no global `box-sizing` reset**
(the SPA is authored under the default `content-box`), so any element that combines `width: 100%`
with padding/border must set `box-sizing: border-box` locally or it overflows its container — e.g.
`.ctv-nav-item` (#377). Prefer `width: auto` (shrink-to-fit) over `width: 100%` + padding where you can.
## 2. CRITICAL: sub-path screens must own their own pathname state
If a route sets `allowSubPaths: true` (e.g. so `/app/blocks/{id}` works under the `/app/blocks` nav
entry), **the screen component itself must track `window.location.pathname` and listen for
`popstate`** — do not rely on `App.tsx` re-rendering `ScreenContent` when the sub-path changes.
`popstate`** — do not rely on the composition root re-rendering `ScreenContent` when the sub-path
changes.
**Why**: `App.tsx`'s `routeFromLocation()` matches an `allowSubPaths` route by prefix
**Why**: `app/routes.tsx`'s `routeFromLocation()` matches an `allowSubPaths` route by prefix
(`pathname.startsWith(\`${route.path}/\`)`) and returns the **same `ScreenRoute` object reference**
for the base path and every sub-path under it. `App`'s state update is
`setActiveRoute(routeFromLocation())`; React's `useState` setter bails via `Object.is` when the new
@@ -46,8 +58,8 @@ Exemplars of screens that already do this correctly: `BlocksScreen.tsx`, `Templa
**Exception — a guarded sub-path route defers pathname ownership to App.** When a sub-path route's
screens ALSO register a dirty guard (§8), the wrapper must **not** self-listen for `popstate`; App
owns the pathname and passes the approved sub-path down as a prop. The `LibrariesRouteScreen` wrapper
in `App.tsx` is the exemplar (sub-path parsed by `parseLibrariesSubRoute`, dispatched via a flat
`switch`, sub-path supplied by App's `librariesSubPath` state). See §8 for why child-before-parent
in `app/ScreenContent.tsx` is the exemplar (sub-path parsed by `parseLibrariesSubRoute`, dispatched
via a flat `switch`, sub-path supplied by App's `librariesSubPath` state). See §8 for why child-before-parent
effect order makes the self-listening pattern unsafe here.
## 3. Data loading pattern
@@ -251,16 +263,17 @@ is gone. The SPA seams now are:
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
- Every screen with meaningful logic gets a screen test; every API client module gets a
param-mapping / URL-building test (e.g. `logs.test.ts` next to `logs.ts`).
- `web/src/App.test.tsx` covers navigation + the route table, including regressions like the
sub-path bug in §2 (see the tests around `PlayoutsRouteScreen` that click into
`/app/playouts/{id}/...` sub-paths and assert the correct sub-screen rendered).
- `web/src/App.test.tsx` covers composition, navigation, shell behavior, and integration regressions
such as sub-path-to-sub-path rendering and guarded popstate restoration. Detailed screen behavior
belongs in the screen's colocated test. `web/src/app/routes.test.tsx` independently pins stable
route-object identity and query/path matching.
- **Nav-label test-selector care**: `getByRole('link'/'button', { name: /Regex/ })` matches by
substring by default — a loose regex can match more than one nav item. Verified example: the
System nav button is matched with an **anchored** regex (`name: /^System/`) rather than a bare
`/System/`, specifically to avoid ambiguous matches against other labels that start with or
contain "System". Anchor (`^`/`$`) or use exact strings in `getByRole` name matchers whenever a
new label could be a substring of (or share a substring with) an existing one — check
`App.tsx`'s nav `label:` list for collisions before picking a new label.
`app/routes.tsx`'s nav `label:` list for collisions before picking a new label.
- **Extracted-screen tests own their own fetch mock** (Schedules #207, Channels #244, Playouts
#245): when a
screen is pulled out of `App.tsx` into `web/src/screens/<Name>Screen.tsx`, its colocated
@@ -356,21 +369,30 @@ Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state v
## 10. TopBar primary-action button (`usePrimaryAction`)
The shell TopBar (`App.tsx`) renders at most **one** primary-action button (top-right, Plus icon) for the
active screen. The TopBar has no reference to the screen component, so the click is delivered as a window
`CustomEvent` keyed on the active route id; the seam is `web/src/primaryAction.ts`:
The shell TopBar (`app/AppShell.tsx`) renders at most **one** primary-action button (top-right, Plus
icon) for the active screen. `App.tsx` wraps the shell and matched screen in the minimal
`PrimaryActionProvider` from `web/src/primaryAction.ts`: the active screen explicitly registers one
handler, and the TopBar reads that matching registration directly from React context. No window event,
string-keyed dispatcher, or generic screen-action framework participates in normal screen actions.
- **A screen opts in** by calling `usePrimaryAction(routeId, handler)` at the **top of the component, before
any early return** (it's a hook). The handler must be reachable there — a create/navigate handler or a
`useState` setter, not something defined below a loading/error `return`. Reference: `SchedulesScreen`
(`usePrimaryAction('schedules', () => setForm('create'))`).
- **The route must ALSO declare a matching non-empty `primaryAction` label** in the `routes` table. The
TopBar renders the button **only when `route.primaryAction` is non-empty** — so declaring a label without
subscribing renders a dead button, and subscribing without a label renders nothing. Keep the two in lockstep.
- **The provider owns exactly one registration.** Each mounted hook gets an opaque owner token; registering a
newer screen replaces the previous registration, and cleanup clears only the registration owned by that
hook. A stale unmount therefore cannot erase the newer screen's action. The hook keeps the latest handler in
a ref, so ordinary screen renders update behavior without reclaiming/churning ownership. Directly-rendered
screen tests outside the provider remain harmless; there is simply no shell action consumer.
- **The route must ALSO declare a matching non-empty `primaryAction` label** in the stable
`app/routes.tsx` table. The TopBar renders the button only when that label is non-empty **and** the active
route owns a matching registration — metadata alone can no longer produce a dead button, while a
registration without a label remains intentionally invisible. Keep the two in lockstep.
The `#238` tests in `App.test.tsx` guard this: a data-driven `it.each` asserts each URL-navigating create
screen's banner actually navigates (so a typo'd route id → a button that navigates nowhere → red), plus a
drop test that an action screen shows no banner button. The dialog/editor create screens (schedules, multi/
rerun collections, trakt) are exercised by their own create-flow tests.
drop test that an action screen shows no banner button. `primaryAction.test.tsx` separately pins matching,
latest-handler, route-change, cleanup, and stale-owner semantics. The dialog/editor create screens
(schedules, multi/rerun collections, trakt) are exercised by their own create-flow tests.
- **When to WIRE vs DROP (issue #238).** The Plus icon makes the button semantically a *"create new item"*
affordance. **Keep + wire it only on list screens with a single, unambiguous create flow** ("Add Channel",
"Add Schedule", "Add Multi-Collection", "Add Rerun Collection", "Add Trakt List", "Add Filler Preset", "Add
@@ -2,16 +2,16 @@
**Date:** 2026-07-02
**Issue:** [ersatztv#97 - Backend: channel on-air / now-playing state API](http://192.168.1.95:3000/timothy/ersatztv/issues/97)
**Status:** Approved for implementation planning.
**Status:** Implemented by #97; universal streaming-mode coverage completed by #99.
## Context
The ChicoryTV Channels prototype needs runtime channel state for the **On-air** filter, live row treatment, and **Now playing** lane. The existing `GET /api/channels` endpoint returns static channel configuration and should stay cheap. Runtime state changes with wall-clock time and active streaming sessions, so it belongs in a separate pollable endpoint.
The ChicoryTV Channels prototype needs runtime channel state for the **On-air** filter, live row treatment, and **Now playing** lane. The existing `GET /api/v1/channels` endpoint returns static channel configuration and should stay cheap. Runtime state changes with wall-clock time and active streaming sessions, so it belongs in a separate pollable endpoint.
ErsatzTV has two distinct concepts:
- The playout schedule is clock-based and exists independent of viewers.
- A stream session exists only while a client is actively pulling a segmenter-backed stream.
- A stream session exists only while a client is actively pulling a stream.
The API should expose both concepts separately rather than blending them into one ambiguous status.
@@ -20,7 +20,7 @@ The API should expose both concepts separately rather than blending them into on
Add a batched endpoint:
```http
GET /api/channels/state
GET /api/v1/channels/state
```
Response:
@@ -48,9 +48,9 @@ Response:
Fields:
- `channelId`: channel database id for joining with `GET /api/channels` rows.
- `channelId`: channel database id for joining with `GET /api/v1/channels` rows.
- `channelNumber`: stable display/routing number, also useful for active session checks.
- `onAir`: `true` only when the channel has an active segmenter session.
- `onAir`: `true` when the channel has an active segmenter-backed or direct stream session.
- `nowPlaying`: current scheduled `PlayoutItem` by wall-clock, or `null`.
- `nowPlaying.title`: display title from the existing playout title mapper.
- `nowPlaying.startUtc` / `finishUtc`: UTC item bounds. The SPA computes progress percentage client-side from these values.
@@ -61,18 +61,22 @@ The endpoint returns one row per channel so the SPA can poll the whole grid with
`nowPlaying` means "what is scheduled right now." It is populated for any channel with a built playout item covering the current wall-clock time, even when `onAir` is false.
`onAir` initially means "has an active HLS segmenter session" using `IFFmpegSegmenterService.IsActive(channelNumber)`. This matches the current `/api/sessions` semantics because the only session registry in the codebase is `IFFmpegSegmenterService.Workers`.
`onAir` means "has an active stream session." It combines
`IFFmpegSegmenterService.IsActive(channelNumber)` for segmenter-backed modes with
`IDirectStreamSessionTracker.IsActive(channelNumber)` for direct modes.
Known `onAir` coverage:
| Streaming mode | Covered by `IsActive` |
| Streaming mode | Activity source |
| --- | --- |
| `HttpLiveStreamingSegmenter` | Yes |
| `TransportStreamHybrid` | Yes |
| `TransportStream` / MPEG-TS direct | No |
| `HttpLiveStreamingDirect` | No |
| `HttpLiveStreamingSegmenter` | `IFFmpegSegmenterService` |
| `TransportStreamHybrid` | `IFFmpegSegmenterService` |
| `TransportStream` / MPEG-TS direct | `IDirectStreamSessionTracker` |
| `HttpLiveStreamingDirect` | `IDirectStreamSessionTracker` |
The direct streaming modes can be actively streaming while `onAir` reports false because they do not register a segmenter worker. That limitation is documented here and tracked as follow-up [#99](http://192.168.1.95:3000/timothy/ersatztv/issues/99). Issue #97 should not instrument the streaming hot path.
The direct-stream tracking seam landed in #99 without changing this endpoint's response contract. Direct
registrations are created only while the response body is executing and are removed on completion,
disconnect, or error; HEAD probes do not count as viewers.
## Null And Degraded States
@@ -86,7 +90,7 @@ When no runtime data is available, the response should degrade to `onAir: false`
`nowPlaying` is also `null` when the current playout item is filler with no program item in its guide group (for example a channel looping fallback filler). When the current item is filler *inside* a program's guide group (pre/mid-roll), the endpoint surfaces the program — matching the XMLTV guide — not the filler.
Known imprecision: OnDemand channels report `nowPlaying` from stored playout items, which are only re-anchored when a viewer tunes in. While such a channel sits idle, the reported item and progress drift from what a new viewer will actually see. This is inherent to the stored data and accepted, like the #99 `onAir` caveat.
Known imprecision: OnDemand channels report `nowPlaying` from stored playout items, which are only re-anchored when a viewer tunes in. While such a channel sits idle, the reported item and progress drift from what a new viewer will actually see. This is inherent to the stored data and accepted.
## Implementation Shape
@@ -98,7 +102,8 @@ The handler should:
2. Query current playout items in one batched database query using the current UTC time.
3. Include enough media metadata to map the item title with the existing `ErsatzTV.Application.Playouts.Mapper.GetDisplayTitle` behavior.
4. Join the current items back to channels in memory.
5. Ask `IFFmpegSegmenterService.IsActive(channel.Number)` for each channel's `onAir` value.
5. Ask both `IFFmpegSegmenterService.IsActive(channel.Number)` and
`IDirectStreamSessionTracker.IsActive(channel.Number)` for each channel's `onAir` value.
Avoid an N+1 database query. The endpoint is intended for frequent table polling.
@@ -108,19 +113,21 @@ No database migration is required because the endpoint reads existing channel, p
If #68 introduces "always playing" channels by holding a segmenter-backed session, `onAir` will continue to work without an API contract change. If that feature uses a different runtime mechanism, revisit the `onAir` implementation behind the same response field.
Once #99 adds active-session tracking for MPEG-TS and HLS-Direct paths, `onAir` can become universal without changing the endpoint contract.
#99 added active-session tracking for MPEG-TS and HLS-Direct paths, making `onAir` universal without changing
the endpoint contract.
## Testing
Controller tests should verify:
- `GET /api/channels/state` routes through MediatR and returns `200 OK`.
- `GET /api/v1/channels/state` routes through MediatR and returns `200 OK`.
- The endpoint advertises a `List<ChannelStateResponseModel>` response.
Application handler tests should verify:
- A channel with an active segmenter session returns `onAir: true`.
- A channel with no active segmenter session returns `onAir: false`.
- A channel with an active direct stream session returns `onAir: true`.
- A channel with neither kind of active session returns `onAir: false`.
- A current playout item returns `nowPlaying` with title, `startUtc`, and `finishUtc`.
- A channel without a current item returns `nowPlaying: null`.
- Multiple channels are resolved through one handler call without per-channel playout lookups.
@@ -0,0 +1,292 @@
# .NET 10 SDK analyzer baseline for ersatztv#15.
#
# AnalysisLevel=latest-All enables the complete SDK rule inventory, whose generated
# per-rule severities otherwise override .editorconfig's bulk suggestion setting.
# This higher-priority snapshot keeps every current .NET 10 rule visible but non-blocking.
# Preserve SDK 'none' entries for rules introduced after .NET 10. On an SDK major upgrade,
# regenerate this list from analysislevel_<major>_all.globalconfig and review new rules.
# A per-rule severity in the repository .editorconfig overrides this baseline.
is_global = true
global_level = -50
dotnet_diagnostic.CA1000.severity = suggestion
dotnet_diagnostic.CA1001.severity = suggestion
dotnet_diagnostic.CA1002.severity = suggestion
dotnet_diagnostic.CA1003.severity = suggestion
dotnet_diagnostic.CA1008.severity = suggestion
dotnet_diagnostic.CA1010.severity = suggestion
dotnet_diagnostic.CA1012.severity = suggestion
dotnet_diagnostic.CA1016.severity = suggestion
dotnet_diagnostic.CA1018.severity = suggestion
dotnet_diagnostic.CA1019.severity = suggestion
dotnet_diagnostic.CA1024.severity = suggestion
dotnet_diagnostic.CA1027.severity = suggestion
dotnet_diagnostic.CA1028.severity = suggestion
dotnet_diagnostic.CA1030.severity = suggestion
dotnet_diagnostic.CA1031.severity = suggestion
dotnet_diagnostic.CA1032.severity = suggestion
dotnet_diagnostic.CA1033.severity = suggestion
dotnet_diagnostic.CA1034.severity = suggestion
dotnet_diagnostic.CA1036.severity = suggestion
dotnet_diagnostic.CA1040.severity = suggestion
dotnet_diagnostic.CA1041.severity = suggestion
dotnet_diagnostic.CA1043.severity = suggestion
dotnet_diagnostic.CA1044.severity = suggestion
dotnet_diagnostic.CA1046.severity = suggestion
dotnet_diagnostic.CA1047.severity = suggestion
dotnet_diagnostic.CA1050.severity = suggestion
dotnet_diagnostic.CA1051.severity = suggestion
dotnet_diagnostic.CA1052.severity = suggestion
dotnet_diagnostic.CA1054.severity = suggestion
dotnet_diagnostic.CA1055.severity = suggestion
dotnet_diagnostic.CA1056.severity = suggestion
dotnet_diagnostic.CA1058.severity = suggestion
dotnet_diagnostic.CA1061.severity = suggestion
dotnet_diagnostic.CA1062.severity = suggestion
dotnet_diagnostic.CA1063.severity = suggestion
dotnet_diagnostic.CA1064.severity = suggestion
dotnet_diagnostic.CA1065.severity = suggestion
dotnet_diagnostic.CA1066.severity = suggestion
dotnet_diagnostic.CA1067.severity = suggestion
dotnet_diagnostic.CA1068.severity = suggestion
dotnet_diagnostic.CA1069.severity = suggestion
dotnet_diagnostic.CA1070.severity = suggestion
dotnet_diagnostic.CA1200.severity = suggestion
dotnet_diagnostic.CA1303.severity = suggestion
dotnet_diagnostic.CA1304.severity = suggestion
dotnet_diagnostic.CA1305.severity = suggestion
dotnet_diagnostic.CA1307.severity = suggestion
dotnet_diagnostic.CA1308.severity = suggestion
dotnet_diagnostic.CA1309.severity = suggestion
dotnet_diagnostic.CA1310.severity = suggestion
dotnet_diagnostic.CA1311.severity = suggestion
dotnet_diagnostic.CA1401.severity = suggestion
dotnet_diagnostic.CA1419.severity = suggestion
dotnet_diagnostic.CA1421.severity = suggestion
dotnet_diagnostic.CA1507.severity = suggestion
dotnet_diagnostic.CA1508.severity = suggestion
dotnet_diagnostic.CA1510.severity = suggestion
dotnet_diagnostic.CA1511.severity = suggestion
dotnet_diagnostic.CA1512.severity = suggestion
dotnet_diagnostic.CA1513.severity = suggestion
dotnet_diagnostic.CA1514.severity = suggestion
dotnet_diagnostic.CA1515.severity = suggestion
dotnet_diagnostic.CA1516.severity = none
dotnet_diagnostic.CA1700.severity = suggestion
dotnet_diagnostic.CA1707.severity = suggestion
dotnet_diagnostic.CA1708.severity = suggestion
dotnet_diagnostic.CA1710.severity = suggestion
dotnet_diagnostic.CA1711.severity = suggestion
dotnet_diagnostic.CA1712.severity = suggestion
dotnet_diagnostic.CA1713.severity = suggestion
dotnet_diagnostic.CA1715.severity = suggestion
dotnet_diagnostic.CA1716.severity = suggestion
dotnet_diagnostic.CA1720.severity = suggestion
dotnet_diagnostic.CA1721.severity = suggestion
dotnet_diagnostic.CA1724.severity = suggestion
dotnet_diagnostic.CA1725.severity = suggestion
dotnet_diagnostic.CA1727.severity = suggestion
dotnet_diagnostic.CA1802.severity = suggestion
dotnet_diagnostic.CA1805.severity = suggestion
dotnet_diagnostic.CA1806.severity = suggestion
dotnet_diagnostic.CA1810.severity = suggestion
dotnet_diagnostic.CA1812.severity = suggestion
dotnet_diagnostic.CA1813.severity = suggestion
dotnet_diagnostic.CA1814.severity = suggestion
dotnet_diagnostic.CA1815.severity = suggestion
dotnet_diagnostic.CA1816.severity = suggestion
dotnet_diagnostic.CA1819.severity = suggestion
dotnet_diagnostic.CA1820.severity = suggestion
dotnet_diagnostic.CA1821.severity = suggestion
dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1823.severity = suggestion
dotnet_diagnostic.CA1824.severity = suggestion
dotnet_diagnostic.CA1825.severity = suggestion
dotnet_diagnostic.CA1826.severity = suggestion
dotnet_diagnostic.CA1827.severity = suggestion
dotnet_diagnostic.CA1828.severity = suggestion
dotnet_diagnostic.CA1829.severity = suggestion
dotnet_diagnostic.CA1830.severity = suggestion
dotnet_diagnostic.CA1832.severity = suggestion
dotnet_diagnostic.CA1833.severity = suggestion
dotnet_diagnostic.CA1834.severity = suggestion
dotnet_diagnostic.CA1835.severity = suggestion
dotnet_diagnostic.CA1836.severity = suggestion
dotnet_diagnostic.CA1837.severity = suggestion
dotnet_diagnostic.CA1838.severity = suggestion
dotnet_diagnostic.CA1839.severity = suggestion
dotnet_diagnostic.CA1840.severity = suggestion
dotnet_diagnostic.CA1841.severity = suggestion
dotnet_diagnostic.CA1842.severity = suggestion
dotnet_diagnostic.CA1843.severity = suggestion
dotnet_diagnostic.CA1844.severity = suggestion
dotnet_diagnostic.CA1845.severity = suggestion
dotnet_diagnostic.CA1846.severity = suggestion
dotnet_diagnostic.CA1847.severity = suggestion
dotnet_diagnostic.CA1848.severity = suggestion
dotnet_diagnostic.CA1849.severity = suggestion
dotnet_diagnostic.CA1850.severity = suggestion
dotnet_diagnostic.CA1851.severity = suggestion
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA1853.severity = suggestion
dotnet_diagnostic.CA1854.severity = suggestion
dotnet_diagnostic.CA1855.severity = suggestion
dotnet_diagnostic.CA1856.severity = suggestion
dotnet_diagnostic.CA1858.severity = suggestion
dotnet_diagnostic.CA1859.severity = suggestion
dotnet_diagnostic.CA1860.severity = suggestion
dotnet_diagnostic.CA1861.severity = suggestion
dotnet_diagnostic.CA1862.severity = suggestion
dotnet_diagnostic.CA1863.severity = suggestion
dotnet_diagnostic.CA1864.severity = suggestion
dotnet_diagnostic.CA1865.severity = suggestion
dotnet_diagnostic.CA1866.severity = suggestion
dotnet_diagnostic.CA1867.severity = suggestion
dotnet_diagnostic.CA1868.severity = suggestion
dotnet_diagnostic.CA1869.severity = suggestion
dotnet_diagnostic.CA1870.severity = suggestion
dotnet_diagnostic.CA1871.severity = suggestion
dotnet_diagnostic.CA1872.severity = suggestion
dotnet_diagnostic.CA1873.severity = suggestion
dotnet_diagnostic.CA1874.severity = suggestion
dotnet_diagnostic.CA1875.severity = suggestion
dotnet_diagnostic.CA2000.severity = suggestion
dotnet_diagnostic.CA2002.severity = suggestion
dotnet_diagnostic.CA2007.severity = suggestion
dotnet_diagnostic.CA2008.severity = suggestion
dotnet_diagnostic.CA2009.severity = suggestion
dotnet_diagnostic.CA2011.severity = suggestion
dotnet_diagnostic.CA2012.severity = suggestion
dotnet_diagnostic.CA2016.severity = suggestion
dotnet_diagnostic.CA2019.severity = suggestion
dotnet_diagnostic.CA2020.severity = suggestion
dotnet_diagnostic.CA2025.severity = suggestion
dotnet_diagnostic.CA2100.severity = suggestion
dotnet_diagnostic.CA2101.severity = suggestion
dotnet_diagnostic.CA2119.severity = suggestion
dotnet_diagnostic.CA2153.severity = suggestion
dotnet_diagnostic.CA2201.severity = suggestion
dotnet_diagnostic.CA2207.severity = suggestion
dotnet_diagnostic.CA2208.severity = suggestion
dotnet_diagnostic.CA2211.severity = suggestion
dotnet_diagnostic.CA2213.severity = suggestion
dotnet_diagnostic.CA2214.severity = suggestion
dotnet_diagnostic.CA2215.severity = suggestion
dotnet_diagnostic.CA2216.severity = suggestion
dotnet_diagnostic.CA2217.severity = suggestion
dotnet_diagnostic.CA2218.severity = suggestion
dotnet_diagnostic.CA2219.severity = suggestion
dotnet_diagnostic.CA2224.severity = suggestion
dotnet_diagnostic.CA2225.severity = suggestion
dotnet_diagnostic.CA2226.severity = suggestion
dotnet_diagnostic.CA2227.severity = suggestion
dotnet_diagnostic.CA2231.severity = suggestion
dotnet_diagnostic.CA2234.severity = suggestion
dotnet_diagnostic.CA2235.severity = suggestion
dotnet_diagnostic.CA2237.severity = suggestion
dotnet_diagnostic.CA2241.severity = suggestion
dotnet_diagnostic.CA2242.severity = suggestion
dotnet_diagnostic.CA2243.severity = suggestion
dotnet_diagnostic.CA2244.severity = suggestion
dotnet_diagnostic.CA2245.severity = suggestion
dotnet_diagnostic.CA2246.severity = suggestion
dotnet_diagnostic.CA2248.severity = suggestion
dotnet_diagnostic.CA2249.severity = suggestion
dotnet_diagnostic.CA2250.severity = suggestion
dotnet_diagnostic.CA2251.severity = suggestion
dotnet_diagnostic.CA2252.severity = suggestion
dotnet_diagnostic.CA2253.severity = suggestion
dotnet_diagnostic.CA2254.severity = suggestion
dotnet_diagnostic.CA2262.severity = suggestion
dotnet_diagnostic.CA2263.severity = suggestion
dotnet_diagnostic.CA2300.severity = suggestion
dotnet_diagnostic.CA2301.severity = suggestion
dotnet_diagnostic.CA2302.severity = suggestion
dotnet_diagnostic.CA2305.severity = suggestion
dotnet_diagnostic.CA2310.severity = suggestion
dotnet_diagnostic.CA2311.severity = suggestion
dotnet_diagnostic.CA2312.severity = suggestion
dotnet_diagnostic.CA2315.severity = suggestion
dotnet_diagnostic.CA2321.severity = suggestion
dotnet_diagnostic.CA2322.severity = suggestion
dotnet_diagnostic.CA2326.severity = suggestion
dotnet_diagnostic.CA2327.severity = suggestion
dotnet_diagnostic.CA2328.severity = suggestion
dotnet_diagnostic.CA2329.severity = suggestion
dotnet_diagnostic.CA2330.severity = suggestion
dotnet_diagnostic.CA2350.severity = suggestion
dotnet_diagnostic.CA2351.severity = suggestion
dotnet_diagnostic.CA2352.severity = suggestion
dotnet_diagnostic.CA2353.severity = suggestion
dotnet_diagnostic.CA2354.severity = suggestion
dotnet_diagnostic.CA2355.severity = suggestion
dotnet_diagnostic.CA2356.severity = suggestion
dotnet_diagnostic.CA2361.severity = suggestion
dotnet_diagnostic.CA2362.severity = suggestion
dotnet_diagnostic.CA3001.severity = suggestion
dotnet_diagnostic.CA3002.severity = suggestion
dotnet_diagnostic.CA3003.severity = suggestion
dotnet_diagnostic.CA3004.severity = suggestion
dotnet_diagnostic.CA3005.severity = suggestion
dotnet_diagnostic.CA3006.severity = suggestion
dotnet_diagnostic.CA3007.severity = suggestion
dotnet_diagnostic.CA3008.severity = suggestion
dotnet_diagnostic.CA3009.severity = suggestion
dotnet_diagnostic.CA3010.severity = suggestion
dotnet_diagnostic.CA3011.severity = suggestion
dotnet_diagnostic.CA3012.severity = suggestion
dotnet_diagnostic.CA3061.severity = suggestion
dotnet_diagnostic.CA3075.severity = suggestion
dotnet_diagnostic.CA3076.severity = suggestion
dotnet_diagnostic.CA3077.severity = suggestion
dotnet_diagnostic.CA3147.severity = suggestion
dotnet_diagnostic.CA5350.severity = suggestion
dotnet_diagnostic.CA5351.severity = suggestion
dotnet_diagnostic.CA5358.severity = suggestion
dotnet_diagnostic.CA5359.severity = suggestion
dotnet_diagnostic.CA5360.severity = suggestion
dotnet_diagnostic.CA5361.severity = suggestion
dotnet_diagnostic.CA5362.severity = suggestion
dotnet_diagnostic.CA5363.severity = suggestion
dotnet_diagnostic.CA5364.severity = suggestion
dotnet_diagnostic.CA5365.severity = suggestion
dotnet_diagnostic.CA5366.severity = suggestion
dotnet_diagnostic.CA5367.severity = suggestion
dotnet_diagnostic.CA5368.severity = suggestion
dotnet_diagnostic.CA5369.severity = suggestion
dotnet_diagnostic.CA5370.severity = suggestion
dotnet_diagnostic.CA5371.severity = suggestion
dotnet_diagnostic.CA5372.severity = suggestion
dotnet_diagnostic.CA5373.severity = suggestion
dotnet_diagnostic.CA5374.severity = suggestion
dotnet_diagnostic.CA5375.severity = suggestion
dotnet_diagnostic.CA5376.severity = suggestion
dotnet_diagnostic.CA5377.severity = suggestion
dotnet_diagnostic.CA5378.severity = suggestion
dotnet_diagnostic.CA5379.severity = suggestion
dotnet_diagnostic.CA5380.severity = suggestion
dotnet_diagnostic.CA5381.severity = suggestion
dotnet_diagnostic.CA5382.severity = suggestion
dotnet_diagnostic.CA5383.severity = suggestion
dotnet_diagnostic.CA5384.severity = suggestion
dotnet_diagnostic.CA5385.severity = suggestion
dotnet_diagnostic.CA5386.severity = suggestion
dotnet_diagnostic.CA5387.severity = suggestion
dotnet_diagnostic.CA5388.severity = suggestion
dotnet_diagnostic.CA5389.severity = suggestion
dotnet_diagnostic.CA5390.severity = suggestion
dotnet_diagnostic.CA5391.severity = suggestion
dotnet_diagnostic.CA5392.severity = suggestion
dotnet_diagnostic.CA5393.severity = suggestion
dotnet_diagnostic.CA5394.severity = suggestion
dotnet_diagnostic.CA5395.severity = suggestion
dotnet_diagnostic.CA5396.severity = suggestion
dotnet_diagnostic.CA5397.severity = suggestion
dotnet_diagnostic.CA5398.severity = suggestion
dotnet_diagnostic.CA5399.severity = suggestion
dotnet_diagnostic.CA5400.severity = suggestion
dotnet_diagnostic.CA5401.severity = suggestion
dotnet_diagnostic.CA5402.severity = suggestion
dotnet_diagnostic.CA5403.severity = suggestion
dotnet_diagnostic.CA5404.severity = suggestion
dotnet_diagnostic.CA5405.severity = suggestion
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# scripts/e2e-functional.sh — drive the manual live-E2E functional flows against a RUNNING
# ErsatzTV instance and assert their HTTP contracts. This is the codified, automatable half of the
# ad-hoc curl scenarios sessions have been running by hand (ersatztv#299); it runs both locally and
# in CI (the `functional-e2e` job in .gitea/workflows/docker-build.yml).
#
# It does NOT boot the app — pair it with scripts/e2e-local.sh (which builds/launches and prints the
# CONFIG_DIR), or point it at any already-running instance:
#
# scripts/e2e-functional.sh [BASE_URL] [CONFIG_DIR]
#
# BASE_URL default http://localhost:8409 (the app's UI+API port; ETV_UI_PORT)
# CONFIG_DIR the instance's config folder — REQUIRED, so we can read its machine api key
# ($CONFIG_DIR/api.key). The /api surface is fail-closed (Api:RequireKeyForReads
# defaults true), so every /api call sends X-Api-Key.
#
# Scope (all curl-only, deterministic, no seeded media / ffmpeg / browser needed):
# 1. Legacy -> SPA redirect sweep (+ the /api,/artwork never-redirect exemption).
# 2. Auth / CSRF / security-stamp flow (setup-claim, read-gate, CSRF gate, login, logout+revoke).
# 3. Library scan lifecycle status-code contract (404 / 202 / scan-status).
# 4. Optimistic-concurrency If-Match / 412 round-trip.
#
# Deliberately OUT of scope for this first cut (need seeded media + the scanner subprocess, or a
# browser, to be deterministic — tracked as ersatztv#299 follow-ups):
# - the 409 "already scanning" re-trigger (racy without a long-running scan),
# - the playout-build lock 409,
# - the genuinely UI-interactive Playwright flows.
#
# Exit status: 0 if every assertion passed, 1 if any failed. Assertions keep running after a
# failure so one run reports the full picture.
set -uo pipefail
BASE_URL="${1:-http://localhost:8409}"
CONFIG_DIR="${2:-}"
if [ -z "$CONFIG_DIR" ]; then
echo "error: CONFIG_DIR (arg 2) is required — needed to read the machine api key ($CONFIG_DIR/api.key)." >&2
echo "usage: scripts/e2e-functional.sh [BASE_URL] CONFIG_DIR" >&2
exit 2
fi
KEY_FILE="$CONFIG_DIR/api.key"
if [ ! -f "$KEY_FILE" ]; then
echo "error: $KEY_FILE not found. Is the instance running with ETV_CONFIG_FOLDER=$CONFIG_DIR?" >&2
exit 2
fi
API_KEY="$(cat "$KEY_FILE")"
PASS=0
FAIL=0
FAILURES=()
# ---- assertion helpers -------------------------------------------------------------------------
# ok/bad: record a single assertion result.
ok() { PASS=$((PASS + 1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; }
bad() { FAIL=$((FAIL + 1)); FAILURES+=("$1"); printf ' \033[31mFAIL\033[0m %s\n' "$1"; }
# status_of METHOD URL [extra curl args...] -> prints the HTTP status code.
status_of() {
local method="$1" url="$2"; shift 2
curl -s -o /dev/null -w '%{http_code}' -X "$method" "$@" "$url"
}
# expect_status "desc" EXPECTED METHOD URL [extra curl args...]
expect_status() {
local desc="$1" expected="$2" method="$3" url="$4"; shift 4
local got; got="$(status_of "$method" "$url" "$@")"
if [ "$got" = "$expected" ]; then ok "$desc (=$expected)"; else bad "$desc — expected $expected, got $got [$method $url]"; fi
}
# api(): curl against the /api surface with the machine key + JSON content type.
api() { curl -s -H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' "$@"; }
# json_field FILE FIELD -> value of a top-level JSON field (via python3, always present here).
json_field() { python3 -c "import json,sys; print(json.load(open('$1')).get('$2',''))"; }
section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
echo "Functional E2E against $BASE_URL (config: $CONFIG_DIR)"
# ---- 1. Legacy -> SPA redirect sweep ------------------------------------------------------------
# The legacy branch 302s GET requests to their /app equivalent; /api, /artwork, /docs, /openapi are
# NEVER redirected (they 4xx from their own handlers/fallback). See ErsatzTV/LegacyUiRedirects.cs +
# Startup.cs MapFallback, and docs/blazor-route-parity.md.
section "Legacy -> SPA redirects"
# redirects_to "src" "expected /app location suffix"
redirects_to() {
local src="$1" want="$2"
local code loc
code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL$src")"
loc="$(curl -s -o /dev/null -w '%{redirect_url}' "$BASE_URL$src")"
if [ "$code" = "302" ] && [ "$loc" = "$BASE_URL$want" ]; then
ok "$src -> 302 $want"
else
bad "$src — expected 302 -> $BASE_URL$want, got $code -> ${loc:-<none>}"
fi
}
redirects_to "/" "/app"
redirects_to "/channels" "/app/channels"
redirects_to "/channels/5" "/app/edit-channel/5"
redirects_to "/settings/ffmpeg" "/app/settings/streaming"
redirects_to "/media/movies" "/app/media?kind=movies"
redirects_to "/media/sources/plex/3/libraries" "/app/libraries/plex/3/sync"
# The exempt prefixes must NOT redirect to /app — they 4xx from their own handlers (an unknown
# /api path 404s; /artwork rejects a non-artwork path 400). Assert only "did not 302 to /app".
never_redirects() {
local src="$1"
local code loc
code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL$src")"
loc="$(curl -s -o /dev/null -w '%{redirect_url}' "$BASE_URL$src")"
if [ "$code" != "302" ] && [ -z "$loc" ] && [ "${code:0:1}" = "4" ]; then
ok "$src -> not redirected (${code})"
else
bad "$src — expected a non-redirect 4xx, got $code -> ${loc:-<none>}"
fi
}
never_redirects "/api/does-not-exist"
never_redirects "/artwork/does-not-exist"
# ---- 2. Machine-key data-plane contracts (scan lifecycle + If-Match/412) ------------------------
# These use the machine key (X-Api-Key), which is independent of the session and does NOT claim the
# local admin — so the fresh-config setup-claim flow in section 3 still sees setupRequired:true.
section "Read gate (Api:RequireKeyForReads = true)"
expect_status "GET /api/v1/channels without a key -> 401" 401 GET "$BASE_URL/api/v1/channels"
expect_status "GET /api/v1/channels with the machine key -> 200" 200 GET "$BASE_URL/api/v1/channels" \
-H "X-Api-Key: $API_KEY"
section "Library scan lifecycle contract"
# Create an empty local library (no LibraryPath) — enough to exercise the enqueue/status contract
# without any media on disk (QueueLibraryScanByLibraryIdHandler never inspects paths).
LIB_JSON="$(mktemp)"
api -X POST "$BASE_URL/api/v1/libraries/local" -d '{"name":"E2E Functional Library"}' -o "$LIB_JSON" -w ''
LIB_ID="$(json_field "$LIB_JSON" id)"
if [ -n "$LIB_ID" ]; then ok "created local library id=$LIB_ID"; else bad "could not create local library (body: $(cat "$LIB_JSON"))"; fi
expect_status "POST /api/v1/libraries/$LIB_ID/scan -> 202 (queued)" 202 POST "$BASE_URL/api/v1/libraries/$LIB_ID/scan" \
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -d '{}'
expect_status "POST /api/v1/libraries/99999/scan -> 404 (unknown library)" 404 POST "$BASE_URL/api/v1/libraries/99999/scan" \
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -d '{}'
expect_status "GET /api/v1/libraries/scan-status -> 200" 200 GET "$BASE_URL/api/v1/libraries/scan-status" \
-H "X-Api-Key: $API_KEY"
section "Optimistic concurrency (If-Match / 412)"
# rerun-collections is the reference concurrency-aware editor: GET emits an ETag, PUT enforces
# If-Match. It needs an existing collection to target, so create one first.
COLL_JSON="$(mktemp)"
api -X POST "$BASE_URL/api/v1/collections" -d '{"name":"E2E Functional Collection"}' -o "$COLL_JSON" -w ''
COLL_ID="$(json_field "$COLL_JSON" id)"
RC_JSON="$(mktemp)"
api -X POST "$BASE_URL/api/v1/rerun-collections" -o "$RC_JSON" -w '' -d "{
\"name\":\"E2E Functional Rerun\",\"collectionType\":\"Collection\",\"selectedId\":$COLL_ID,
\"firstRunPlaybackOrder\":\"Chronological\",\"rerunPlaybackOrder\":\"Chronological\"}"
RC_ID="$(json_field "$RC_JSON" id)"
if [ -n "$RC_ID" ]; then ok "created rerun-collection id=$RC_ID (targets collection $COLL_ID)"; else bad "could not create rerun-collection (body: $(cat "$RC_JSON"))"; fi
# GET must surface the current version as a quoted-integer ETag.
ETAG="$(api -D - -o /dev/null "$BASE_URL/api/v1/rerun-collections/$RC_ID" | awk 'tolower($1)=="etag:"{print $2}' | tr -d '\r')"
if [ -n "$ETAG" ]; then ok "GET rerun-collection $RC_ID surfaces ETag $ETAG"; else bad "GET rerun-collection $RC_ID has no ETag header"; fi
RC_BODY="{\"name\":\"E2E Functional Rerun v2\",\"selectedId\":$COLL_ID,\"firstRunPlaybackOrder\":\"Chronological\",\"rerunPlaybackOrder\":\"Chronological\"}"
# A well-formed but non-current version must be rejected 412 (999999 can never be the live version here).
expect_status "PUT with a stale If-Match -> 412" 412 PUT "$BASE_URL/api/v1/rerun-collections/$RC_ID" \
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -H 'If-Match: "999999"' -d "$RC_BODY"
# The current ETag must be accepted.
expect_status "PUT with the current If-Match -> 200" 200 PUT "$BASE_URL/api/v1/rerun-collections/$RC_ID" \
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d "$RC_BODY"
# A malformed (non-integer) If-Match is a client error.
expect_status "PUT with a malformed If-Match -> 400" 400 PUT "$BASE_URL/api/v1/rerun-collections/$RC_ID" \
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -H 'If-Match: not-a-version' -d "$RC_BODY"
# ---- 3. Auth / CSRF / security-stamp flow -------------------------------------------------------
# Runs last: setup-claim is a one-shot on a fresh config and logout revokes the session. All prior
# sections used the machine key, which does not claim the admin, so setupRequired is still true here.
section "Auth: setup-claim, CSRF gate, login, logout + stamp revocation"
CFG_JSON="$(mktemp)"
curl -s "$BASE_URL/api/v1/auth/config" -o "$CFG_JSON"
if [ "$(json_field "$CFG_JSON" setupRequired)" = "True" ]; then ok "fresh config: setupRequired=true"; else bad "expected setupRequired=true on a fresh config (body: $(cat "$CFG_JSON"))"; fi
JAR="$(mktemp)"
# Claim the local admin (setup is CSRF-gated like every mutation).
expect_status "POST /api/v1/auth/setup -> 200 (claims admin, issues session)" 200 POST "$BASE_URL/api/v1/auth/setup" \
-c "$JAR" -H 'Content-Type: application/json' -H 'X-CSRF: 1' -d '{"username":"admin","password":"e2e-pass-1234"}'
curl -s "$BASE_URL/api/v1/auth/config" -o "$CFG_JSON"
if [ "$(json_field "$CFG_JSON" setupRequired)" = "False" ]; then ok "after claim: setupRequired=false"; else bad "expected setupRequired=false after claim (body: $(cat "$CFG_JSON"))"; fi
expect_status "POST /api/v1/auth/setup again -> 409 (already configured)" 409 POST "$BASE_URL/api/v1/auth/setup" \
-H 'Content-Type: application/json' -H 'X-CSRF: 1' -d '{"username":"admin2","password":"e2e-pass-1234"}'
# CSRF gate on a general /api session mutation: a POST with the session cookie but no X-CSRF is 403.
expect_status "session mutation without X-CSRF -> 403" 403 POST "$BASE_URL/api/v1/collections" \
-b "$JAR" -H 'Content-Type: application/json' -d '{"name":"should-be-csrf-blocked"}'
# Login: wrong password 401, correct 200 (fresh cookie jar).
expect_status "login with wrong password -> 401" 401 POST "$BASE_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' -H 'X-CSRF: 1' -d '{"username":"admin","password":"nope"}'
JAR2="$(mktemp)"
expect_status "login with correct password -> 200" 200 POST "$BASE_URL/api/v1/auth/login" \
-c "$JAR2" -H 'Content-Type: application/json' -H 'X-CSRF: 1' -d '{"username":"admin","password":"e2e-pass-1234"}'
# Logout is CSRF-gated; and after it, the same cookie is invalidated by the rotated security stamp.
expect_status "authed GET before logout -> 200" 200 GET "$BASE_URL/api/v1/auth/machine-key" -b "$JAR2"
expect_status "logout without X-CSRF -> 403" 403 POST "$BASE_URL/api/v1/auth/logout" -b "$JAR2"
expect_status "logout with X-CSRF -> 204" 204 POST "$BASE_URL/api/v1/auth/logout" -b "$JAR2" -H 'X-CSRF: 1'
expect_status "authed GET after logout (stamp revoked) -> 401" 401 GET "$BASE_URL/api/v1/auth/machine-key" -b "$JAR2"
# ---- summary ------------------------------------------------------------------------------------
printf '\n\033[1m== Summary ==\033[0m\n'
printf 'passed: %d failed: %d\n' "$PASS" "$FAIL"
if [ "$FAIL" -ne 0 ]; then
printf '\033[31mfailed assertions:\033[0m\n'
for f in "${FAILURES[@]}"; do printf ' - %s\n' "$f"; done
exit 1
fi
echo "all functional-E2E assertions passed."
+5 -2
View File
@@ -22,7 +22,10 @@
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
BUILD_DIR="$REPO_ROOT/ErsatzTV/bin/Debug/net10.0"
# ETV_BUILD_CONFIG selects which build output to launch (Debug for local dev; the CI functional-E2E
# job builds Release). Must match the `dotnet build --configuration` you ran beforehand.
BUILD_CONFIG="${ETV_BUILD_CONFIG:-Debug}"
BUILD_DIR="$REPO_ROOT/ErsatzTV/bin/$BUILD_CONFIG/net10.0"
PORT="${ETV_UI_PORT:-8409}"
READY_LINE="Done migrating search index"
TIMEOUT_SECS=120
@@ -31,7 +34,7 @@ CONFIG_DIR="${1:-$(mktemp -d)}"
mkdir -p "$CONFIG_DIR"
if [ ! -d "$BUILD_DIR" ]; then
echo "error: $BUILD_DIR does not exist — run 'dotnet build ErsatzTV.sln' first" >&2
echo "error: $BUILD_DIR does not exist — run 'dotnet build ErsatzTV.sln${BUILD_CONFIG:+ --configuration $BUILD_CONFIG}' first" >&2
exit 1
fi
+82 -1311
View File
File diff suppressed because it is too large Load Diff
+35 -1150
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -734,6 +734,9 @@ export interface components {
"subfolderCount": number;
"imageCount": number;
"durationSeconds": null | number;
};
"IptvSettingsResponseModel": {
"baseUrl": string;
};
"LanguageCodeResponseModel": {
"code": string;
@@ -1657,6 +1660,9 @@ export interface components {
};
"UpdateImageFolderDurationResponseModel": {
"durationSeconds": null | number;
};
"UpdateIptvSettingsRequest": {
"baseUrl": null | string;
};
"UpdateLocalLibraryPathRequest": {
"id": number;
+16
View File
@@ -4,6 +4,7 @@ import {
deleteResolution,
getFfmpegSettings,
getHdhrSettings,
getIptvSettings,
getLoggingSettings,
getPlayoutSettings,
getResolutions,
@@ -13,6 +14,7 @@ import {
messageFromSettingsError,
updateFfmpegSettings,
updateHdhrSettings,
updateIptvSettings,
updateLoggingSettings,
updatePlayoutSettings,
updateScannerSettings,
@@ -145,6 +147,20 @@ describe('settings API module', () => {
expect(JSON.parse(init?.body as string)).toMatchObject({ tunerCount: 2 });
});
it('fetches and updates IPTV settings', async () => {
const settings = { baseUrl: 'http://192.168.1.99:8409' };
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
await expect(getIptvSettings()).resolves.toMatchObject(settings);
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/iptv');
await updateIptvSettings(settings);
const [path, init] = lastFetchCall();
expect(path.toString()).toBe('/api/v1/settings/iptv');
expect(init?.method).toBe('PUT');
expect(JSON.parse(init?.body as string)).toMatchObject({ baseUrl: 'http://192.168.1.99:8409' });
});
it('fetches resolutions from GET /api/v1/settings/resolutions', async () => {
const resolutions = [{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 }];
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(resolutions));
+14 -1
View File
@@ -19,6 +19,8 @@ export type UiSettings = components['schemas']['UiSettingsResponseModel'];
export type UpdateUiSettingsRequest = components['schemas']['UpdateUiSettingsRequest'];
export type HdhrSettings = components['schemas']['HdhrSettingsResponseModel'];
export type UpdateHdhrSettingsRequest = components['schemas']['UpdateHdhrSettingsRequest'];
export type IptvSettings = components['schemas']['IptvSettingsResponseModel'];
export type UpdateIptvSettingsRequest = components['schemas']['UpdateIptvSettingsRequest'];
export type Resolution = components['schemas']['ResolutionResponseModel'];
export type CreateResolutionRequest = components['schemas']['CreateResolutionRequest'];
export type LogEventLevel = components['schemas']['LogEventLevel'];
@@ -83,6 +85,14 @@ export function updateHdhrSettings(body: UpdateHdhrSettingsRequest): Promise<Hdh
return request<HdhrSettings>('/api/v1/settings/hdhr', { body, method: 'PUT' });
}
export function getIptvSettings(): Promise<IptvSettings> {
return request<IptvSettings>('/api/v1/settings/iptv');
}
export function updateIptvSettings(body: UpdateIptvSettingsRequest): Promise<IptvSettings> {
return request<IptvSettings>('/api/v1/settings/iptv', { body, method: 'PUT' });
}
export function getResolutions(): Promise<Resolution[]> {
return request<Resolution[]>('/api/v1/settings/resolutions');
}
@@ -103,6 +113,7 @@ export interface SettingsScreenData {
logging: LoggingSettings;
ui: UiSettings;
hdhr: HdhrSettings;
iptv: IptvSettings;
resolutions: Resolution[];
ffmpegProfiles: FFmpegProfile[];
watermarks: Watermark[];
@@ -158,6 +169,7 @@ async function loadSettingsScreenData(): Promise<SettingsScreenData> {
getLoggingSettings(),
getUiSettings(),
getHdhrSettings(),
getIptvSettings(),
getResolutions()
]);
@@ -172,7 +184,7 @@ async function loadSettingsScreenData(): Promise<SettingsScreenData> {
getDashboardHealth()
] as const);
const [ffmpeg, playout, xmltv, scanner, logging, ui, hdhr, resolutions] = await tier1;
const [ffmpeg, playout, xmltv, scanner, logging, ui, hdhr, iptv, resolutions] = await tier1;
const [profilesResult, watermarksResult, fillerPresetsResult, mediaSourcesResult, versionResult, healthResult] =
await tier2;
@@ -189,6 +201,7 @@ async function loadSettingsScreenData(): Promise<SettingsScreenData> {
fillerPresets: fillerPresets.value,
health: health.value,
hdhr,
iptv,
logging,
mediaSources: mediaSources.value,
playout,
+364
View File
@@ -0,0 +1,364 @@
import {
Fragment,
useEffect,
useRef,
useState,
type KeyboardEvent,
type MouseEvent,
type ReactNode
} from 'react';
import {
Bell,
CalendarClock,
Cast,
Check,
ChevronDown,
CircleHelp,
ClipboardCopy,
Info,
ListVideo,
Plus,
Search
} from 'lucide-react';
import chicoryMarkUrl from '../../../design-system/assets/chicory-mark.svg';
import {
useChannelsQuery,
useDashboardVersionQuery,
type DashboardHealthQueryState
} from '../api';
import {
Button,
IconButton,
NavItem,
NavSection
} from '../components';
import {
designSystemThemes,
type DesignSystemThemeId
} from '../designSystem';
import { usePrimaryActionHandler } from '../primaryAction';
import { navigateToPath } from '../routing';
import { DashboardHealthSummary } from '../screens/DashboardScreen';
import { UnauthorizedBanner } from '../UnauthorizedBanner';
import { UserMenu } from '../UserMenu';
import {
routeById,
routeHref,
sidebarNavGroups,
type ScreenId,
type ScreenRoute
} from './routes';
type NavigateHandler = (route: ScreenRoute, event: MouseEvent) => void;
function SidebarNavGroup({
activeRoute,
ids,
onNavigate
}: {
activeRoute: ScreenRoute | null;
ids: readonly ScreenId[];
onNavigate: NavigateHandler;
}) {
return (
<>
{ids.map((id) => {
const route = routeById.get(id)!;
return (
<NavItem
key={route.id}
icon={route.icon}
label={route.label}
active={route.id === activeRoute?.id}
badge={route.badge}
badgeTone="warn"
href={routeHref(route)}
onClick={(event) => onNavigate(route, event)}
/>
);
})}
</>
);
}
function ThemeSwitcher({
theme,
onThemeChange
}: {
theme: DesignSystemThemeId;
onThemeChange: (theme: DesignSystemThemeId) => void;
}) {
return (
<section className="ctv-theme-switcher" aria-label="Theme">
<span>Theme</span>
<div>
{designSystemThemes.map(({ id, description, label }) => (
<button
key={id}
type="button"
className={`ctv-theme-swatch ctv-theme-swatch-${id}`}
aria-label={description}
aria-pressed={theme === id}
title={label}
onClick={() => onThemeChange(id)}
/>
))}
</div>
</section>
);
}
function Sidebar({
activeRoute,
healthState,
onNavigate
}: {
activeRoute: ScreenRoute | null;
healthState: DashboardHealthQueryState;
onNavigate: NavigateHandler;
}) {
return (
<aside className="ctv-sidebar">
<div className="ctv-brand">
<img src={chicoryMarkUrl} alt="ChicoryTV" />
<span className="ctv-brand-wordmark">
Chicory<span>TV</span>
</span>
</div>
<nav aria-label="Primary" className="ctv-nav">
{sidebarNavGroups.map(({ ids, label }) => (
<Fragment key={label ?? 'Primary'}>
{label ? <NavSection>{label}</NavSection> : null}
<SidebarNavGroup activeRoute={activeRoute} ids={ids} onNavigate={onNavigate} />
</Fragment>
))}
</nav>
<div className="ctv-sidebar-health">
<div>
<span>ChicoryTV</span>
<SidebarVersion />
</div>
<DashboardHealthSummary healthState={healthState} />
</div>
</aside>
);
}
function SidebarVersion() {
const versionQuery = useDashboardVersionQuery();
if (versionQuery.status === 'success') {
return <code>{versionQuery.version.appVersion ?? 'version unknown'}</code>;
}
if (versionQuery.status === 'error') {
return <code>version unavailable</code>;
}
return <code>loading version</code>;
}
function EndpointRow({ icon, label, url }: { icon: ReactNode; label: string; url: string }) {
const [copied, setCopied] = useState(false);
const copyUrl = () => {
void navigator.clipboard?.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
return (
<div className="ctv-connect-endpoint">
<span className="ctv-connect-endpoint-icon">{icon}</span>
<div>
<strong>{label}</strong>
<code>{url}</code>
</div>
<button type="button" onClick={copyUrl}>
{copied ? <Check aria-hidden="true" size={13} /> : <ClipboardCopy aria-hidden="true" size={13} />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
);
}
function ConnectMenu() {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const channelsQuery = useChannelsQuery();
const playlistUrl = `${window.location.origin}/iptv/channels.m3u`;
const guideUrl = `${window.location.origin}/iptv/xmltv.xml`;
useEffect(() => {
if (open) {
menuRef.current?.focus();
}
}, [open]);
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
const channelCountLabel = (() => {
if (channelsQuery.status === 'loading') {
return 'Loading channels';
}
if (channelsQuery.status === 'error') {
return 'Channels unavailable';
}
const count = channelsQuery.channels.length;
return `${count} ${count === 1 ? 'channel' : 'channels'}`;
})();
return (
<div className="ctv-connect">
<button
type="button"
className="ctv-connect-button"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<Cast aria-hidden="true" size={15} />
Connect
<ChevronDown aria-hidden="true" size={13} />
</button>
{open && (
<>
<button
type="button"
className="ctv-connect-dismiss"
aria-label="Close Connect menu"
onClick={() => setOpen(false)}
/>
<div
className="ctv-connect-menu"
role="dialog"
aria-label="Connect a player"
tabIndex={-1}
ref={menuRef}
onKeyDown={onMenuKeyDown}
>
<div className="ctv-connect-header">
<strong>Connect a player</strong>
<span>{channelCountLabel}</span>
</div>
<EndpointRow
icon={<ListVideo aria-hidden="true" size={15} />}
label="M3U playlist"
url={playlistUrl}
/>
<EndpointRow
icon={<CalendarClock aria-hidden="true" size={15} />}
label="XMLTV guide"
url={guideUrl}
/>
<p>
<Info aria-hidden="true" size={13} />
Works with any IPTV player.
</p>
</div>
</>
)}
</div>
);
}
function TopBar({ route }: { route: ScreenRoute | null }) {
const title = route?.title ?? 'Page not found';
const kicker = route?.kicker ?? 'Unknown route';
const primaryAction = route?.primaryAction ?? '';
const primaryActionHandler = usePrimaryActionHandler(route?.id ?? '');
const [searchValue, setSearchValue] = useState('');
return (
<header className="ctv-topbar">
<div className="ctv-topbar-title">
<p>{kicker}</p>
<h1>{title}</h1>
</div>
<form
className="ctv-topbar-search"
onSubmit={(event) => {
event.preventDefault();
const trimmed = searchValue.trim();
if (trimmed) {
navigateToPath(`/app/search?query=${encodeURIComponent(trimmed)}`);
}
}}
role="search"
>
<Search aria-hidden="true" size={15} />
<input
aria-label="Search"
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search movies, shows, music..."
type="search"
value={searchValue}
/>
</form>
<div className="ctv-topbar-spacer" />
<div className="ctv-topbar-tools">
<UserMenu />
<ConnectMenu />
<span className="ctv-topbar-divider" />
<IconButton title="Documentation" size="sm">
<CircleHelp aria-hidden="true" size={17} />
</IconButton>
<IconButton title="Notifications" size="sm">
<Bell aria-hidden="true" size={17} />
</IconButton>
<span className="ctv-avatar">TB</span>
</div>
{route && primaryAction && primaryActionHandler ? (
<Button
startIcon={<Plus aria-hidden="true" size={15} />}
onClick={primaryActionHandler}
>
{primaryAction}
</Button>
) : null}
</header>
);
}
export function AppShell({
children,
healthState,
onNavigate,
onThemeChange,
route,
theme
}: {
children: ReactNode;
healthState: DashboardHealthQueryState;
onNavigate: NavigateHandler;
onThemeChange: (theme: DesignSystemThemeId) => void;
route: ScreenRoute | null;
theme: DesignSystemThemeId;
}) {
return (
<div className="ctv-app-shell">
<Sidebar activeRoute={route} healthState={healthState} onNavigate={onNavigate} />
<div className="ctv-shell-body">
<TopBar route={route} />
<UnauthorizedBanner />
<main className="ctv-main">
<ThemeSwitcher theme={theme} onThemeChange={onThemeChange} />
{children}
</main>
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { useEffect, useState } from 'react';
import { Info } from 'lucide-react';
import type { DashboardHealthQueryState } from '../api';
import { ChannelBuilderScreen } from '../builder/ChannelBuilder';
import { Card } from '../components';
import {
parseLibrariesSubRoute,
parseMediaSubRoute
} from '../routing';
import { ApiKeyScreen } from '../screens/ApiKeyScreen';
import { BlockPlayoutTroubleshootingScreen } from '../screens/BlockPlayoutTroubleshootingScreen';
import { BlocksScreen } from '../screens/BlocksScreen';
import { ChannelEditScreen } from '../screens/ChannelEditScreen';
import { ChannelsScreen } from '../screens/ChannelsScreen';
import { CollectionsScreen } from '../screens/CollectionsScreen';
import { DashboardScreen } from '../screens/DashboardScreen';
import { DecoTemplatesScreen } from '../screens/DecoTemplatesScreen';
import { DecosScreen } from '../screens/DecosScreen';
import { FFmpegProfilesScreen } from '../screens/FFmpegProfilesScreen';
import { FillerPresetsScreen } from '../screens/FillerPresetsScreen';
import { GuideScreen } from '../screens/GuideScreen';
import { ImageBrowserScreen } from '../screens/ImageBrowserScreen';
import { LibrariesScreen } from '../screens/LibrariesScreen';
import { LocalLibraryEditScreen } from '../screens/LocalLibraryEditScreen';
import { LogsScreen } from '../screens/LogsScreen';
import { MediaBrowseScreen } from '../screens/MediaBrowseScreen';
import {
ArtistDetailScreen,
MovieDetailScreen,
SeasonDetailScreen,
ShowDetailScreen
} from '../screens/MediaDetailScreen';
import { MultiCollectionsScreen } from '../screens/MultiCollectionsScreen';
import { PathReplacementsEditScreen } from '../screens/PathReplacementsEditScreen';
import { PlaybackTroubleshootingScreen } from '../screens/PlaybackTroubleshootingScreen';
import { PlexSourceScreen } from '../screens/PlexSourceScreen';
import { PlaylistsScreen } from '../screens/PlaylistsScreen';
import { PlayoutsRouteScreen } from '../screens/PlayoutsScreen';
import { RemoteConnectionEditScreen } from '../screens/RemoteConnectionEditScreen';
import { RemoteLibrariesEditScreen } from '../screens/RemoteLibrariesEditScreen';
import { RemoteSourceScreen } from '../screens/RemoteSourceScreen';
import { RerunCollectionsScreen } from '../screens/RerunCollectionsScreen';
import { SchedulesScreen } from '../screens/SchedulesScreen';
import { SearchScreen } from '../screens/SearchScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { TemplatesScreen } from '../screens/TemplatesScreen';
import { TraktListsScreen } from '../screens/TraktListsScreen';
import { TrashScreen } from '../screens/TrashScreen';
import { TroubleshootingScreen } from '../screens/TroubleshootingScreen';
import { WatermarksScreen } from '../screens/WatermarksScreen';
import { YamlValidatorScreen } from '../screens/YamlValidatorScreen';
import {
normalizeAppPath,
type ScreenId,
type ScreenRoute
} from './routes';
function NotFoundScreen() {
const pathname = normalizeAppPath(window.location.pathname);
return (
<div className="ctv-screen-stack">
<Card title={<h2>Unknown app route</h2>} subtitle="This URL does not match a ChicoryTV screen.">
<div className="ctv-placeholder-layout">
<div className="ctv-placeholder-icon"><Info aria-hidden="true" size={20} /></div>
<div>
<h3>Requested path</h3>
<p>{pathname}</p>
</div>
</div>
</Card>
</div>
);
}
// The Media nav entry owns detail sub-pages (/app/media/{movies|shows|seasons|artists}/{id}) and the
// image folder browser (/app/media/images/browser). Like PlayoutsRouteScreen, this wrapper tracks
// pathname locally + listens for popstate, because routeFromLocation() returns the SAME 'media'
// ScreenRoute object for the base grid and every sub-path (Object.is bails App's setActiveRoute).
export function MediaRouteScreen() {
const [pathname, setPathname] = useState(() => window.location.pathname);
const [search, setSearch] = useState(() => window.location.search);
useEffect(() => {
const onPopState = () => {
setPathname(window.location.pathname);
setSearch(window.location.search);
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const sub = parseMediaSubRoute(pathname);
if (sub?.kind === 'movie') {
return <MovieDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'show') {
return <ShowDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'season') {
return <SeasonDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'artist') {
return <ArtistDetailScreen key={pathname} id={sub.id} />;
}
if (sub?.kind === 'images') {
return <ImageBrowserScreen key={pathname} />;
}
return <MediaBrowseScreen key={search} />;
}
// The Libraries nav entry owns the media-source editor sub-paths. CRITICAL (design §D.2, finding 4):
// unlike PlayoutsRouteScreen/MediaRouteScreen this wrapper does NOT register its own `popstate`
// listener. Its editor sub-screens register a dirty guard, so App must approve the path first and
// pass it down. This wrapper never reads a raw navigation event.
export function LibrariesRouteScreen({ subPath }: { subPath: string }) {
const sub = parseLibrariesSubRoute(subPath);
if (sub === null) {
return <LibrariesScreen />;
}
switch (sub.kind) {
case 'local-new':
case 'local-edit':
return <LocalLibraryEditScreen key={subPath} />;
case 'remote-source':
return sub.family === 'plex'
? <PlexSourceScreen key={subPath} />
: <RemoteSourceScreen key={subPath} family={sub.family} />;
case 'remote-connection':
return <RemoteConnectionEditScreen key={subPath} family={sub.family} />;
case 'remote-libraries':
return <RemoteLibrariesEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
case 'remote-path-replacements':
return <PathReplacementsEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
}
}
function assertNeverScreen(screenId: never): never {
throw new Error(`Unhandled screen route: ${String(screenId)}`);
}
export function ScreenContent({
healthState,
librariesSubPath,
route
}: {
healthState: DashboardHealthQueryState;
librariesSubPath: string;
route: ScreenRoute | null;
}) {
if (route === null) {
return <NotFoundScreen />;
}
const routeId: ScreenId = route.id;
switch (routeId) {
case 'dashboard':
return <DashboardScreen healthState={healthState} />;
case 'channels':
return <ChannelsScreen />;
case 'builder':
return <ChannelBuilderScreen />;
case 'editChannel':
return <ChannelEditScreen key={window.location.pathname} />;
case 'guide':
return <GuideScreen />;
case 'schedules':
return <SchedulesScreen />;
case 'blocks':
return <BlocksScreen key={window.location.pathname} />;
case 'templates':
return <TemplatesScreen key={window.location.pathname} />;
case 'decos':
return <DecosScreen key={window.location.pathname} />;
case 'decoTemplates':
return <DecoTemplatesScreen key={window.location.pathname} />;
case 'playouts':
return <PlayoutsRouteScreen />;
case 'libraries':
return <LibrariesRouteScreen subPath={librariesSubPath} />;
case 'settings':
return <SettingsScreen />;
case 'apiKey':
return <ApiKeyScreen />;
case 'media':
return <MediaRouteScreen />;
case 'search':
return <SearchScreen key={window.location.search} />;
case 'trash':
return <TrashScreen />;
case 'collections':
return <CollectionsScreen />;
case 'multiCollections':
return <MultiCollectionsScreen />;
case 'rerunCollections':
return <RerunCollectionsScreen />;
case 'playlists':
return <PlaylistsScreen />;
case 'traktLists':
return <TraktListsScreen key={window.location.pathname} />;
case 'fillerPresets':
return <FillerPresetsScreen />;
case 'ffmpegProfiles':
return <FFmpegProfilesScreen />;
case 'watermarks':
return <WatermarksScreen />;
case 'logs':
return <LogsScreen />;
case 'troubleshooting':
return <TroubleshootingScreen />;
case 'blockPlayoutTroubleshooting':
return <BlockPlayoutTroubleshootingScreen />;
case 'playbackTroubleshooting':
return <PlaybackTroubleshootingScreen key={window.location.search} />;
case 'yamlValidator':
return <YamlValidatorScreen />;
default:
return assertNeverScreen(routeId);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
routeById,
routeFromLocation
} from './routes';
describe('app route matching', () => {
afterEach(() => {
window.history.replaceState(null, '', '/app');
});
it('returns one stable route object across sibling allowSubPaths locations', () => {
const playoutsRoute = routeById.get('playouts');
window.history.replaceState(null, '', '/app/playouts/20/alternate-schedules');
const alternateSchedules = routeFromLocation();
window.history.replaceState(null, '', '/app/playouts/20/templates');
const templates = routeFromLocation();
expect(alternateSchedules).toBe(playoutsRoute);
expect(templates).toBe(playoutsRoute);
expect(templates).toBe(alternateSchedules);
});
it('ignores the query string while preserving the route object', () => {
const searchRoute = routeById.get('search');
window.history.replaceState(null, '', '/app/search?query=alpha');
const firstQuery = routeFromLocation();
window.history.replaceState(null, '', '/app/search?query=beta');
const secondQuery = routeFromLocation();
expect(firstQuery).toBe(searchRoute);
expect(secondQuery).toBe(searchRoute);
});
it('normalizes a trailing slash and rejects an unowned path', () => {
window.history.replaceState(null, '', '/app/channels/');
expect(routeFromLocation()).toBe(routeById.get('channels'));
window.history.replaceState(null, '', '/app/channels/42');
expect(routeFromLocation()).toBeNull();
});
});
+524
View File
@@ -0,0 +1,524 @@
import type { ReactNode } from 'react';
import {
Boxes,
Palette,
CalendarClock,
CalendarDays,
CalendarRange,
Clapperboard,
Film,
FolderTree,
KeyRound,
Layers,
LayoutDashboard,
LayoutGrid,
Library,
Link2,
ListChecks,
ListMusic,
ListVideo,
Plus,
Repeat,
ScrollText,
Search,
Settings,
SlidersHorizontal,
Stamp,
Stethoscope,
Trash2,
Tv
} from 'lucide-react';
export type ScreenId =
| 'dashboard'
| 'channels'
| 'builder'
| 'editChannel'
| 'guide'
| 'schedules'
| 'blocks'
| 'templates'
| 'decos'
| 'decoTemplates'
| 'playouts'
| 'media'
| 'search'
| 'trash'
| 'collections'
| 'multiCollections'
| 'rerunCollections'
| 'playlists'
| 'fillerPresets'
| 'libraries'
| 'traktLists'
| 'ffmpegProfiles'
| 'watermarks'
| 'settings'
| 'apiKey'
| 'logs'
| 'troubleshooting'
| 'blockPlayoutTroubleshooting'
| 'playbackTroubleshooting'
| 'yamlValidator';
export interface ScreenRoute {
id: ScreenId;
path: string;
label: string;
title: string;
kicker: string;
description: string;
icon: ReactNode;
primaryAction: string;
placeholder: string;
badge?: number;
// Screens that own linkable sub-sections (e.g. /app/settings/streaming) opt in here;
// the sub-path is otherwise unowned and 404s (see routeFromLocation).
allowSubPaths?: boolean;
}
// This is the one stable route-object table for the application. routeFromLocation returns
// references from this array so allowSubPaths navigations intentionally preserve object identity.
export const routes: ScreenRoute[] = [
{
id: 'dashboard',
path: '/app',
label: 'Dashboard',
title: 'Dashboard',
kicker: 'Overview',
description: 'On-air status, health checks, and recent server activity.',
icon: <LayoutDashboard aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Dashboard workspace'
},
{
id: 'channels',
path: '/app/channels',
label: 'Channels',
title: 'Channels',
kicker: 'Lineup',
description: 'Dense channel table, ordering tools, bulk actions, and playback status.',
icon: <Tv aria-hidden="true" size={16} />,
primaryAction: 'Add Channel',
placeholder: 'Channel list workspace'
},
{
id: 'builder',
path: '/app/new-channel',
label: 'New Channel',
title: 'New Channel',
kicker: 'Builder',
description: 'Library-to-lineup channel creation flow with template-backed defaults.',
icon: <Plus aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Channel builder workspace'
},
{
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
// sub-paths like /app/edit-channel/5.
id: 'editChannel',
path: '/app/edit-channel',
label: 'Edit Channel',
title: 'Edit Channel',
kicker: 'Channel',
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
icon: <Tv aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Channel editor workspace',
allowSubPaths: true
},
{
id: 'guide',
path: '/app/guide',
label: 'Guide',
title: 'Guide',
kicker: 'EPG',
description: 'Timeline grid with channel rows, programme blocks, and a live now marker.',
icon: <LayoutGrid aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'EPG grid workspace'
},
{
id: 'schedules',
path: '/app/schedules',
label: 'Schedules',
title: 'Schedules',
kicker: 'Programming',
description: 'Two-pane schedule editor with drag ordering and progressive details.',
icon: <CalendarClock aria-hidden="true" size={16} />,
primaryAction: 'Add Schedule',
placeholder: 'Schedule editor workspace'
},
{
// The editor lives at a sub-path (/app/blocks/{id}); BlocksScreen owns parsing the id suffix.
id: 'blocks',
path: '/app/blocks',
label: 'Blocks',
title: 'Blocks',
kicker: 'Programming',
description: 'Group blocks and edit their items, durations, and playout preview.',
icon: <Boxes aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Blocks workspace',
allowSubPaths: true
},
{
// The editor lives at a sub-path (/app/templates/{id}); TemplatesScreen owns parsing the id suffix.
id: 'templates',
path: '/app/templates',
label: 'Templates',
title: 'Templates',
kicker: 'Programming',
description: 'Group templates and assign blocks to times of day.',
icon: <CalendarDays aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Templates workspace',
allowSubPaths: true
},
{
// The editor lives at a sub-path (/app/decos/{id}); DecosScreen owns parsing the id suffix.
id: 'decos',
path: '/app/decos',
label: 'Decos',
title: 'Decos',
kicker: 'Programming',
description: 'Group decos and configure watermark, graphics, break content, and filler overrides.',
icon: <Palette aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Decos workspace',
allowSubPaths: true
},
{
// The editor lives at a sub-path (/app/deco-templates/{id}); DecoTemplatesScreen owns parsing the id suffix.
id: 'decoTemplates',
path: '/app/deco-templates',
label: 'Deco Templates',
title: 'Deco Templates',
kicker: 'Programming',
description: 'Group deco templates and assign decos to times of day.',
icon: <CalendarRange aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Deco templates workspace',
allowSubPaths: true
},
{
// The alternate-schedules / templates editors live at sub-paths
// (/app/playouts/{id}/alternate-schedules, /app/playouts/{id}/templates).
id: 'playouts',
path: '/app/playouts',
label: 'Playouts',
title: 'Playouts',
kicker: 'Runtime',
description: 'Playout state, reset controls, timeline diagnostics, and build warnings.',
icon: <ListVideo aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Playouts workspace',
allowSubPaths: true
},
{
id: 'media',
path: '/app/media',
label: 'Browse',
title: 'Browse Media',
kicker: 'Media',
description: 'Browse indexed movies, shows, episodes, music and more by kind.',
icon: <Clapperboard aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Media browse workspace',
allowSubPaths: true
},
{
id: 'search',
path: '/app/search',
label: 'Search',
title: 'Search',
kicker: 'Media',
description: 'Search across every media kind at once.',
icon: <Search aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Search workspace',
allowSubPaths: true
},
{
id: 'trash',
path: '/app/trash',
label: 'Trash',
title: 'Trash',
kicker: 'Media',
description: 'Library items whose files have gone missing.',
icon: <Trash2 aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Trash workspace'
},
{
id: 'collections',
path: '/app/collections',
label: 'Collections',
title: 'Collections',
kicker: 'Media',
description: 'Manual, smart, multi, playlist, search, and rerun collection management.',
icon: <FolderTree aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Collections workspace'
},
{
id: 'multiCollections',
path: '/app/multi-collections',
label: 'Multi-Collections',
title: 'Multi-Collections',
kicker: 'Media',
description: 'Group manual and smart collections into a single schedulable collection.',
icon: <Layers aria-hidden="true" size={16} />,
primaryAction: 'Add Multi-Collection',
placeholder: 'Multi-collections workspace'
},
{
id: 'rerunCollections',
path: '/app/rerun-collections',
label: 'Rerun Collections',
title: 'Rerun Collections',
kicker: 'Media',
description: 'A collection with distinct first-run and rerun playback orders.',
icon: <Repeat aria-hidden="true" size={16} />,
primaryAction: 'Add Rerun Collection',
placeholder: 'Rerun collections workspace'
},
{
id: 'playlists',
path: '/app/playlists',
label: 'Playlists',
title: 'Playlists',
kicker: 'Media',
description: 'Group, order, and preview playlist items scheduled into channels.',
icon: <ListMusic aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Playlists workspace'
},
{
id: 'fillerPresets',
path: '/app/filler-presets',
label: 'Filler Presets',
title: 'Filler Presets',
kicker: 'Media',
description: 'Pre/mid/post-roll, tail and fallback filler presets used by schedules.',
icon: <Film aria-hidden="true" size={16} />,
primaryAction: 'Add Filler Preset',
placeholder: 'Filler presets workspace',
allowSubPaths: true
},
{
// The media-source editors live at sub-paths (/app/libraries/local/{id}, /app/libraries/plex,
// /app/libraries/{jellyfin|emby}/connection, .../{id}/sync, .../{id}/path-replacements).
// Unlike other allowSubPaths screens, the libraries wrapper does NOT self-listen for popstate:
// its editors register a dirty guard, so App owns pathname/popstate and passes the approved
// sub-path down (see LibrariesRouteScreen + spa-conventions §8, design §D.2).
id: 'libraries',
path: '/app/libraries',
label: 'Libraries',
title: 'Libraries',
kicker: 'Sources',
description: 'Local, Plex, Jellyfin, and Emby media source monitoring.',
icon: <Library aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Libraries workspace',
allowSubPaths: true
},
{
// The editor lives at a sub-path (/app/trakt-lists/{id}); the screen owns parsing the
// {id} suffix itself (see TraktListsScreen), same pattern as editChannel/settings.
id: 'traktLists',
path: '/app/trakt-lists',
label: 'Trakt Lists',
title: 'Trakt Lists',
kicker: 'Media',
description: 'Add, match, and manage Trakt list imports.',
icon: <Link2 aria-hidden="true" size={16} />,
primaryAction: 'Add Trakt List',
placeholder: 'Trakt lists workspace',
allowSubPaths: true
},
{
id: 'ffmpegProfiles',
path: '/app/ffmpeg-profiles',
label: 'FFmpeg Profiles',
title: 'FFmpeg Profiles',
kicker: 'System',
description: 'Transcoding profiles: resolution, video/audio formats, hardware acceleration.',
icon: <SlidersHorizontal aria-hidden="true" size={16} />,
primaryAction: 'Add Profile',
placeholder: 'FFmpeg profiles workspace',
allowSubPaths: true
},
{
id: 'watermarks',
path: '/app/watermarks',
label: 'Watermarks',
title: 'Watermarks',
kicker: 'System',
description: 'Channel watermark overlays: image, position, size and opacity.',
icon: <Stamp aria-hidden="true" size={16} />,
primaryAction: 'Add Watermark',
placeholder: 'Watermarks workspace',
allowSubPaths: true
},
{
id: 'settings',
path: '/app/settings',
label: 'Settings',
title: 'Settings',
kicker: 'System',
description: 'Server configuration, access keys, FFmpeg profiles, and UI preferences.',
icon: <Settings aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Settings workspace',
allowSubPaths: true
},
{
// Since #295 the browser authenticates by session cookie; this screen only *displays* the
// server machine key (for MCP / external REST clients) and, for local accounts, changes the
// admin password — there is no key to "save" here (see spa-conventions §5e).
id: 'apiKey',
path: '/app/api-key',
label: 'API Key',
title: 'API Key',
kicker: 'System',
description: 'View the machine key for MCP and external REST clients, and change the local admin password.',
icon: <KeyRound aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'API key workspace'
},
{
id: 'logs',
path: '/app/logs',
label: 'Logs',
title: 'Logs',
kicker: 'System',
description: 'Recent server log entries with level and free-text filtering.',
icon: <ScrollText aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Logs workspace'
},
{
id: 'troubleshooting',
path: '/app/troubleshooting',
label: 'Troubleshooting',
title: 'Troubleshooting',
kicker: 'System',
description: 'Version, environment, hardware and FFmpeg capability diagnostics.',
icon: <Stethoscope aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Troubleshooting workspace'
},
{
id: 'blockPlayoutTroubleshooting',
path: '/app/troubleshooting/blocks',
label: 'Block History',
title: 'Block Playout Troubleshooting',
kicker: 'System',
description: 'Inspect a block playout, its blocks, and each blocks scheduling history.',
icon: <Boxes aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Block playout troubleshooting workspace'
},
{
id: 'playbackTroubleshooting',
path: '/app/troubleshooting/playback',
label: 'Playback Troubleshooting',
title: 'Playback Troubleshooting',
kicker: 'System',
description: 'Preview channel or media-item playback with a chosen FFmpeg profile and inspect the transcode logs.',
icon: <Stethoscope aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Playback troubleshooting workspace'
},
{
id: 'yamlValidator',
path: '/app/troubleshooting/yaml',
label: 'Schedule Validator',
title: 'Sequential Schedule Validator',
kicker: 'System',
description: 'Validate a sequential-schedule YAML document against the schema.',
icon: <ListChecks aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Sequential schedule validator workspace'
}
];
export const routeById = new Map(routes.map((route) => [route.id, route]));
export interface SidebarNavGroupDefinition {
label?: string;
ids: readonly ScreenId[];
}
export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
{
ids: [
'dashboard',
'channels',
'builder',
'guide',
'schedules',
'blocks',
'templates',
'decos',
'decoTemplates',
'playouts'
]
},
{
label: 'Media',
ids: [
'media',
'search',
'trash',
'collections',
'multiCollections',
'rerunCollections',
'playlists',
'fillerPresets',
'libraries',
'traktLists'
]
},
{
label: 'System',
ids: [
'settings',
'apiKey',
'logs',
'troubleshooting',
'blockPlayoutTroubleshooting',
'yamlValidator',
'ffmpegProfiles',
'watermarks'
]
}
];
export function normalizeAppPath(pathname: string): string {
return pathname.replace(/\/+$/, '') || '/app';
}
export function routeFromLocation(): ScreenRoute | null {
const pathname = normalizeAppPath(window.location.pathname);
const exactMatch = routes.find((route) => route.path === pathname);
if (exactMatch) {
return exactMatch;
}
// Fall back to a sub-path of a route that has explicitly opted into linkable
// sub-sections (e.g. /app/settings/streaming) - the screen owns parsing the
// suffix itself (see SettingsScreen).
const prefixMatches = routes
.filter((route) => route.allowSubPaths && pathname.startsWith(`${route.path}/`))
.sort((a, b) => b.path.length - a.path.length);
return prefixMatches[0] ?? null;
}
export function routeHref(route: ScreenRoute): string {
return route.id === 'dashboard' ? '/app' : route.path;
}
+796 -18
View File
@@ -1,5 +1,6 @@
import { cleanup, render, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyDesignSystemTheme } from '../designSystem';
import { ChannelBuilderScreen } from './ChannelBuilder';
function jsonResponse(body: unknown, status = 200): Response {
@@ -9,42 +10,208 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
// Mocks every endpoint the builder loads on mount so it can render its library
// browser, plus /api/v1/library/browse which the browse hook fans out across.
function mockBuilderApi() {
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = input.toString();
// ---- Channel Builder (#89) fixtures ----------------------------------------
// The API serializes with Newtonsoft NullValueHandling.Ignore: null-valued
// members are omitted from the wire entirely. Fixtures mirror that by
// stripping null/undefined keys after overrides are applied.
function omitNullKeys(fixture: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(fixture).filter(([, value]) => value != null));
}
if (url.startsWith('/api/v1/library/browse')) {
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
// ChannelResponseModel
function channelSummary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
categories: '',
ffmpegProfile: '1080p H.264',
group: 'ChicoryTV',
id: 1,
isEnabled: true,
language: 'English',
name: 'Movies',
number: '5',
showInEpg: true,
sortNumber: 5,
streamingMode: 'HttpLiveStreamingSegmenter',
...overrides
};
}
// ChannelTemplateResponseModel — nullable members (filler/watermark ids,
// preferred languages, streamSelector, musicVideoCreditsTemplate) are null on
// the Standard template and therefore absent from the serialized payload.
function channelTemplate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return omitNullKeys({
description: 'General-purpose 1080p H.264.',
ffmpegProfileId: 100,
fixedStartTimeBehavior: 'Strict',
id: 10,
idleBehavior: 'StopOnDisconnect',
isDefault: true,
isSystem: true,
musicVideoCreditsMode: 'None',
name: 'Standard',
playoutMode: 'Continuous',
playoutSource: 'Generated',
randomStartPoint: false,
shuffleScheduleItems: false,
songVideoMode: 'Default',
streamSelectorMode: 'Default',
streamingMode: 'HttpLiveStreamingSegmenter',
subtitleMode: 'None',
transcodeMode: 'OnDemand',
...overrides
});
}
// LibraryBrowseItemResponseModel - Newtonsoft omits null fields, so exactly one
// typed id is populated and nullable metadata (duration/itemCount) is absent
// unless the caller supplies it.
function browseItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return omitNullKeys({
artwork: '',
collectionType: 'TelevisionShow',
id: 1,
libraryId: 31,
libraryName: 'Cartoons',
mediaItemId: 1,
mediaType: 'TelevisionShow',
title: 'Looney Tunes',
...overrides
});
}
// FFmpegFullProfileResponseModel (only id/name are read by the builder)
function ffmpegProfile(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { id: 100, name: '1080p H.264', ...overrides };
}
// Read the JSON body captured for a mutating request to `path`.
function requestBodyFor(path: string): Record<string, unknown> {
const call = vi
.mocked(window.fetch)
.mock.calls.find(([input, init]) => input.toString() === path && init?.body != null);
if (!call) {
throw new Error(`no request captured for ${path}`);
}
return JSON.parse(call[1]?.body as string) as Record<string, unknown>;
}
function mockBuilderApi({
artworkUploadFailure = null,
browseHandler = null,
browseItems = [],
channels = [],
channelTemplates = [],
createTemplateResponse = null,
defaultChannelTemplate = null,
ffmpegProfiles = [],
fromLineupFailure = null,
fromLineupResponse = { channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 }
}: {
artworkUploadFailure?: { status: number } | null;
browseHandler?: ((search: URLSearchParams) => { page: unknown[]; totalCount: number }) | null;
browseItems?: unknown[];
channels?: unknown[];
channelTemplates?: unknown[];
createTemplateResponse?: unknown;
defaultChannelTemplate?: unknown;
ffmpegProfiles?: unknown[];
fromLineupFailure?: { detail?: string; status?: number; title?: string } | null;
fromLineupResponse?: unknown;
} = {}) {
let currentChannelTemplates = channelTemplates;
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const path = input.toString();
if (path === '/api/v1/channels') {
return Promise.resolve(jsonResponse(channels));
}
if (url === '/api/v1/channel-templates/default') {
// Builder treats a 404 here as "no default template".
return Promise.resolve(jsonResponse({ status: 404, title: 'Not Found' }, 404));
if (path === '/api/v1/channels/from-lineup') {
if (fromLineupFailure) {
return Promise.resolve(jsonResponse(fromLineupFailure, fromLineupFailure.status ?? 422));
}
return Promise.resolve(jsonResponse(fromLineupResponse, 201));
}
// channels, channel-templates, ffmpeg/profiles, filler-presets, watermarks, media-sources
return Promise.resolve(jsonResponse([]));
if (path.startsWith('/api/v1/library/browse')) {
const search = new URL(path, window.location.origin).searchParams;
if (browseHandler) {
return Promise.resolve(jsonResponse(browseHandler(search)));
}
const mediaType = search.get('mediaType');
const page = mediaType
? browseItems.filter((item) => (item as { mediaType?: string }).mediaType === mediaType)
: browseItems;
return Promise.resolve(jsonResponse({ page, totalCount: page.length }));
}
if (path === '/api/v1/channel-templates') {
if ((init?.method ?? 'GET') === 'POST') {
const created = createTemplateResponse ?? channelTemplate();
currentChannelTemplates = [...currentChannelTemplates, created];
return Promise.resolve(jsonResponse(created, 201));
}
return Promise.resolve(jsonResponse(currentChannelTemplates));
}
if (path === '/api/v1/channel-templates/default') {
return Promise.resolve(
defaultChannelTemplate ? jsonResponse(defaultChannelTemplate) : jsonResponse(null, 404)
);
}
if (path === '/api/v1/ffmpeg/profiles') {
return Promise.resolve(jsonResponse(ffmpegProfiles));
}
if (path.startsWith('/api/v1/filler-presets')) {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/watermarks' || path === '/api/v1/media-sources') {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/artwork/uploads') {
if (artworkUploadFailure) {
return Promise.resolve(new Response(null, { status: artworkUploadFailure.status }));
}
return Promise.resolve(
jsonResponse({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' }, 201)
);
}
return Promise.resolve(jsonResponse(null, 404));
});
}
function browseMediaTypes(fetchMock: ReturnType<typeof mockBuilderApi>): string[] {
return fetchMock.mock.calls
.map(([u]) => u.toString())
.filter((u) => u.startsWith('/api/v1/library/browse'))
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
.map(([url]) => url.toString())
.filter((url) => url.startsWith('/api/v1/library/browse'))
.map((url) => new URL(url, 'http://localhost').searchParams.get('mediaType'))
.filter((mediaType): mediaType is string => mediaType != null && mediaType !== '');
}
describe('ChannelBuilder library browse', () => {
describe('Channel Builder (#89)', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
window.history.replaceState(null, '', '/');
});
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app/new-channel');
vi.restoreAllMocks();
});
it('fans out over movies/shows/artists and never requests TelevisionSeason', async () => {
@@ -52,7 +219,6 @@ describe('ChannelBuilder library browse', () => {
render(<ChannelBuilderScreen />);
// Wait until the initial library browse fan-out has fired.
await waitFor(() => {
expect(browseMediaTypes(fetchMock).length).toBeGreaterThan(0);
});
@@ -61,4 +227,616 @@ describe('ChannelBuilder library browse', () => {
expect(types).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
expect(types.has('TelevisionSeason')).toBe(false);
});
// ---- Channel Builder (#89) -----------------------------------------------
const renderBuilder = async () => {
window.history.replaceState(null, '', '/app/new-channel');
const utils = render(<ChannelBuilderScreen />);
await screen.findByPlaceholderText('Search shows & movies…');
return utils;
};
// The shell's nav link and top-bar primary action also read "New Channel" /
// "Create Channel"; scope these to the builder toolbar.
const createBtn = () =>
within(document.querySelector('.ctv-builder-toolbar') as HTMLElement).getByRole('button', {
name: 'Create Channel'
});
const builderTitle = () => screen.getByText('New Channel', { selector: '.ctv-builder-title' });
const numberField = () => screen.getByLabelText('Number', { exact: false }) as HTMLInputElement;
const builderDefaults = () => ({
channels: [channelSummary()],
channelTemplates: [channelTemplate()],
defaultChannelTemplate: channelTemplate(),
ffmpegProfiles: [ffmpegProfile()]
});
it('renders the builder three-column layout with library items and the default template', async () => {
mockBuilderApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
expect(builderTitle()).toBeInTheDocument();
expect(screen.getByText('Lineup')).toBeInTheDocument();
expect(screen.getByText('Channel Template')).toBeInTheDocument();
expect(screen.getByText('Standard')).toBeInTheDocument();
expect(await screen.findByText('Looney Tunes')).toBeInTheDocument();
expect(screen.getByText('Tom & Jerry')).toBeInTheDocument();
expect(container.querySelectorAll('.ctv-builder-col')).toHaveLength(3);
expect(screen.getByText('This channel has no content yet. Double-click or drag titles from the library to build the lineup.')).toBeInTheDocument();
});
it('computes the AUTO channel number from the max integer part of existing channels', async () => {
mockBuilderApi({
...builderDefaults(),
channels: [channelSummary({ id: 1, number: '5' }), channelSummary({ id: 2, number: '13.1' })],
browseItems: [browseItem()]
});
await renderBuilder();
const numberInput = numberField();
expect(numberInput.value).toBe('14');
expect(screen.getByText('AUTO')).toBeInTheDocument();
});
it('adds a library item to the lineup on double-click and dedupes repeats', async () => {
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
expect(container.querySelector('.ctv-builder-summary')?.textContent).toContain('1 in lineup');
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
fireEvent.doubleClick(screen.getAllByText('Looney Tunes')[0]);
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
});
it('removes a lineup item and clears the lineup through the confirm dialog', async () => {
mockBuilderApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(2);
fireEvent.click(within(container.querySelectorAll('.ctv-builder-lineup-row')[0] as HTMLElement).getByRole('button', { name: 'Remove' }));
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
fireEvent.click(await screen.findByRole('button', { name: 'Clear lineup' }));
await waitFor(() => expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(0));
});
it('filters library results through the debounced search query', async () => {
mockBuilderApi({
...builderDefaults(),
browseHandler: (search) => {
const query = (search.get('query') ?? '').toLowerCase();
const mediaType = search.get('mediaType');
const all = [
browseItem(),
browseItem({ id: 2, mediaItemId: 2, mediaType: 'Movie', collectionType: 'Movie', title: 'Blade Runner', libraryName: 'Movies' })
];
const page = all
.filter((item) => !mediaType || item.mediaType === mediaType)
.filter((item) => !query || String(item.title).toLowerCase().includes(query));
return { page, totalCount: page.length };
}
});
await renderBuilder();
expect(await screen.findByText('Blade Runner')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search shows & movies…'), { target: { value: 'blade' } });
await waitFor(() => expect(screen.queryByText('Looney Tunes')).not.toBeInTheDocument());
expect(screen.getByText('Blade Runner')).toBeInTheDocument();
});
it('reorders lineup items with native drag and drop', async () => {
mockBuilderApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
let rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes');
fireEvent.dragStart(rows[0]);
fireEvent.dragOver(rows[1]);
fireEvent.drop(rows[1]);
rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Tom & Jerry');
expect(rows[1].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes');
});
it('validates the channel number for format and uniqueness', async () => {
mockBuilderApi({
...builderDefaults(),
channels: [channelSummary({ number: '5' })],
browseItems: [browseItem()]
});
await renderBuilder();
const numberInput = numberField();
fireEvent.change(numberInput, { target: { value: 'abc' } });
expect(await screen.findByText(/Use digits/)).toBeInTheDocument();
fireEvent.change(numberInput, { target: { value: '5' } });
expect(await screen.findByText(/already in use/)).toBeInTheDocument();
});
it('keeps Create disabled until a name and at least one lineup item exist', async () => {
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
await renderBuilder();
const createButton = createBtn();
expect(createButton).toBeDisabled();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } });
expect(createButton).toBeEnabled();
});
it('preselects the default template and flags an override when the Shuffle toggle diverges', async () => {
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
// default template has shuffleScheduleItems=false -> no override tag yet
expect(screen.queryByText('overrides template')).not.toBeInTheDocument();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
expect(await screen.findByText('overrides template')).toBeInTheDocument();
});
it('applies a selected template shuffle default and clears the override', async () => {
mockBuilderApi({
...builderDefaults(),
channelTemplates: [
channelTemplate(),
channelTemplate({ id: 11, isDefault: false, name: 'Music videos', shuffleScheduleItems: true })
],
browseItems: [browseItem()]
});
const { container } = await renderBuilder();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
expect(await screen.findByText('overrides template')).toBeInTheDocument();
// open the template picker and select the shuffle-by-default template
fireEvent.click(screen.getByText('Standard'));
fireEvent.click(await screen.findByText('Music videos'));
await waitFor(() => expect(screen.queryByText('overrides template')).not.toBeInTheDocument());
});
it('marks a rerun-collection row invalid in a multi-item lineup and blocks Create', async () => {
mockBuilderApi({
...builderDefaults(),
browseItems: [
browseItem(),
browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', collectionKind: 'Rerun', rerunCollectionId: 9, mediaItemId: undefined })
]
});
await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
// RerunCollection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Saturday Block'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Mixed' } });
expect(await screen.findByText('Only valid alone')).toBeInTheDocument();
expect(createBtn()).toBeDisabled();
});
it('clears a MultiCollection shuffle requirement when the Shuffle toggle is turned on', async () => {
mockBuilderApi({
...builderDefaults(),
browseItems: [
browseItem({ id: 7, title: 'Prime Time', mediaType: 'MultiCollection', collectionType: 'MultiCollection', multiCollectionId: 7, mediaItemId: undefined })
]
});
const { container } = await renderBuilder();
// MultiCollection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Prime Time'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Multi' } });
expect(await screen.findByText('Requires Shuffle')).toBeInTheDocument();
expect(createBtn()).toBeDisabled();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
await waitFor(() => expect(screen.queryByText('Requires Shuffle')).not.toBeInTheDocument());
expect(createBtn()).toBeEnabled();
});
it('posts a well-formed create request and navigates to channels on success', async () => {
mockBuilderApi({
...builderDefaults(),
channels: [channelSummary({ number: '5' })],
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: ' Retro Cartoons ' } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const body = requestBodyFor('/api/v1/channels/from-lineup');
expect(body.name).toBe('Retro Cartoons');
expect(body.number).toBe('6');
expect(body.group).toBe('ChicoryTV');
expect(body.templateId).toBe(10);
expect(body.logo).toEqual({ contentType: '', path: '' });
expect((body.advanced as Record<string, unknown>).playbackOrder).toBe('Chronological');
expect((body.advanced as Record<string, unknown>).playoutMode).toBe('Continuous');
const lineup = body.lineup as Array<Record<string, unknown>>;
expect(lineup).toHaveLength(2);
expect(lineup[0].mediaItemId).toBe(1);
expect(lineup[1].mediaItemId).toBe(2);
expect(lineup[0].mediaType).toBe('TelevisionShow');
});
it('highlights the offending lineup row and shows the detail on a 422 ProblemDetails', async () => {
mockBuilderApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })],
// Verbatim wire shape: ApiResults hard-codes the 422 title "Validation failed",
// and the handler's not-found detail is "lineup[i] <Label> <id> does not exist."
fromLineupFailure: { detail: 'lineup[1] TelevisionShow 999 does not exist.', status: 422, title: 'Validation failed' }
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
fireEvent.click(createBtn());
expect(await screen.findByText('lineup[1] TelevisionShow 999 does not exist.')).toBeInTheDocument();
await waitFor(() => {
const rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[1].classList.contains('ctv-builder-lineup-row-error')).toBe(true);
});
expect(window.location.pathname).toBe('/app/new-channel');
});
it('uploads the channel image then references the returned path in the create body', async () => {
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const uploadCall = vi
.mocked(window.fetch)
.mock.calls.find(([input]) => input.toString() === '/api/v1/artwork/uploads');
expect(uploadCall).toBeDefined();
const formData = uploadCall?.[1]?.body as FormData;
expect(formData).toBeInstanceOf(FormData);
expect(formData.get('target')).toBe('logo');
expect((formData.get('file') as File).name).toBe('logo.png');
const body = requestBodyFor('/api/v1/channels/from-lineup');
expect(body.logo).toEqual({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' });
});
it('fans out 5 typed browse requests in Collections mode and renders the merged, title-sorted items', async () => {
const byType: Record<string, Record<string, unknown>> = {
Collection: browseItem({
id: 2,
title: 'Saturday Cartoons',
mediaType: 'Collection',
collectionType: 'Collection',
collectionKind: 'Manual',
collectionId: 2,
mediaItemId: undefined
}),
SmartCollection: browseItem({
id: 3,
title: 'Action Movies',
mediaType: 'SmartCollection',
collectionType: 'SmartCollection',
collectionKind: 'Smart',
smartCollectionId: 3,
mediaItemId: undefined
}),
MultiCollection: browseItem({
id: 7,
title: 'Prime Time',
mediaType: 'MultiCollection',
collectionType: 'MultiCollection',
collectionKind: 'Multi',
multiCollectionId: 7,
mediaItemId: undefined
}),
RerunCollection: browseItem({
id: 9,
title: 'Saturday Block',
mediaType: 'RerunCollection',
collectionType: 'RerunFirstRun',
collectionKind: 'Rerun',
rerunCollectionId: 9,
mediaItemId: undefined
}),
Playlist: browseItem({
id: 12,
title: 'Zzz Late Night',
mediaType: 'Playlist',
collectionType: 'Playlist',
playlistId: 12,
mediaItemId: undefined
})
};
mockBuilderApi({
...builderDefaults(),
browseItems: [browseItem()],
browseHandler: (search) => {
const mediaType = search.get('mediaType');
const item = mediaType ? byType[mediaType] : undefined;
return item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 };
}
});
const { container } = await renderBuilder();
// Wait for the initial library fan-out (Movie/TelevisionShow/Artist) to be
// ISSUED before clearing the mock — renderBuilder only awaits the search
// box, and React flushes passive effects asynchronously, so on a slow
// machine the mount fan-out can otherwise leak past mockClear and pollute
// the post-click assertion below.
await waitFor(() => {
const browseCalls = vi
.mocked(window.fetch)
.mock.calls.filter(([input]) => input.toString().startsWith('/api/v1/library/browse'));
expect(browseCalls.length).toBeGreaterThanOrEqual(3);
});
// Only count requests made after switching to the Collections tab.
vi.mocked(window.fetch).mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
expect(await screen.findByText('Action Movies')).toBeInTheDocument();
const browseTypes = vi
.mocked(window.fetch)
.mock.calls.map(([input]) => input.toString())
.filter((url) => url.startsWith('/api/v1/library/browse'))
.map((url) => new URL(url, window.location.origin).searchParams.get('mediaType'))
.filter((mediaType): mediaType is string => mediaType != null);
expect(browseTypes).toEqual(['Collection', 'SmartCollection', 'MultiCollection', 'RerunCollection', 'Playlist']);
const titles = Array.from(container.querySelectorAll('.ctv-builder-poster-title')).map((el) => el.textContent);
expect(titles).toEqual(['Action Movies', 'Prime Time', 'Saturday Block', 'Saturday Cartoons', 'Zzz Late Night']);
});
it('loads a second page of library results and preserves the first page', async () => {
const pages = [
[browseItem({ id: 1, mediaItemId: 1, title: 'Item A' }), browseItem({ id: 2, mediaItemId: 2, title: 'Item B' })],
[browseItem({ id: 3, mediaItemId: 3, title: 'Item C' })]
];
mockBuilderApi({
...builderDefaults(),
browseHandler: (search) => {
// The builder fans out per-kind (Movie/TelevisionShow/TelevisionSeason/Artist);
// only return real pages for the kind these fixtures use (TelevisionShow) so
// the other 3 kinds don't duplicate the same items.
if (search.get('mediaType') !== 'TelevisionShow') {
return { page: [], totalCount: 0 };
}
const pageNum = Number(search.get('pageNum') ?? '0');
return { page: pages[pageNum] ?? [], totalCount: 3 };
}
});
await renderBuilder();
expect(await screen.findByText('Item A')).toBeInTheDocument();
expect(screen.getByText('Item B')).toBeInTheDocument();
expect(screen.queryByText('Item C')).not.toBeInTheDocument();
const loadMoreButton = screen.getByRole('button', { name: 'Load more' });
fireEvent.click(loadMoreButton);
expect(await screen.findByText('Item C')).toBeInTheDocument();
expect(screen.getByText('Item A')).toBeInTheDocument();
expect(screen.getByText('Item B')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
});
it('saves the current settings as a new template and selects it', async () => {
const created = channelTemplate({
id: 55,
name: 'Saturday Special',
description: 'Custom mix.',
isDefault: false,
isSystem: false,
shuffleScheduleItems: true
});
mockBuilderApi({
...builderDefaults(),
browseItems: [browseItem()],
createTemplateResponse: created
});
const { container } = await renderBuilder();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
fireEvent.click(screen.getByText('Standard'));
fireEvent.click(await screen.findByText('Save current settings as template…'));
expect(await screen.findByText('Save as channel template')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Template name'), { target: { value: 'Saturday Special' } });
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Custom mix.' } });
fireEvent.click(screen.getByRole('button', { name: 'Save template' }));
await waitFor(() => expect(screen.queryByText('Save as channel template')).not.toBeInTheDocument());
const body = requestBodyFor('/api/v1/channel-templates');
expect(body.name).toBe('Saturday Special');
expect(body.description).toBe('Custom mix.');
expect(body.shuffleScheduleItems).toBe(true);
expect(body.playoutMode).toBe('Continuous');
expect(screen.getByText('Saturday Special')).toBeInTheDocument();
});
it('shows a friendly error and skips channel creation when the artwork upload returns a bare 413', async () => {
mockBuilderApi({
...builderDefaults(),
browseItems: [browseItem()],
artworkUploadFailure: { status: 413 }
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(createBtn());
expect(await screen.findByText('Image is too large (max 30 MB).')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/new-channel');
expect(
vi.mocked(window.fetch).mock.calls.some(([input]) => input.toString() === '/api/v1/channels/from-lineup')
).toBe(false);
expect(createBtn()).toBeEnabled();
});
it('renders the empty-templates state and keeps Create disabled with no templates', async () => {
mockBuilderApi({
...builderDefaults(),
channelTemplates: [],
defaultChannelTemplate: null,
browseItems: [browseItem()]
});
await renderBuilder();
expect(
screen.getByText(/No channel templates exist yet\. Create one in Settings before building a channel/)
).toBeInTheDocument();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } });
expect(createBtn()).toBeDisabled();
});
it('sends a Collection lineup item with only its typed id populated', async () => {
mockBuilderApi({
...builderDefaults(),
browseItems: [
browseItem({
id: 2,
title: 'Saturday Cartoons',
mediaType: 'Collection',
collectionType: 'Collection',
collectionKind: 'Manual',
collectionId: 2,
mediaItemId: undefined
})
]
});
await renderBuilder();
// Collection is a collections kind, not one of the 4 library kinds —
// find it under the Collections tab.
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
fireEvent.doubleClick(await screen.findByText('Saturday Cartoons'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Marathon' } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const body = requestBodyFor('/api/v1/channels/from-lineup');
const lineup = body.lineup as Array<Record<string, unknown>>;
expect(lineup).toHaveLength(1);
expect(lineup[0].mediaType).toBe('Collection');
expect(lineup[0].collectionType).toBe('Collection');
expect(lineup[0].collectionId).toBe(2);
expect(lineup[0].mediaItemId).toBeUndefined();
expect(lineup[0].multiCollectionId).toBeUndefined();
expect(lineup[0].smartCollectionId).toBeUndefined();
expect(lineup[0].rerunCollectionId).toBeUndefined();
expect(lineup[0].playlistId).toBeUndefined();
});
it('renders the builder under the cool and dual design themes', async () => {
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
await renderBuilder();
applyDesignSystemTheme('cool');
expect(document.documentElement).toHaveAttribute('data-theme', 'cool');
expect(builderTitle()).toBeInTheDocument();
applyDesignSystemTheme('dual');
expect(document.documentElement).toHaveAttribute('data-theme', 'dual');
expect(screen.getByPlaceholderText('Search shows & movies…')).toBeInTheDocument();
});
});
+5
View File
@@ -764,6 +764,11 @@
display: flex;
align-items: center;
gap: 10px;
/* The SPA ships no global box-sizing reset (default content-box), so width:100% + the
horizontal padding below would otherwise overflow .ctv-nav (overflow:auto) by 20px and
produce a horizontal scrollbar in the sidebar (#377). Border-box keeps the item exactly
the nav content width, padding included. */
box-sizing: border-box;
width: 100%;
height: 34px;
border: 0;
+68
View File
@@ -0,0 +1,68 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
Button,
Checkbox,
Input,
ProgressBar,
Switch,
Tabs,
Toast,
Tooltip
} from '.';
describe('component primitives', () => {
afterEach(cleanup);
it('exports typed primitives with expected interactions', () => {
const onSwitch = vi.fn();
const onCheckbox = vi.fn();
const onTab = vi.fn();
render(
<>
<Button variant="primary" loading>
Saving
</Button>
<Switch checked={false} onChange={onSwitch} label="Show disabled" />
<Checkbox indeterminate onChange={onCheckbox} label="Select all" />
<Input label="Channel number" value="5.1" error="Already in use" onChange={() => {}} />
<ProgressBar value={62} showLabel />
<Tabs
value="streaming"
onChange={onTab}
tabs={[{ value: 'streaming', label: 'Streaming' }]}
/>
<Tooltip label="Reset playout">
<button type="button">Reset</button>
</Tooltip>
</>
);
expect(screen.getByRole('button', { name: 'Saving' })).toBeDisabled();
fireEvent.click(screen.getByRole('switch', { name: 'Show disabled' }));
expect(onSwitch).toHaveBeenCalledWith(true);
expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveAttribute(
'aria-checked',
'mixed'
);
expect(screen.getByText('Already in use')).toBeInTheDocument();
expect(screen.getByText('62%')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Streaming' }));
expect(onTab).toHaveBeenCalledWith('streaming');
fireEvent.mouseEnter(screen.getByText('Reset'));
expect(screen.getByRole('tooltip')).toHaveTextContent('Reset playout');
});
it('uses distinct semantic icons for warning and error toasts', () => {
const { container } = render(
<>
<Toast tone="warn" title="Check settings" />
<Toast tone="error" title="Save failed" />
</>
);
expect(container.querySelector('.lucide-triangle-alert')).toBeInTheDocument();
expect(container.querySelector('.lucide-circle-x')).toBeInTheDocument();
});
});
+23 -8
View File
@@ -25,48 +25,63 @@ describe('initialPinFlowState', () => {
describe('evaluatePinFlow — all transitions with injected state', () => {
it('locked & !authorized => waiting (keep polling)', () => {
const state = evaluatePinFlow({ isLocked: true, isAuthorized: false });
const state = evaluatePinFlow({ isLocked: true, isAuthorized: false, hasServers: false });
expect(state.status).toBe('waiting');
expect(state.done).toBe(false);
expect(state.ok).toBe(false);
});
it('locked & authorized => finalizing (keep polling, discovering servers)', () => {
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true });
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true, hasServers: false });
expect(state.status).toBe('finalizing');
expect(state.done).toBe(false);
expect(state.ok).toBe(false);
});
it('!locked & authorized => success (terminal)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true });
it('!locked & authorized & servers => success (terminal)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: true });
expect(state.status).toBe('success');
expect(state.done).toBe(true);
expect(state.ok).toBe(true);
});
it('!locked & authorized & NO servers => authorized-no-servers (terminal, recoverable, not ok) — #345', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: false });
expect(state.status).toBe('authorized-no-servers');
expect(state.done).toBe(true); // terminal: the lock has released, polling stops
expect(state.ok).toBe(false); // authorized, but NOT "Connected to Plex."
expect(state.message).not.toBe('Connected to Plex.');
expect(state.message).toMatch(/no eligible servers/i);
});
it('!locked & !authorized => timeout (terminal, ended without auth)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false });
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false, hasServers: false });
expect(state.status).toBe('timeout');
expect(state.done).toBe(true);
expect(state.ok).toBe(false);
});
it('locked & budget exhausted => budget-exhausted (terminal, not a failure)', () => {
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true }, { budgetExhausted: true });
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true, hasServers: false }, { budgetExhausted: true });
expect(state.status).toBe('budget-exhausted');
expect(state.done).toBe(true);
expect(state.ok).toBe(false);
});
it('budget exhaustion is ignored once the lock has released (success still wins)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true }, { budgetExhausted: true });
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: true }, { budgetExhausted: true });
expect(state.status).toBe('success');
expect(state.ok).toBe(true);
});
it('budget exhaustion is ignored once the lock has released (authorized-no-servers still wins)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: false }, { budgetExhausted: true });
expect(state.status).toBe('authorized-no-servers');
expect(state.ok).toBe(false);
});
it('budget exhaustion is ignored once the lock has released (timeout still wins)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false }, { budgetExhausted: true });
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false, hasServers: false }, { budgetExhausted: true });
expect(state.status).toBe('timeout');
});
});
+39 -13
View File
@@ -1,26 +1,40 @@
// The Plex OAuth pin-flow poll STATE MACHINE (design §C1 step 5), as a pure, timer-free module so
// the transitions can be unit-tested with injected state. The Plex screen (slice S6b) drives it:
// after `POST /pin-flow` it opens the auth URL and polls `GET /api/v1/media-sources/plex` every 2s for
// up to 150s, feeding each observation ({ isLocked, isAuthorized }) here to decide what to show and
// whether to keep polling.
// up to 150s, feeding each observation ({ isLocked, isAuthorized, hasServers }) here to decide what to
// show and whether to keep polling.
//
// KEY INSIGHT (finding 5): `isAuthorized` flips true the moment the token is saved, which is BEFORE
// SynchronizePlexMediaSources discovers the servers and releases the Plex lock. So authorization
// alone is NOT success — the terminal success signal is the lock RELEASING while authorized. Poll
// until the lock releases, not until authorized.
//
// isLocked & !isAuthorized -> waiting (keep polling; user hasn't authorized yet)
// isLocked & isAuthorized -> finalizing (keep polling; discovering servers)
// !isLocked & isAuthorized -> success (terminal)
// !isLocked & !isAuthorized -> timeout (terminal; flow ended without auth)
// isLocked (budget spent) -> budget-exhausted (terminal; a large first sync legitimately
// still holds the lock — not a failure)
// AUTHORIZED-BUT-EMPTY (#345): authorization and server discovery are distinct outcomes. A real flow
// reached `isAuthorized: true`, `isLocked: false`, `servers: []` (a Plex account that owns/has been
// granted no eligible servers). That is a coherent, recoverable terminal — NOT "Connected to Plex." —
// so the released-lock success branch further splits on whether any server was actually discovered.
//
// isLocked & !isAuthorized -> waiting (keep polling; user hasn't authorized yet)
// isLocked & isAuthorized -> finalizing (keep polling; discovering servers)
// !isLocked & isAuthorized & servers -> success (terminal; connected to a server)
// !isLocked & isAuthorized & !servers -> authorized-no-servers (terminal; authorized, none discovered)
// !isLocked & !isAuthorized -> timeout (terminal; flow ended without auth)
// isLocked (budget spent) -> budget-exhausted (terminal; a large first sync legitimately
// still holds the lock — not a failure)
export type PinFlowStatus = 'waiting' | 'finalizing' | 'success' | 'timeout' | 'budget-exhausted';
export type PinFlowStatus =
| 'waiting'
| 'finalizing'
| 'success'
| 'authorized-no-servers'
| 'timeout'
| 'budget-exhausted';
export interface PinFlowObservation {
isLocked: boolean;
isAuthorized: boolean;
/** Whether server discovery has surfaced at least one server for this authorization. */
hasServers: boolean;
}
export interface PinFlowState {
@@ -41,14 +55,22 @@ const MESSAGES: Record<PinFlowStatus, string> = {
waiting: 'Waiting for you to authorize in the Plex tab…',
finalizing: 'Authorized — discovering your Plex servers…',
success: 'Connected to Plex.',
'authorized-no-servers': 'Signed in to Plex, but no eligible servers were discovered.',
timeout: 'Plex sign-in timed out — try again.',
'budget-exhausted': 'Still working — this can take a while on a large first sync. Use Refresh to check again.'
};
const TERMINAL: ReadonlySet<PinFlowStatus> = new Set<PinFlowStatus>([
'success',
'authorized-no-servers',
'timeout',
'budget-exhausted'
]);
function stateFor(status: PinFlowStatus): PinFlowState {
return {
status,
done: status === 'success' || status === 'timeout' || status === 'budget-exhausted',
done: TERMINAL.has(status),
ok: status === 'success',
message: MESSAGES[status]
};
@@ -73,11 +95,15 @@ export function evaluatePinFlow(
observation: PinFlowObservation,
options: { budgetExhausted?: boolean } = {}
): PinFlowState {
const { isLocked, isAuthorized } = observation;
const { isLocked, isAuthorized, hasServers } = observation;
// Lock released: terminal either way. Success iff authorized.
// Lock released: terminal. Not authorized at all -> timeout. Authorized -> success only when a
// server was actually discovered; authorized-but-empty is its own recoverable terminal (#345).
if (!isLocked) {
return stateFor(isAuthorized ? 'success' : 'timeout');
if (!isAuthorized) {
return stateFor('timeout');
}
return stateFor(hasServers ? 'success' : 'authorized-no-servers');
}
// Still locked but out of budget: not a failure, just "still working".
+152
View File
@@ -0,0 +1,152 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PrimaryActionProvider, usePrimaryAction, usePrimaryActionHandler } from './primaryAction';
function Registration({ routeId, handler }: { routeId: string; handler: () => void }) {
usePrimaryAction(routeId, handler);
return null;
}
function Action({ routeId }: { routeId: string }) {
const handler = usePrimaryActionHandler(routeId);
return (
<button type="button" disabled={!handler} onClick={handler}>
Run {routeId}
</button>
);
}
describe('primary actions', () => {
afterEach(() => {
cleanup();
});
it('invokes the active handler for a matching route', () => {
const handler = vi.fn();
render(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={handler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
expect(handler).toHaveBeenCalledOnce();
});
it('returns no handler for a nonmatching route', () => {
const handler = vi.fn();
render(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={handler} />
<Action routeId="channels" />
</PrimaryActionProvider>
);
const action = screen.getByRole('button', { name: 'Run channels' });
expect(action).toBeDisabled();
fireEvent.click(action);
expect(handler).not.toHaveBeenCalled();
});
it('invokes the latest handler without replacing the registration', () => {
const firstHandler = vi.fn();
const latestHandler = vi.fn();
const view = render(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={firstHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
view.rerender(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={latestHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
expect(firstHandler).not.toHaveBeenCalled();
expect(latestHandler).toHaveBeenCalledOnce();
});
it("moves the same owner's registration to a new route and clears it on unmount", () => {
const handler = vi.fn();
const view = render(
<PrimaryActionProvider>
<Registration routeId="schedules" handler={handler} />
<Action routeId="schedules" />
<Action routeId="channels" />
</PrimaryActionProvider>
);
view.rerender(
<PrimaryActionProvider>
<Registration routeId="channels" handler={handler} />
<Action routeId="schedules" />
<Action routeId="channels" />
</PrimaryActionProvider>
);
expect(screen.getByRole('button', { name: 'Run schedules' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Run channels' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: 'Run channels' }));
expect(handler).toHaveBeenCalledOnce();
view.rerender(
<PrimaryActionProvider>
<Action routeId="schedules" />
<Action routeId="channels" />
</PrimaryActionProvider>
);
expect(screen.getByRole('button', { name: 'Run channels' })).toBeDisabled();
});
it('does not let an older owner rerender or cleanup reclaim a newer registration', () => {
const olderHandler = vi.fn();
const updatedOlderHandler = vi.fn();
const newerHandler = vi.fn();
const view = render(
<PrimaryActionProvider>
<Registration key="older" routeId="schedules" handler={olderHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
view.rerender(
<PrimaryActionProvider>
<Registration key="older" routeId="schedules" handler={olderHandler} />
<Registration key="newer" routeId="schedules" handler={newerHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
view.rerender(
<PrimaryActionProvider>
<Registration key="older" routeId="schedules" handler={updatedOlderHandler} />
<Registration key="newer" routeId="schedules" handler={newerHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
expect(newerHandler).toHaveBeenCalledOnce();
view.rerender(
<PrimaryActionProvider>
<Registration key="newer" routeId="schedules" handler={newerHandler} />
<Action routeId="schedules" />
</PrimaryActionProvider>
);
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
expect(olderHandler).not.toHaveBeenCalled();
expect(updatedOlderHandler).not.toHaveBeenCalled();
expect(newerHandler).toHaveBeenCalledTimes(2);
});
it('lets screens render harmlessly without a provider', () => {
expect(() => render(<Registration routeId="schedules" handler={vi.fn()} />)).not.toThrow();
});
});
+57 -28
View File
@@ -1,39 +1,68 @@
import { useEffect, useRef } from 'react';
import {
createContext,
createElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';
// The TopBar renders one "primary action" button per screen (top-right). Because the
// TopBar and the screens are decoupled (the TopBar has no reference to the active
// screen component), the click is delivered as a window CustomEvent keyed on the
// active route id; a screen opts in with `usePrimaryAction(routeId, handler)`.
//
// A screen that does NOT call usePrimaryAction gets NO button — App.tsx's route table
// declares an empty `primaryAction` for such screens and the TopBar suppresses the
// button (see issue #238: the old code rendered a dead button for every unwired route).
export const PRIMARY_ACTION_EVENT = 'ctv:primary-action';
type PrimaryActionHandler = () => void;
export function dispatchPrimaryAction(routeId: string): void {
window.dispatchEvent(new CustomEvent(PRIMARY_ACTION_EVENT, { detail: routeId }));
interface PrimaryActionRegistration {
owner: symbol;
routeId: string;
handler: PrimaryActionHandler;
}
/**
* Subscribe a screen to its TopBar primary-action button. The handler runs whenever the
* TopBar dispatches `ctv:primary-action` with a detail matching `routeId`. The latest
* handler is held in a ref so re-renders don't churn the window listener.
*/
export function usePrimaryAction(routeId: string, handler: () => void): void {
interface PrimaryActionContextValue {
registration: PrimaryActionRegistration | undefined;
register: (owner: symbol, routeId: string, handler: PrimaryActionHandler) => () => void;
}
const PrimaryActionContext = createContext<PrimaryActionContextValue | undefined>(undefined);
/** Owns the single primary-action registration shared by the shell and active screen. */
export function PrimaryActionProvider({ children }: { children: ReactNode }) {
const [registration, setRegistration] = useState<PrimaryActionRegistration>();
const register = useCallback((owner: symbol, routeId: string, handler: PrimaryActionHandler) => {
setRegistration({ owner, routeId, handler });
return () => {
setRegistration((current) => (current?.owner === owner ? undefined : current));
};
}, []);
const value = useMemo(() => ({ registration, register }), [register, registration]);
return createElement(PrimaryActionContext.Provider, { value }, children);
}
/** Register a screen's primary action. Rendering outside the provider is intentionally harmless. */
export function usePrimaryAction(routeId: string, handler: PrimaryActionHandler): void {
const handlerRef = useRef(handler);
// Keep the ref pointing at the latest handler without re-subscribing the window listener
// on every render. Updating a ref during render trips react-hooks/refs, so do it in an effect.
const [owner] = useState(() => Symbol('primary-action-owner'));
const register = useContext(PrimaryActionContext)?.register;
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
const listener = (event: Event) => {
if ((event as CustomEvent<string>).detail === routeId) {
handlerRef.current();
}
};
window.addEventListener(PRIMARY_ACTION_EVENT, listener);
return () => window.removeEventListener(PRIMARY_ACTION_EVENT, listener);
}, [routeId]);
if (!register) {
return;
}
return register(owner, routeId, () => handlerRef.current());
}, [owner, register, routeId]);
}
/** Return the active handler only when its registration belongs to the requested route. */
export function usePrimaryActionHandler(routeId: string): PrimaryActionHandler | undefined {
const registration = useContext(PrimaryActionContext)?.registration;
return registration?.routeId === routeId ? registration.handler : undefined;
}
+96
View File
@@ -170,4 +170,100 @@ describe('PlexSourceScreen', () => {
expect(fetchSpy.mock.calls.some(([input, init]) => String(input).endsWith('/sign-out') && (init?.method ?? '').toUpperCase() === 'POST')).toBe(true)
);
});
// #345: a real pin flow reached isAuthorized:true, isLocked:false, servers:[] (a Plex account that
// owns no eligible servers). The UI used to claim "Connected to Plex." while the Connection card said
// "Not signed in.", offered Sign in, and hid Sign out — so the stored authorization was unremovable.
it('pin flow ending authorized/unlocked with NO servers is coherent and recoverable', async () => {
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
const counter = { state: 0 };
installFetch(stateRef, counter);
vi.spyOn(window, 'open').mockReturnValue({} as Window);
vi.useFakeTimers();
render(<PlexSourceScreen />);
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy());
stateRef.current = { isAuthorized: false, isLocked: true, servers: [] };
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
await vi.waitFor(() => expect(screen.getByText(/Waiting for you to authorize/i)).toBeTruthy());
// Authorized but STILL LOCKED = finalizing (discovery in progress). The empty-servers terminal
// messaging must NOT appear here — while locked this is the normal "discovering" window, not
// "no servers were found" (regression guard for the isLocked gate).
stateRef.current = { isAuthorized: true, isLocked: true, servers: [] };
await vi.advanceTimersByTimeAsync(2000);
await vi.waitFor(() => expect(screen.getByText(/discovering your Plex servers/i)).toBeTruthy());
expect(screen.queryByText(/Your Plex account is authorized/i)).toBeNull(); // no "sign out and retry" summary
expect(screen.queryByText(/no eligible servers were discovered/i)).toBeNull();
expect(screen.queryByRole('button', { name: 'Sign in to Plex' })).toBeNull();
// Lock releases while authorized but discovery found nothing: the distinct terminal state.
stateRef.current = { isAuthorized: true, isLocked: false, servers: [] };
await vi.advanceTimersByTimeAsync(2000);
// Pin-flow message distinguishes authorization from server discovery (does not claim connection).
await vi.waitFor(() =>
expect(screen.getByText('Signed in to Plex, but no eligible servers were discovered.')).toBeTruthy()
);
expect(screen.queryByText('Connected to Plex.')).toBeNull();
// The explanatory summary is shown (distinct wording anchors it).
expect(screen.getByText(/Your Plex account is authorized/i)).toBeTruthy();
// No Servers card, and the Connection card + actions agree: Sign out offered, Sign in withheld.
expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull();
expect(screen.getByRole('button', { name: 'Sign out of Plex' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Sign in to Plex' })).toBeNull();
// Polling stopped on the terminal state.
const callsAfterTerminal = counter.state;
await vi.advanceTimersByTimeAsync(6000);
expect(counter.state).toBe(callsAfterTerminal);
});
it('authorized/unlocked/empty state loaded fresh (no pin flow) can sign out', async () => {
// The reload path: no pinState, not polling — driven purely by the loaded RemoteMediaSourceState.
const stateRef = { current: { isAuthorized: true, isLocked: false, servers: [] } as PlexState };
const counter = { state: 0 };
const fetchSpy = installFetch(stateRef, counter);
render(<PlexSourceScreen />);
// The explanatory summary and Sign-out action reflect "authorized, no servers" (not "Not signed in.").
await screen.findByText(/Your Plex account is authorized/i);
expect(screen.queryByRole('button', { name: 'Sign in to Plex' })).toBeNull();
expect(screen.queryByText('Not signed in.')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Sign out of Plex' }));
expect(await screen.findByText(/removes all synced Plex servers/i)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
await vi.waitFor(() =>
expect(fetchSpy.mock.calls.some(([input, init]) => String(input).endsWith('/sign-out') && (init?.method ?? '').toUpperCase() === 'POST')).toBe(true)
);
});
// #345 (review follow-up): a large first sync legitimately still holds the lock past the poll
// budget (budget-exhausted). The empty-servers "sign out and retry" messaging must NOT show — it
// would contradict the "Still working…" status and urge aborting an in-progress sync.
it('budget-exhausted while locked with no servers does not show the "no servers, sign out" messaging', async () => {
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
const counter = { state: 0 };
installFetch(stateRef, counter);
vi.spyOn(window, 'open').mockReturnValue({} as Window);
vi.useFakeTimers();
render(<PlexSourceScreen />);
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy());
// Authorized but locked forever (large first sync holds the lock).
stateRef.current = { isAuthorized: true, isLocked: true, servers: [] };
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
await vi.waitFor(() => expect(screen.getByText(/discovering your Plex servers|Waiting/i)).toBeTruthy());
await vi.advanceTimersByTimeAsync(152_000); // consume the whole 150s budget
await vi.waitFor(() => expect(screen.getByText(/Still working/i)).toBeTruthy());
// The lock is still held, so no terminal "no servers were found" messaging.
expect(screen.queryByText(/Your Plex account is authorized/i)).toBeNull();
expect(screen.queryByText(/no eligible servers were discovered/i)).toBeNull();
});
});
+35 -8
View File
@@ -84,7 +84,10 @@ export function PlexSourceScreen() {
}
setBoot({ status: 'ready', state });
const elapsed = Date.now() - startedAtRef.current;
const next = evaluatePinFlow(state, { budgetExhausted: isPinFlowBudgetExhausted(elapsed) });
const next = evaluatePinFlow(
{ isLocked: state.isLocked, isAuthorized: state.isAuthorized, hasServers: (state.servers ?? []).length > 0 },
{ budgetExhausted: isPinFlowBudgetExhausted(elapsed) }
);
setPinState(next);
if (next.done) {
setPolling(false);
@@ -210,8 +213,26 @@ export function PlexSourceScreen() {
const { isAuthorized, isLocked, servers } = boot.state;
const serverList = servers ?? [];
const connected = serverList.length > 0;
const needsFix = connected && !isAuthorized;
const hasServers = serverList.length > 0;
// Authorization (a stored Plex token) and connection (a discovered server) are DISTINCT (#345).
// `hasPlexAccount` is "there is stored Plex state to sign out of" — an authorized token OR
// discovered servers — and it, not the server count, gates Sign in vs Sign out. This makes the
// authorized/unlocked/empty-servers state recoverable: Sign out is offered, Sign in is not.
const hasPlexAccount = isAuthorized || hasServers;
const needsFix = hasServers && !isAuthorized;
// Only a RELEASED lock is terminal: while `isLocked`, an authorized+empty observation is the
// normal `finalizing` (discovery in progress) or `budget-exhausted` (large first sync still
// running) window — NOT "no servers were found". Gating on `!isLocked` keeps the "no eligible
// servers, sign out and retry" messaging from contradicting the in-flight discovery status and
// from urging a sign-out that would abort a legitimately-running sync.
const authorizedNoServers = isAuthorized && !hasServers && !isLocked;
const connectionSubtitle = !hasPlexAccount
? 'Not signed in.'
: needsFix
? 'Signed in, but credentials need attention.'
: authorizedNoServers
? 'Signed in to Plex — no eligible servers were discovered.'
: 'Signed in to Plex.';
return (
<div className="ctv-libraries-screen">
@@ -235,10 +256,10 @@ export function PlexSourceScreen() {
<Card
title={<h3>Connection</h3>}
subtitle={connected ? (isAuthorized ? 'Signed in to Plex.' : 'Signed in, but credentials need attention.') : 'Not signed in.'}
subtitle={connectionSubtitle}
actions={
<div style={{ display: 'flex', gap: 8 }}>
{!connected && (
{!hasPlexAccount && (
<Button disabled={starting || polling} loading={starting} onClick={startPinFlow} startIcon={<LogIn aria-hidden="true" size={14} />}>
Sign in to Plex
</Button>
@@ -248,7 +269,7 @@ export function PlexSourceScreen() {
Fix Plex Credentials
</Button>
)}
{connected && (
{hasPlexAccount && (
<Button disabled={signingOut || polling} onClick={() => setConfirmSignOut(true)} startIcon={<LogOut aria-hidden="true" size={14} />} variant="secondary">
Sign out of Plex
</Button>
@@ -265,12 +286,12 @@ export function PlexSourceScreen() {
)}
</div>
)}
{!polling && !pinState && !connected && (
{!polling && !pinState && !hasPlexAccount && (
<div className="ctv-schedule-empty">Sign in to Plex to connect a server.</div>
)}
</Card>
{connected && (
{hasServers && (
<Card title={<h3>Servers</h3>} subtitle={`${serverList.length} server${serverList.length === 1 ? '' : 's'} discovered.`} padded={false}>
<div className="ctv-library-list" role="list" aria-label="Plex servers">
{serverList.map((server) => (
@@ -300,6 +321,12 @@ export function PlexSourceScreen() {
</div>
)}
{authorizedNoServers && (
<div className="ctv-libraries-summary">
<span><Badge tone="neutral">No servers</Badge> Your Plex account is authorized, but no eligible servers were discovered. Confirm your Plex account owns or has been granted access to a server, then Sign out of Plex and sign in again to retry.</span>
</div>
)}
<ConfirmDialog
open={confirmSignOut}
tone="danger"
+19 -4
View File
@@ -1,5 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PrimaryActionProvider, usePrimaryActionHandler } from '../primaryAction';
import { SchedulesScreen } from './SchedulesScreen';
function jsonResponse(body: unknown, status = 200): Response {
@@ -157,6 +158,11 @@ async function renderReady(options: MockOptions = {}) {
return handle;
}
function SchedulesPrimaryActionButton() {
const handler = usePrimaryActionHandler('schedules');
return <button type="button" disabled={!handler} onClick={handler}>TopBar action</button>;
}
describe('SchedulesScreen — load', () => {
it('renders the lineup for the active schedule', async () => {
await renderReady();
@@ -438,10 +444,19 @@ describe('SchedulesScreen — schedule CRUD', () => {
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/v1/schedules/99/items')).toBe(true));
});
it('responds to the TopBar primary-action event by opening create', async () => {
await renderReady();
window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: 'schedules' }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy());
it('registers the TopBar primary action to open create', async () => {
mockApi();
render(
<PrimaryActionProvider>
<SchedulesPrimaryActionButton />
<SchedulesScreen />
</PrimaryActionProvider>
);
await waitFor(() => expect(screen.getByLabelText('Schedule lineup')).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: 'TopBar action' }));
expect(screen.getByRole('dialog')).toBeTruthy();
expect(within(screen.getByRole('dialog')).getByLabelText('Name')).toBeTruthy();
});
+660
View File
@@ -0,0 +1,660 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingsScreen } from './SettingsScreen';
describe('Settings screen (#93)', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
window.history.replaceState(null, '', '/');
});
beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app/settings/general');
vi.restoreAllMocks();
});
const openSettings = async () => {
render(<SettingsScreen />);
expect(
await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')
).toBeInTheDocument();
};
it('renders each section on nav click', async () => {
mockSettingsApi();
render(<SettingsScreen />);
expect(await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Streaming/ }));
expect(await screen.findByText('FFmpeg engine, transcoding defaults and HLS session tuning.')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/settings/streaming');
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
expect(await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Guide \(XMLTV\)/ }));
expect(await screen.findByText('Shape of the XMLTV guide data served to Jellyfin and other clients.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Scanner/ }));
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Logging/ }));
expect(await screen.findByText('Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
expect(await screen.findByText('HDHomeRun emulation, connected media sources and server info.')).toBeInTheDocument();
});
it('supports deep-linking directly to a section path', async () => {
mockSettingsApi();
window.history.replaceState(null, '', '/app/settings/scanner');
render(<SettingsScreen />);
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
});
it('loads values from the API into the General pane', async () => {
mockSettingsApi({ uiSettings: defaultUiSettings({ isDarkMode: false, language: 'fr' }) });
await openSettings();
expect(await screen.findByDisplayValue('Light')).toBeInTheDocument();
expect(screen.getByDisplayValue('fr')).toBeInTheDocument();
});
it('shows the save bar with the correct dirty count when editing a field, and Discard restores', async () => {
mockSettingsApi();
await openSettings();
const languageInput = screen.getByPlaceholderText('en-US');
fireEvent.change(languageInput, { target: { value: 'de-DE' } });
expect(await screen.findByText('1 unsaved change')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(screen.queryByText('1 unsaved change')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('en-US')).toBeInTheDocument();
});
it('saves only the changed group(s) and shows a confirmation', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockSettingsApi();
await openSettings();
const languageInput = screen.getByPlaceholderText('en-US');
fireEvent.change(languageInput, { target: { value: 'de-DE' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/v1/settings/ui')).toMatchObject({ language: 'de-DE' });
expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/settings/ffmpeg', expect.objectContaining({ method: 'PUT' }));
vi.advanceTimersByTime(2000);
await waitFor(() => expect(screen.queryByText('Settings saved')).not.toBeInTheDocument());
});
it('renders the IPTV pane and saves an edited advertised base URL', async () => {
mockSettingsApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^IPTV/ }));
expect(
await screen.findByText('How ErsatzTV advertises itself to IPTV clients in M3U playlists and stream URLs.')
).toBeInTheDocument();
const baseUrlInput = screen.getByPlaceholderText('(use request origin)');
fireEvent.change(baseUrlInput, { target: { value: 'https://tv.example.com/etv' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/v1/settings/iptv')).toMatchObject({ baseUrl: 'https://tv.example.com/etv' });
});
it('shows a warning callout when scanner refresh interval is 0', async () => {
mockSettingsApi({ scannerSettings: defaultScannerSettings({ libraryRefreshInterval: 0 }) });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Scanner/ }));
expect(await screen.findByText('Automatic scanning is disabled — libraries only update when scanned manually.')).toBeInTheDocument();
});
it('adds and deletes a custom resolution (delete goes through a confirm dialog)', async () => {
mockSettingsApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Custom resolutions');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
expect(await screen.findByText('2560 × 1080')).toBeInTheDocument();
expect(requestBodyFor('/api/v1/settings/resolutions')).toMatchObject({ height: 1080, width: 2560 });
const deleteButtons = screen.getAllByRole('button', { name: /Delete \d+×\d+/ });
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => expect(screen.queryByText('2560 × 1080')).not.toBeInTheDocument());
});
it('does not show a delete button on non-custom resolutions', async () => {
mockSettingsApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('1920 × 1080');
expect(screen.queryByRole('button', { name: 'Delete 1920×1080' })).not.toBeInTheDocument();
});
it('renders FFmpeg profiles read-only with a link to the FFmpeg Profiles editor, and media sources', async () => {
mockSettingsApi({
ffmpegProfiles: [ffmpegProfile({ id: 1, name: '1080p H.264' }), ffmpegProfile({ id: 2, name: '720p H.264' })],
mediaSources: [mediaSource({ id: 30, kind: 'Local', name: 'Local' })]
});
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
expect((await screen.findAllByText('1080p H.264')).length).toBeGreaterThan(0);
expect(screen.getAllByText('720p H.264').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: /Manage FFmpeg profiles/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
expect(await screen.findByText('Local')).toBeInTheDocument();
});
it('shows a Classic UI link to the legacy Blazor app in the System pane (#147)', async () => {
mockSettingsApi();
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
const link = await screen.findByRole('link', { name: /Open Classic UI/ });
expect(link).toHaveAttribute('href', '/system/health');
});
it('renders and stays editable when media sources (tier-2 reference data) fail to load', async () => {
mockSettingsApi({ mediaSourcesFailuresBeforeSuccess: 99 });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
expect(await screen.findByText("Couldn't load media sources")).toBeInTheDocument();
// The rest of the screen is fully functional: edit + save still work.
fireEvent.click(screen.getByRole('button', { name: /General/ }));
fireEvent.change(await screen.findByPlaceholderText('en-US'), { target: { value: 'nl-BE' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/v1/settings/ui')).toMatchObject({ language: 'nl-BE' });
});
it('shows the error state with a working retry when a settings group (tier-1) fails', async () => {
mockSettingsApi({ settingsGetFailuresBeforeSuccess: { '/api/v1/settings/scanner': 1 } });
render(<SettingsScreen />);
const alert = await screen.findByRole('alert');
expect(alert).toBeInTheDocument();
expect(screen.queryByText('Loading settings…')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(
await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')
).toBeInTheDocument();
});
it('saves exactly the dirty groups when multiple groups are edited', async () => {
mockSettingsApi();
await openSettings();
fireEvent.change(screen.getByPlaceholderText('en-US'), { target: { value: 'de-DE' } });
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '5' } });
await screen.findByText('2 unsaved changes');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/v1/settings/ui')).toMatchObject({ language: 'de-DE' });
expect(requestBodyFor('/api/v1/settings/playout')).toMatchObject({ daysToBuild: 5 });
const putCallCount = (path: string) =>
vi.mocked(window.fetch).mock.calls.filter(([input, init]) => input.toString() === path && init?.method === 'PUT')
.length;
expect(putCallCount('/api/v1/settings/ui')).toBe(1);
expect(putCallCount('/api/v1/settings/playout')).toBe(1);
expect(putCallCount('/api/v1/settings/ffmpeg')).toBe(0);
expect(putCallCount('/api/v1/settings/xmltv')).toBe(0);
expect(putCallCount('/api/v1/settings/scanner')).toBe(0);
expect(putCallCount('/api/v1/settings/logging')).toBe(0);
expect(putCallCount('/api/v1/settings/hdhr')).toBe(0);
});
it('on partial save failure, keeps only the failed group dirty and surfaces its error', async () => {
mockSettingsApi({
settingsMutationFailures: { '/api/v1/settings/playout': { detail: 'Playout save failed', status: 500 } }
});
await openSettings();
fireEvent.change(screen.getByPlaceholderText('en-US'), { target: { value: 'de-DE' } });
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '5' } });
await screen.findByText('2 unsaved changes');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('1 unsaved change')).toBeInTheDocument();
expect(screen.getByText(/Playout save failed/)).toBeInTheDocument();
expect(screen.queryByText('Settings saved')).not.toBeInTheDocument();
// The succeeded (ui) group's edit stuck even though the draft stayed on the Playout pane.
fireEvent.click(screen.getByRole('button', { name: /General/ }));
expect(await screen.findByDisplayValue('de-DE')).toBeInTheDocument();
});
it('surfaces an inline error (no unhandled rejection) when adding a duplicate resolution fails', async () => {
mockSettingsApi({ resolutionCreateFailure: { detail: 'Resolution already exists', status: 422 } });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Custom resolutions');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
expect(await screen.findByText('Resolution already exists')).toBeInTheDocument();
});
it('surfaces a resolution-delete failure inside the still-open confirm dialog', async () => {
mockSettingsApi({ resolutionDeleteFailure: { detail: 'Resolution is in use', status: 409 } });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('1920 × 1080');
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
await screen.findByText('2560 × 1080');
const deleteButtons = screen.getAllByRole('button', { name: /Delete \d+×\d+/ });
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
expect(await within(dialog).findByText('Resolution is in use')).toBeInTheDocument();
// The dialog stays open with the resolution still present - a clean draft would
// otherwise show nothing at all, since saveError only renders inside the save bar.
expect(screen.getByText('2560 × 1080')).toBeInTheDocument();
});
it('disables Save and shows an inline error when a numeric field is cleared', async () => {
mockSettingsApi();
await openSettings();
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '' } });
await screen.findByText('1 unsaved change');
expect(screen.getByText('Must be a whole number ≥ 0')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save changes/ })).toBeDisabled();
});
it('treats a tunerCount of 0 as invalid (backend rejects 0)', async () => {
mockSettingsApi();
await openSettings();
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
await screen.findByText('HDHomeRun emulation, connected media sources and server info.');
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '0' } });
await screen.findByText('1 unsaved change');
expect(screen.getByText('Must be a whole number ≥ 1')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save changes/ })).toBeDisabled();
});
it('shows the raw wire value as an extra HLS Direct option when it is outside the known set', async () => {
mockSettingsApi({ ffmpegSettings: defaultFfmpegSettings({ hlsDirectOutputFormat: 'Hls' }) });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
expect(await screen.findByDisplayValue('Hls')).toBeInTheDocument();
});
it('sends a non-null globalWatermarkId through in the ffmpeg save PUT body', async () => {
mockSettingsApi({ watermarks: [{ id: 5, name: 'Bug' }] });
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
await screen.findByText('Global defaults');
const watermarkSelect = screen
.getAllByRole('combobox')
.find((select) => within(select).queryByText('Bug')) as HTMLSelectElement;
fireEvent.change(watermarkSelect, { target: { value: '5' } });
await screen.findByText('1 unsaved change');
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
expect(requestBodyFor('/api/v1/settings/ffmpeg')).toMatchObject({ globalWatermarkId: 5 });
});
it('renders the most recent last-scan time per media source', async () => {
mockSettingsApi({
mediaSources: [
mediaSource({
id: 30,
kind: 'Local',
libraries: [
library({ id: 31, lastScan: '2026-07-01T00:00:00Z' }),
library({ id: 32, lastScan: '2026-07-05T14:30:00Z' })
],
name: 'Local'
}),
mediaSource({ id: 40, kind: 'Jellyfin', libraries: [], name: 'Jellyfin Server' })
]
});
await openSettings();
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
expect(await screen.findByText('Local')).toBeInTheDocument();
expect(await screen.findByText('Jellyfin Server')).toBeInTheDocument();
expect(screen.getByText(/Last scan/)).toBeInTheDocument();
});
});
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
function library(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 31,
itemCount: 0,
lastScan: null,
mediaKind: 'Movies',
name: 'Movies',
...overrides
};
}
function mediaSource(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
connectionAddress: null,
id: 30,
kind: 'Local',
libraries: [],
name: 'Local',
...overrides
};
}
function ffmpegProfile(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { id: 100, name: '1080p H.264', ...overrides };
}
// Read the JSON body captured for a mutating request to `path`.
function requestBodyFor(path: string): Record<string, unknown> {
const call = vi
.mocked(window.fetch)
.mock.calls.find(([input, init]) => input.toString() === path && init?.body != null);
if (!call) {
throw new Error(`no request captured for ${path}`);
}
return JSON.parse(call[1]?.body as string) as Record<string, unknown>;
}
function defaultFfmpegSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
defaultFFmpegProfileId: 1,
defaultMpegTsScript: '',
extractEmbeddedSubtitles: false,
fFmpegPath: '/usr/bin/ffmpeg',
fFprobePath: '/usr/bin/ffprobe',
globalFallbackFillerId: null,
globalWatermarkId: null,
hlsDirectOutputFormat: 'MpegTs',
hlsSegmenterIdleTimeout: 60,
initialSegmentCount: 1,
preferredAudioLanguageCode: 'eng',
probeForInterlacedFrames: true,
saveReports: false,
useEmbeddedSubtitles: true,
workAheadSegmenterLimit: 1,
...overrides
};
}
function defaultPlayoutSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
daysToBuild: 2,
scriptedScheduleTimeoutSeconds: 30,
skipMissingItems: true,
...overrides
};
}
function defaultXmltvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
blockBehavior: 'SplitTimeEvenly',
daysToBuild: 2,
timeZone: 'Local',
...overrides
};
}
function defaultScannerSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
libraryRefreshInterval: 6,
...overrides
};
}
function defaultLoggingSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
defaultMinimumLogLevel: 'Information',
httpMinimumLogLevel: 'Warning',
scanningMinimumLogLevel: 'Information',
schedulingMinimumLogLevel: 'Information',
searchingMinimumLogLevel: 'Information',
streamingMinimumLogLevel: 'Information',
...overrides
};
}
function defaultUiSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
isDarkMode: true,
language: 'en-US',
...overrides
};
}
function defaultHdhrSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
tunerCount: 2,
uuid: '6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41',
...overrides
};
}
function defaultIptvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
baseUrl: '',
...overrides
};
}
function defaultResolutions(): Array<Record<string, unknown>> {
return [
{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 },
{ height: 720, id: 2, isCustom: false, name: '1280x720', width: 1280 }
];
}
function mockSettingsApi({
ffmpegProfiles = [],
ffmpegSettings = defaultFfmpegSettings(),
hdhrSettings = defaultHdhrSettings(),
iptvSettings = defaultIptvSettings(),
loggingSettings = defaultLoggingSettings(),
mediaSources = [],
mediaSourcesFailuresBeforeSuccess = 0,
playoutSettings = defaultPlayoutSettings(),
resolutionCreateFailure = null,
resolutionDeleteFailure = null,
resolutions = defaultResolutions(),
scannerSettings = defaultScannerSettings(),
settingsGetFailuresBeforeSuccess = {},
settingsMutationFailures = {},
uiSettings = defaultUiSettings(),
watermarks = [],
xmltvSettings = defaultXmltvSettings()
}: {
ffmpegProfiles?: unknown[];
ffmpegSettings?: Record<string, unknown>;
hdhrSettings?: Record<string, unknown>;
iptvSettings?: Record<string, unknown>;
loggingSettings?: Record<string, unknown>;
mediaSources?: unknown[];
mediaSourcesFailuresBeforeSuccess?: number;
playoutSettings?: Record<string, unknown>;
resolutionCreateFailure?: { detail?: string; status?: number; title?: string } | null;
resolutionDeleteFailure?: { detail?: string; status?: number; title?: string } | null;
resolutions?: Array<Record<string, unknown>>;
scannerSettings?: Record<string, unknown>;
settingsGetFailuresBeforeSuccess?: Record<string, number>;
settingsMutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
uiSettings?: Record<string, unknown>;
watermarks?: unknown[];
xmltvSettings?: Record<string, unknown>;
} = {}) {
let currentResolutions = resolutions;
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
const remainingSettingsGetFailures = { ...settingsGetFailuresBeforeSuccess };
const currentSettings: Record<string, Record<string, unknown>> = {
'/api/v1/settings/ffmpeg': ffmpegSettings,
'/api/v1/settings/hdhr': hdhrSettings,
'/api/v1/settings/iptv': iptvSettings,
'/api/v1/settings/logging': loggingSettings,
'/api/v1/settings/playout': playoutSettings,
'/api/v1/settings/scanner': scannerSettings,
'/api/v1/settings/ui': uiSettings,
'/api/v1/settings/xmltv': xmltvSettings
};
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const path = input.toString();
const method = init?.method ?? 'GET';
if (path in currentSettings) {
if (method === 'GET' && (remainingSettingsGetFailures[path] ?? 0) > 0) {
remainingSettingsGetFailures[path] -= 1;
return Promise.resolve(new Response(null, { status: 500 }));
}
if (method === 'PUT') {
const failure = settingsMutationFailures[path];
if (failure) {
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
currentSettings[path] = {
...currentSettings[path],
...JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
};
}
return Promise.resolve(jsonResponse(currentSettings[path]));
}
if (path === '/api/v1/settings/resolutions') {
if (method === 'POST') {
if (resolutionCreateFailure) {
return Promise.resolve(
jsonResponse(resolutionCreateFailure, resolutionCreateFailure.status ?? 422)
);
}
const body = JSON.parse(String(init?.body ?? '{}')) as { height: number; width: number };
const created = {
height: body.height,
id: Math.max(0, ...currentResolutions.map((resolution) => Number(resolution.id))) + 1,
isCustom: true,
name: `${body.width}x${body.height}`,
width: body.width
};
currentResolutions = [...currentResolutions, created];
return Promise.resolve(jsonResponse(created, 201));
}
return Promise.resolve(jsonResponse(currentResolutions));
}
if (path.match(/^\/api\/v1\/settings\/resolutions\/\d+$/)) {
if (resolutionDeleteFailure) {
return Promise.resolve(
jsonResponse(resolutionDeleteFailure, resolutionDeleteFailure.status ?? 422)
);
}
const id = Number(path.split('/').at(-1));
currentResolutions = currentResolutions.filter((resolution) => Number(resolution.id) !== id);
return Promise.resolve(new Response(null, { status: 204 }));
}
if (path === '/api/v1/media-sources') {
if (remainingMediaSourcesFailures > 0) {
remainingMediaSourcesFailures -= 1;
return Promise.resolve(new Response(null, { status: 500 }));
}
return Promise.resolve(jsonResponse(mediaSources));
}
if (path === '/api/v1/ffmpeg/profiles') {
return Promise.resolve(jsonResponse(ffmpegProfiles));
}
if (path === '/api/v1/watermarks') {
return Promise.resolve(jsonResponse(watermarks));
}
if (path.startsWith('/api/v1/filler-presets')) {
return Promise.resolve(jsonResponse([]));
}
if (path === '/api/v1/version') {
return Promise.resolve(jsonResponse({ apiVersion: 3, appVersion: '26.4.0' }));
}
if (path === '/api/v1/health') {
return Promise.resolve(jsonResponse([]));
}
return Promise.resolve(jsonResponse(null, 404));
});
}
+42 -2
View File
@@ -14,6 +14,7 @@ import {
ListVideo,
Proportions,
Radar,
Radio,
ScrollText,
Server,
SlidersHorizontal,
@@ -28,6 +29,7 @@ import {
messageFromSettingsError,
updateFfmpegSettings,
updateHdhrSettings,
updateIptvSettings,
updateLoggingSettings,
updatePlayoutSettings,
updateScannerSettings,
@@ -36,6 +38,7 @@ import {
useSettingsScreenQuery,
type FfmpegSettings,
type HdhrSettings,
type IptvSettings,
type LoggingSettings,
type LogEventLevel,
type MediaSource,
@@ -52,7 +55,7 @@ import {
const SETTINGS_BASE_PATH = '/app/settings';
type SectionId = 'general' | 'streaming' | 'playout' | 'xmltv' | 'scanner' | 'logging' | 'system';
type SectionId = 'general' | 'streaming' | 'playout' | 'xmltv' | 'iptv' | 'scanner' | 'logging' | 'system';
interface SectionDef {
hint: string;
@@ -66,6 +69,7 @@ const SECTIONS: SectionDef[] = [
{ hint: 'FFmpeg & transcoding', icon: <Clapperboard aria-hidden="true" size={16} />, id: 'streaming', label: 'Streaming' },
{ hint: 'Build defaults', icon: <ListVideo aria-hidden="true" size={16} />, id: 'playout', label: 'Playout' },
{ hint: 'EPG output', icon: <CalendarDays aria-hidden="true" size={16} />, id: 'xmltv', label: 'Guide (XMLTV)' },
{ hint: 'M3U & stream URLs', icon: <Radio aria-hidden="true" size={16} />, id: 'iptv', label: 'IPTV' },
{ hint: 'Library refresh', icon: <Radar aria-hidden="true" size={16} />, id: 'scanner', label: 'Scanner' },
{ hint: 'Log levels', icon: <ScrollText aria-hidden="true" size={16} />, id: 'logging', label: 'Logging' },
{ hint: 'HDHR, sources, about', icon: <Server aria-hidden="true" size={16} />, id: 'system', label: 'System' }
@@ -115,6 +119,7 @@ function hlsDirectFormatOptions(current: OutputFormatKind): Array<{ label: strin
interface Draft {
ffmpeg: FfmpegSettings;
hdhr: HdhrSettings;
iptv: IptvSettings;
logging: LoggingSettings;
playout: PlayoutSettings;
scanner: ScannerSettings;
@@ -124,12 +129,13 @@ interface Draft {
type GroupKey = keyof Draft;
const GROUP_KEYS: GroupKey[] = ['ui', 'ffmpeg', 'playout', 'xmltv', 'scanner', 'logging', 'hdhr'];
const GROUP_KEYS: GroupKey[] = ['ui', 'ffmpeg', 'playout', 'xmltv', 'iptv', 'scanner', 'logging', 'hdhr'];
function draftFromData(data: SettingsScreenData): Draft {
return {
ffmpeg: data.ffmpeg,
hdhr: data.hdhr,
iptv: data.iptv,
logging: data.logging,
playout: data.playout,
scanner: data.scanner,
@@ -172,6 +178,7 @@ function formatLastScan(value: string): string {
const GROUP_LABELS: Record<GroupKey, string> = {
ffmpeg: 'Streaming',
hdhr: 'System',
iptv: 'IPTV',
logging: 'Logging',
playout: 'Playout',
scanner: 'Scanner',
@@ -189,6 +196,8 @@ async function saveGroup(key: GroupKey, draft: Draft): Promise<Partial<Draft>> {
return { playout: await updatePlayoutSettings(draft.playout) };
case 'xmltv':
return { xmltv: await updateXmltvSettings(draft.xmltv) };
case 'iptv':
return { iptv: await updateIptvSettings(draft.iptv) };
case 'scanner':
return { scanner: await updateScannerSettings(draft.scanner) };
case 'logging':
@@ -703,6 +712,36 @@ function XmltvPane({
);
}
function IptvPane({
iptv,
set
}: {
iptv: IptvSettings;
set: (patch: Partial<IptvSettings>) => void;
}) {
return (
<Pane subtitle="How ErsatzTV advertises itself to IPTV clients in M3U playlists and stream URLs." title="IPTV">
<Card padded={false}>
<Row
control={340}
first
help="Absolute base URL clients should use to reach this server (e.g. http://192.168.1.99:8409 or https://tv.example.com/etv). Leave empty to use the address of each incoming request."
label="Advertised base URL"
>
<Input
fullWidth
onChange={(event) => set({ baseUrl: event.target.value })}
placeholder="(use request origin)"
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={iptv.baseUrl}
/>
</Row>
</Card>
</Pane>
);
}
function ScannerPane({
invalidFields,
scanner,
@@ -1104,6 +1143,7 @@ export function SettingsScreen() {
{section === 'xmltv' && (
<XmltvPane invalidFields={invalidFields} set={(patch) => set('xmltv', patch)} xmltv={draft.xmltv} />
)}
{section === 'iptv' && <IptvPane iptv={draft.iptv} set={(patch) => set('iptv', patch)} />}
{section === 'scanner' && (
<ScannerPane invalidFields={invalidFields} scanner={draft.scanner} set={(patch) => set('scanner', patch)} />
)}
+11
View File
@@ -1,6 +1,17 @@
/* Base reset on <html>/<body>: the browser-default body margin (8px) frames every
full-viewport layout the app shell AND the shell-less .ctv-auth-page boot pages, both
min-height:100vh exposing a light border on all sides (#373). Strip it and paint the app
surface so any residual gap/overscroll stays dark. Matches the design-system convention
(design-system/components/forms/forms.card.html, .../chicorytv-admin/*.html). */
html,
body {
margin: 0;
}
/* Base typography on <body>: Dialog (and any overlay) portals into document.body, outside
.ctv-app-shell without this, portaled UI falls back to the browser default font. */
body {
background: var(--surface-app);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: var(--text-sm, 13px);
color: var(--text-primary, #f0ebe4);