Compare commits

..
Author SHA1 Message Date
timothyandOpenAI Codex 97397906e3 docs(jellyfin): record player-owned playback verdict
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 31s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 31s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Record the isolated Jellyfin 12 live evidence and the server-plugin-only no-go. Remove the discovery snapshot invalidated by Jellyfin's actual playback call path, scope the guide cache to connection settings, and fail closed for dangling mirror sources.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 20:15:35 +02:00
timothyandOpenAI Codex 63f72c7c50 fix(jellyfin): harden player-owned playback probe
Pin discovery/open to a short schedule-identity snapshot without sharing mutable Jellyfin media sources, gate the authorization-incomplete lab probe behind an explicit opt-in, and add focused plugin tests to the main solution. Redact remote URLs and serialize OpenAPI ApiKey requirements correctly.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 19:07:55 +02:00
timothyandOpenAI Codex dd4ce6f378 feat(api): resolve scheduled playback sources
Add an authenticated playback-source endpoint for player-owned playback and document the Jellyfin Live TV proof-of-concept boundary. Cache guide snapshots for the probe plugin while leaving native Jellyfin source handling in control.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 21:44:07 +02:00
timothyandOpenAI Codex 724ca5168f feat(jellyfin): add player-owned playback probe
Add a Jellyfin 12 rc2 ILiveTvService probe that reads ErsatzTV guide/source data and returns native Jellyfin media sources while preserving the LiveTvChannel session item.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 21:37:35 +02:00
timothy ecde59be32 docs(queue): record cheap-worker launch config (#356)
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 / EF migration integrity (SQLite + MySql) (push) Successful in 4m29s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m44s
Merge the reviewed, green documentation update. Fixes #355.
2026-07-14 19:00:01 +00:00
timothyandOpenAI Codex b37924545c chore(ci): retry flaky Libraries screen test
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Retrigger CI after the existing Libraries scan-progress test failed once remotely but passed six consecutive local runs at the same documentation-only PR head.

Refs #355

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 20:51:59 +02:00
timothyandOpenAI Codex a54a2b6f82 chore(ci): retry runner setup for docs PR
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Retrigger CI after the migration job failed in Setup .NET while cleaning a missing runner-cache file, before any migration command executed.

Refs #355

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 20:46:01 +02:00
timothyandOpenAI Codex 67bfc1ceb1 docs(queue): record cheap-worker launch config
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 / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Record the tested GPT-5.4-Mini low-effort worker command, current spawn-agent limitation, and unsupported legacy model trap so future sessions can route bounded work without rediscovery.

Refs #355

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 20:33:21 +02:00
timothyandOpenAI Codex 1e0eaca35e docs(queue): start tool-bearing selectors at low effort
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 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m4s
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 4m29s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m20s
Clarify that enabled web_search makes a cheap worker tool-bearing even when the selector intends to use only Gitea, reserve minimal for tool-free supplied-evidence synthesis, and require hard evidence/output caps.

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 20:03:57 +02:00
timothyandOpenAI Codex 7cbd871ab7 test(spa): close remaining playout gate gaps
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
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 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m50s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m52s
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 4m39s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m5s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m19s
Cover the None action matrix, required add inputs, file-edit blank/trim/payload/refresh/error behavior, and clearing a default deco to null.

Refs #245

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 19:22:41 +02:00
timothyandOpenAI Codex 6b41350e0c test(spa): complete playout kind coverage
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Exercise default-deco persistence and errors plus the complete action, add-dialog, and edit-field kind matrices identified by cold review.

Refs #245

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 19:12:56 +02:00
timothyandOpenAI Codex 533abe7cdb refactor(spa): extract playouts screen
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
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 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Move the Playouts UI, route wrapper, helpers, and detailed behavior tests into a colocated screen module while preserving App-level navigation coverage.

Refs #245

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 18:54:32 +02:00
timothy e454ed94fd Merge pull request 'docs(queue): enforce milestone-aware picker ranking' (#348) from codex/queue-picker-claim-comments 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 4m55s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m53s
2026-07-14 06:03:46 +00:00
timothy ffb3e6d5e1 Merge pull request 'fix(spa): clear dirty guard before saved navigation' (#349) from codex/344-dirty-guard into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (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 / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-14 06:02:35 +00:00
timothyandOpenAI Codex b9305fdee9 fix(spa): preserve remote drafts during save
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 / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Disable and gate remote connection draft inputs while a save owns the current revision. Exercise the real App-owned route transition after a successful save so the regression proves the editor unmounts without a dirty prompt.

Refs #344

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 00:06:29 +02:00
timothyandOpenAI Codex 3481f98bcc fix(spa): clear dirty guard before saved navigation
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fixes #344

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-13 23:47:43 +02:00
timothyandOpenAI Codex e280e9072c docs(queue): enumerate milestone candidates
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m25s
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
Refs #347

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-13 23:31:04 +02:00
timothyandOpenAI Codex d235b91d15 docs(queue): preserve reviewer comment claims
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fixes #347

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-13 23:20:50 +02:00
timothyandOpenAI Codex b2c093d2d6 docs(e2e): record real media-source validation results
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
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 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m26s
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 / 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) Failing after 3m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m20s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Refs #333

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-13 22:51:52 +02:00
timothyandCodex 8da1256ffa docs(workflow): enforce session-wide cost routing
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
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 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 3m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Fixes #342

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 21:43:11 +02:00
timothyandCodex 2609b4ce59 fix: add cross-origin resource policy header
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m15s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m44s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Fixes #330

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 21:25:06 +02:00
timothyandCodex 80751a5d74 docs: require low-cost queue selection preflight
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 4m19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m32s
Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 20:56:23 +02:00
timothyandCodex 985eed072c docs(release): prepare v26.8.0 promotion [decisions-edit]
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 / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m51s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m47s
refs #335

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 19:50:39 +02:00
timothyandCodex 977f9125a5 docs: track #202 real-server integration 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 4m30s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m43s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m36s
refs #202 #333

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 19:05:04 +02:00
timothyandCodex 954aff21f1 docs: make issue queue handoff client-neutral
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
Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 18:59:40 +02:00
timothyandClaude Opus 4.8 a042623a53 fix(ci): #314 review — reap ZAP container in the cleanup() trap; warn on missing semgrep
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15s
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 4m34s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) 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 / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m35s
Cold review of PR #331:
- Move the ZAP-container reap from an inline post-scan line into cleanup() (the
  EXIT trap) so a SIGINT/timeout kill mid-scan can't leave it running.
- semgrep absent now prints a stderr WARN instead of silently skipping the SAST
  pass (no false confidence for a release gate).

refs #314

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:36:08 +02:00
timothyandClaude Opus 4.8 366d5d316c feat(ci): #314 — repeatable black-box security scan (authenticated ZAP + semgrep)
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 / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 10m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
scripts/security-scan.sh: boots a THROWAWAY container from the image under test
(fresh config volume; never the deployed prod/test container — the active scan
attacks write endpoints), reads its machine key, and runs an authenticated OWASP
ZAP api-scan that imports /openapi/v1.json (all 160 /api/v1 ops) and injects
X-Api-Key on every request via a replacer rule so it reaches the
[RequiresAuthentication] + RequireKeyForReads surface — then a semgrep SAST
cross-check. Wrapped in `timeout` because zap-api-scan can hang in post-scan
cleanup after the report is already written.

docs/ci-cd.md: new 'Security scanning' section (out-of-ecosystem black-box gate,
run on the docker host per-release like migration-smoke, not a per-PR CI job) +
the Microsoft.OpenApi 2.7.5 pin note in dependency management.

refs #314 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:28:13 +02:00
timothyandClaude Opus 4.8 940af13a5f fix(deps): #314 — pin Microsoft.OpenApi 2.7.5 (GHSA-v5pm-xwqc-g5wc, High)
Microsoft.AspNetCore.OpenApi 10.0.2 + Scalar.AspNetCore pull Microsoft.OpenApi
2.0.0 transitively — High severity (stack overflow parsing a circular $ref;
fixed in 2.7.5). Direct-pin 2.7.5 in CPM + a direct ErsatzTV PackageReference so
the override actually resolves (the #8 SQLitePCLRaw transitive-override pattern).

Build clean (transformers compile unchanged against 2.7.5), v1.json byte-identical
(no OpenAPI-output change), ErsatzTV + ErsatzTV.Tests both vuln-clear, 198
OpenAPI/contract/security tests green.

refs #314 #8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:09:30 +02:00
timothyandClaude Opus 4.8 2e4e07a207 fix(#172): review B1 — sync 404 metadata test + extend create-group trim to Deco/DecoTemplate
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m51s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 2m41s
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 / Build & test (.NET) (push) Successful in 8m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m40s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m56s
Renovate / Renovate (push) Successful in 2m38s
Dependency vulnerability scan / NuGet vulnerable packages (push) Failing after 1m11s
Cold review (PR #325) caught that removing the unreachable 404 from
BlockController/TemplateController.CreateGroup left ApiErrorResponseMetadataTests
still asserting those ops document 404 -> red Build & test. Removed those two stale
assertions. For spec consistency, extended the trim to the two OTHER create-group
actions carrying the same unreachable 404 (DecoController, DecoTemplateController --
their Create*GroupHandler only do a duplicate-name AnyAsync -> 422, never a lookup
that 404s). Net: all four CreateGroup 404 assertions removed (422 siblings kept),
both controllers trimmed, v1.json regenerated (4 unreachable 404 blocks gone total).
PlaylistController.CreateGroup already carried no 404.

Refs #172

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:16:52 +02:00
timothyandClaude Opus 4.8 216130b4d7 fix(#172): API hardening — null-name 500s, duplicate template items, unreachable 404
Clears the still-live findings from #172 (verified against main; #2/#4/#7 and the
auth/search/Trakt tail were already deliberate-documented or fixed since 2026-07-07).

- Null/empty Name → 500 (10 create/replace handlers). Block/Template/DecoTemplate/Deco
  Create+Replace/Update + UpdateFFmpegProfile did `request.Name.Length > 50` on a
  client-nullable string → unhandled NullReferenceException → HTTP 500 (no global
  exception filter). Now `string.IsNullOrWhiteSpace(request.Name) || .Length > 50` →
  422; also rejects empty/whitespace names, matching the group-create handlers'
  NotEmpty behavior. CreatePlaylist coalesces null→"" at the DTO so it was an
  empty-name persist, not a 500; guarded the same way.
- ReplaceTemplateItems overlap validation iterated with an `item == otherItem`
  record value-equality skip, so two exact-duplicate items were value-equal and
  bypassed the intersection check (both persisted). Now index-based (i != j) so
  duplicates register as a self-intersection and are rejected 422.
- Trimmed the unreachable 404 ProducesResponseType from POST /api/blocks/groups and
  POST /api/templates/groups (a create has no parent lookup that can 404); v1.json
  regenerated.
- Regression tests: all 10 name-guard paths + the duplicate-items path (19 cases).
- Docs: decisions.md entry + api-conventions.md §3b null-safe-validation bullet.

fixes #172

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:16:52 +02:00
timothyandClaude Opus 4.8 682dceec8f fix(api): #286 review — allowlist non-/api routes in the versioning test; base-url-aware deprecation Link
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 3m5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m2s
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 3m49s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m43s
Cold-fork + Codex review of PR #326:
- ApiRouteVersioningTests: iterate IRouteTemplateProvider (covers a
  template-less [HttpGet] paired with an action-level [Route]) and assert
  any non-/api route against an explicit KnownNonApiRoutes allowlist
  instead of silently skipping — an accidental absolute non-/api route
  (which would also escape ApiAuthorizationFilter's /api-scoped gate) now
  fails the test.
- ApiVersionRewriteMiddleware: root the deprecation Link at Request.PathBase
  so it stays correct under ETV_BASE_URL (</etv/docs>, not host-root </docs>).

refs #286 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:40:58 +02:00
timothyandClaude Opus 4.8 ef2bd65c27 feat(api): #286 — mount the whole /api surface at /api/v1
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
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
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.

Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.

Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).

Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.

Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.

fixes #286
refs #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:30:20 +02:00
timothyandClaude Opus 4.8 51b67dea06 fix(#238): review — trakt sub-route no-op + data-driven wiring test
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
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 4m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m35s
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 4m18s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m25s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m26s
Cold-review follow-ups (both non-blocking):
- Low: the TopBar "Add Trakt List" button was a silent no-op on the
  /app/trakt-lists/{id} detail sub-route (setAddOpen state isn't rendered by
  the editor branch, and the screen is keyed by pathname so the state
  wouldn't survive a navigate). Guard on editingId: route back to the list
  from the detail view, open the dialog from the list.
- Nit: the invariant test only spot-checked 2 screens. Replaced with a
  data-driven it.each over the 4 URL-navigating create screens (channels,
  filler, ffmpeg, watermarks) asserting each banner actually navigates — a
  typo'd route id now fails red. Docs wording corrected to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:35:59 +02:00
timothyandClaude Opus 4.8 612b11589c fix(#238): wire TopBar primary-action on create screens, drop dead buttons
The shell TopBar rendered a primary-action button (Plus icon) for every
screen, but only SchedulesScreen subscribed to its ctv:primary-action event
— so every other screen's button was dead (a labelled no-op, or a bare "+"
for the ~10 routes whose primaryAction was '').

Resolution (issue #238): the Plus-icon button is a "create new item"
affordance. Keep + wire it only on the 8 list screens with a single create
flow (channels, schedules, multi/rerun collections, trakt lists, filler
presets, ffmpeg profiles, watermarks) via a shared usePrimaryAction hook
(web/src/primaryAction.ts); drop it (primaryAction: '') everywhere else —
where the action isn't a create (Save/Refresh/Play/Validate/Reset/Scan, all
of which have correct in-body controls), is ambiguous (collections tabs), a
silent no-op (builder, playlists), or misplaced (dashboard, libraries). The
TopBar now renders the button only when primaryAction is non-empty.

Also relabels the apiKey route's stale post-#295 "Save key"/description.
Docs: spa-conventions.md §10 + decisions.md 2026-07-12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:35:59 +02:00
timothy c197de421a Merge pull request 'fix(api): #265 — If-Match evaluates per RFC 7232 (valid-but-non-matching -> 412)' (#322) from fix/265-ifmatch-412 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 8m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m42s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m25s
Merge PR #322: #265 If-Match evaluates per RFC 7232 (valid-but-non-matching → 412)
2026-07-12 21:32:25 +00:00
timothy ffece01c32 style: #265 — normalize whitespace in the 11 touched files (format gate #311)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m29s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 2m56s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The fix-as-you-touch format gate requires every .cs this PR touches to fully conform to
.editorconfig, including pre-existing whitespace on lines the change didn't edit. dotnet format
(whitespace) applied to the 11 touched files; legacy files left untouched (no big-bang reformat).
Whitespace/layout only — no behavior change. Refs #265
2026-07-12 23:21:00 +02:00
timothy 50cd29d841 fix(api): #265 review — quote-aware If-Match scanner, RFC OWS trim, de-BOM
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Failing after 2m30s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent review fix commit (cold fork MERGEABLE-WITH-NITS + Codex BLOCKED, 2 Highs):

- Codex H1: a comma (0x2C) is a valid etagc and can appear INSIDE a quoted opaque-tag
  ("3,5" is ONE tag). The old Split(',') broke it into two malformed tokens → 400. Replaced
  with a quote-aware position scanner that treats a comma as a separator only outside the
  quotes; "3,5" is now one valid non-canonical tag → 412.
- Codex H2: RFC 7230 OWS is SP/HTAB only. string.Trim() also strips NBSP and other Unicode
  whitespace, letting " * " masquerade as the "*" force-write escape. Trim only
  (' ', '\t'); such input is now Malformed → 400.
- Fork nit: corrected the canonical-guard comment (interior-whitespace tags are rejected by
  IsEtagc, not NumberStyles.None).
- CI Formatting gate: de-BOM the 8 touched legacy Application .cs (charset=utf-8, #311/#310).
- Tests: added comma-in-tag ("3,5", "x,y","3"), empty-element tolerance, NBSP-not-OWS,
  trailing-junk, lowercase-weak, wildcard-in-list cases. Full ErsatzTV.Tests green (1556).

Refs #253 #197
2026-07-12 23:09:36 +02:00
timothyandClaude Opus 4.8 8090e10408 fix(api): #265 — If-Match evaluates per RFC 7232 (valid-but-non-matching → 412, not 400)
The shared optimistic-concurrency parser (ConcurrencyHeaders.ParseIfMatch) classified any
non-canonical/weak/list If-Match value as Malformed → 400. Per RFC 7232 §3.1 a syntactically
-valid entity-tag that simply doesn't strong-match must be 412; 400 is only for a genuine
grammar violation.

- Rewrite ParseIfMatch as a real RFC 7232 entity-tag/list parser: walks the comma-separated
  1#entity-tag list, validates each [W/]DQUOTE *etagc DQUOTE member, and collects the strong
  members whose opaque text is our canonical decimal. Weak / empty / non-canonical /
  out-of-range tags are valid but contribute no version (→ empty set → 412); genuine grammar
  violations (unquoted, SP-in-tag, unterminated, garbage) → 400.
- Reshape IfMatchCondition.ExpectedVersion : Option<int> → ExpectedVersions : Option<Seq<int>>
  and VersionedAggregateExtensions.CheckVersion → set membership (any strong match proceeds;
  empty set always 412). Threads through 10 replace/update commands + handlers + request
  mappers + 9 controllers.
- No wire-contract change (400 + 412 already declared on every PUT; the field is header-derived
  and internal — no DTO/route/response-type/OpenAPI change).
- Tests: ConcurrencyHeadersTests rewritten for the new classification (lists, weak, empty,
  non-canonical → Version/empty-set; grammar violations → Malformed) + new
  VersionedAggregateExtensionsTests for CheckVersion membership/empty-set/force-write.
- Docs: api-conventions.md §7a rewritten; decisions.md entry appended.

Refs #253 #197
fixes #265

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:09:36 +02:00
timothyandClaude Opus 4.8 6537697fe5 test(#319): hash-drift guard prefers the built index.html when present
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 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / EF migration integrity (SQLite + MySql) (push) Successful in 4m33s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m19s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m14s
Addresses the cold-review LOW: the guard hashed only the committed source
web/index.html, so a hypothetical future Vite transform of the inline script
would leave the test green while the deployed CSP silently broke the SPA. It now
hashes the built wwwroot/app/index.html when it exists (the exact bytes the
browser hashes — full coverage on any local build), falling back to the source
only in a fresh CI checkout where the built artifact is gitignored/absent. Vite
copies the inline script verbatim today, so the two agree. Doc comments +
decisions.md synced. Test-and-docs only; no production code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:41:24 +02:00
timothyandClaude Opus 4.8 873b3e54a5 security(#319): enforcing CSP + Permissions-Policy on the host
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 1m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Completes the CSP the #279 baseline-headers middleware deferred. Surfaced by
the #314 ZAP baseline (missing CSP/Permissions-Policy WARNs); a #197 exit item.

SecurityHeadersMiddleware now also sets Permissions-Policy (deny-all for
camera/mic/geolocation/payment/usb) and an enforcing Content-Security-Policy on
every response EXCEPT /docs (Scalar) and /openapi — those rely on inline
bootstrap scripts/styles a strict policy would break (baseline headers still
apply; hardening that admin surface is a #197 follow-up).

CSP: default-src 'self'; script-src 'self' + the hash of the SPA's inline
theme-bootstrap script (no 'unsafe-inline'/'unsafe-eval'); style-src adds
'unsafe-inline' (React inline styles) + fonts.googleapis.com; font-src adds
fonts.gstatic.com; img-src adds data:/blob:; object-src 'none'; base-uri 'self';
frame-ancestors 'none'; form-action 'self'.

The Google Fonts allowance (the SPA CSS @imports the Geist web font) was caught
by live-E2E, which the static recon missed. A guard test hashes the committed
web/index.html inline script and fails if it drifts from the middleware constant
(the built wwwroot/app is gitignored/absent in CI, and Vite copies the inline
script verbatim). Verified: full test pass, live-E2E (SPA renders clean, zero
CSP violations), curl (CSP present on /app + /api, absent on /docs + /openapi).

Docs: docs/decisions.md entry.

fixes #319

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:36:44 +02:00
timothy fa655053ba Merge PR #316: #295 PR2 SPA session cutover + #301 side-effecting-GET POST-ification
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) Failing after 1m8s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
#295 PR2: browser SPA authenticates with the session cookie (boot gate: Setup/Login), API key demoted to machine/MCP-only (machine-key management screen), logout. #301: three side-effecting troubleshoot GET/HEAD endpoints + graphics-elements refresh POST-ified so the session-mutation CSRF gate covers them. Reviewed (fork + Codex + Fable reconciliation + fork fix-commit re-review), live-E2E'd (5/5), CI green.

Fixes #295
Fixes #301
2026-07-12 20:04:54 +00:00
timothyandClaude Opus 4.8 be9cf5f381 fix(ci): build before GenerateOpenApiDocuments in update-openapi.sh
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m32s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 2m44s
The new api-docs gate (#303 H4/H5) runs update-openapi.sh after only `dotnet
restore`. The script's `dotnet build -t:GenerateOpenApiDocuments` does not compile
the project (OpenApiGenerateDocumentsOnBuild=false), so in a clean tree getdocument
fails with 'ErsatzTV.deps.json does not exist' (exit 129). This PR is the first to
change the /api surface and thus the first to exercise the gate's regen path,
exposing the latent bug. Add a full `dotnet build` before the doc-gen target so the
assembly + deps.json exist. Verified: clean-tree regen now succeeds with zero diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:42 +02:00
timothyandClaude Opus 4.8 105c0ec88a fix(web): #316 review — playback phase promotion, engine-neutral download test fixtures, 401 signal, AuthSession optional fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:42 +02:00
timothyandClaude Opus 4.8 ec26e1be5b fix(api): #316 review — POST-ify graphics-elements refresh, LockedError→409, no-store machine-key
- GET /api/graphics-elements no longer side-effects; refresh moved to
  POST /api/graphics-elements/refresh (204), closing a CSRF vector on a GET.
- PrepareTroubleshootingPlaybackHandler now returns a typed LockedError from
  both atomic lock-acquire failures; ApiResults.ToErrorResult maps it to 409
  instead of falling through to 422, so a lock lost in the race between the
  controller's pre-check and the handler's atomic acquire still reports 409.
- AuthController.MachineKey sets Cache-Control: no-store + Pragma: no-cache
  on the 200 response carrying the master API key.
- Reworded the stale "subtitleId query parameter" endpoint description now
  that playback/start takes a JSON body.
- Regenerated openapi/v1.json + docs/endpoint-index.md; docs/api-conventions.md
  updated with the LockedError pattern (§3a) and the ToErrorResult table row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:42 +02:00
timothyandClaude Opus 4.8 461c763dc6 docs: #295 PR2 + #301 — decisions entry, api-conventions §9, e2e-local browser flow
- decisions.md: new entry (SPA cookie-only cutover, boot-gate-not-route, #301
  POST-ification rationale, machine-key-read + OIDC-logout residual) + TOC line.
- api-conventions §9: #301 resolved (POST-ify) + 'never add a side-effecting GET'
  standing rule; machine-key endpoint added to the auth surface list; PR2-shipped note.
- e2e-local: fix stale 'no key required' claim (fail-closed since #197) + browser
  setup/login boot-gate flow.
(spa-conventions §5e rewrite landed with the SPA-consumers slice.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:42 +02:00
timothyandClaude Opus 4.8 a9f1a4f6b5 fix(test): #301 update OpenApiErrorResponseContractTests to POST playback/start route
Slice A updated OpenApiContractHonestyTests for the troubleshoot GET->POST route
change but missed the hardcoded [TestCase] rows in OpenApiErrorResponseContractTests
still referencing the removed GET/HEAD /api/troubleshoot/playback.m3u8 409 — the
lookup threw KeyNotFound on the regenerated spec. Point them at the new
POST /api/troubleshoot/playback/start (404/409/422, all ProblemDetails-documented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 3c1e694905 fix(web): #295 align MachineKey wire field to server ({ apiKey }, not { key })
The server returns MachineKeyResponse(string ApiKey) -> JSON { apiKey }, but the
hand-written SPA MachineKey type declared { key } and ApiKeyScreen read result.key,
which would be undefined at runtime (blank key + empty copy). Mocked unit tests
passed against the wrong shape. Align the type, the screen, and both test mocks to
the real { apiKey } contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 de9fc7e8cc feat(web): #295 PR2 SPA consumers — troubleshoot POST/blob downloads + machine-key screen + password change
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 9348a9938a feat(web): #295 PR2 SPA session-auth foundation (boot gate, CSRF header, login/setup/logout)
Replace the API-key SPA model with the session-cookie auth from PR1:

- client.ts: stop sending X-Api-Key; attach X-Csrf on mutating verbs (POST/PUT/
  PATCH/DELETE); add suppressUnauthorizedSignal to skip the global 401 signal on
  expected wrong-credentials 401s (login / change-password).
- api/auth.ts: hand-written wire types (AuthConfig/AuthSession/MachineKey — the
  auth surface is IgnoreApi, deliberately not in generated types) + endpoint fns
  (getAuthConfig/getAuthSession/login/setup/logout/changePassword/getMachineKey);
  keep the notifyUnauthorized/subscribeUnauthorized 401 signal; add
  clearLegacyStoredApiKey. Legacy get/set/clearStoredApiKey retained ONLY so the
  still-shipping ApiKeyScreen (machine-key slice) compiles without a cross-slice
  conflict — the client no longer reads them.
- AuthGate.tsx: boot gate wrapping <App/> outside the shell (mints no URL, deep
  links survive login). checking -> setup | login | ready | error, with a safe
  default AuthContext so App.test.tsx renders without a provider; config-fetch
  failure lands on an explicit error+Retry, never a blank screen.
- LoginScreen / SetupScreen: shell-less centered cards; inline 401 / 409 handling;
  SSO button + local-form gating from AuthConfig.
- UnauthorizedBanner: rewritten to prompt re-login (passive; consults the
  unsaved-changes guard before flipping the gate).
- UserMenu: TopBar sign-out (guard -> logout -> signOut), mounted next to
  ConnectMenu.
- main.tsx: wrap <AuthGate><App/></AuthGate> inside StrictMode.

Tests: client/auth/AuthGate/LoginScreen/SetupScreen/UnauthorizedBanner/UserMenu
(723 pass). Lint + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 88d82266b6 feat(api): #301 POST-ify side-effecting troubleshoot GETs + GET /api/auth/machine-key
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 9dc48118d1 chore: re-trigger CI (api-docs runner checkout-cache flake on 5c5a90af; identical tree)
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 13s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
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 4m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / Build & test (.NET) (push) Successful in 8m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m48s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:33:43 +02:00
timothyandClaude Opus 4.8 5c5a90afde fix(ci): #315 address review nits — drop fragile FAIL_RE, validate --timeout, log image id
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) Failing after 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review (MERGEABLE-WITH-NITS) findings:
- Remove the broad FAIL_RE log-scan (matched benign ErsatzTV startup noise — library
  scans against absent media mounts, EF connection retries — risking a false-FAIL that
  blocks a good release). It was also redundant: a failed migration faults the
  BackgroundService -> default StopHost -> container exit, which the early-exit check
  already catches reliably (per the reviewer's own analysis). Migration failure is now
  detected by early container exit + timeout + the post-boot serve probe.
- Validate --timeout is a positive integer (was: '--timeout abc' -> 0 -> instant false-FAIL).
- Log the resolved image id after (attempted) pull, so a pull-failure that rehearses a
  stale local :latest is visible to the operator.

Re-validated live on bumblebee: :latest vs the 283MB prod-copy -> migrations clean, PASS,
image digest logged, no leftover temp dir/container. shellcheck + bash -n clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:23:45 +02:00
timothyandClaude Opus 4.8 4da2b67ab2 feat(ci): #315 migration-on-prod-copy smoke for the release path
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 14s
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 / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
CI's migrations job only proves a migration is well-formed against a fresh,
empty DB. It never exercises the migration — or ErsatzTV's startup data steps
(DatabaseMigratorService -> DbInitializer + PopulatePathHashes over the real
MediaFile table) — against the accumulated prod SQLite, so a migration green on
a fresh DB can still fail/corrupt on prod, found only mid-deploy.

scripts/migration-smoke.sh rehearses it on a THROWAWAY copy of the latest prod
backup: boots the new image against the copy, gates PASS on the "Done applying
database migrations" log line (the migrator is a BackgroundService running
concurrently with Kestrel, so HTTP readiness alone doesn't prove migrations
finished), FAILs on early container exit / migration exception / timeout / not
serving afterwards. Always operates on a copy, never the live DB; tears down its
container + temp dir (incl. the container's root-owned config files) on exit.

Validated live 2026-07-12: :latest vs a copy of the 283MB prod backup ->
migrations applied cleanly, app booted+served, temp dir removed.

Home split: this repo owns the script + docs; wiring it into the Komodo
pre-deploy step is server-management#589 (cross-repo). Docs: docs/ci-cd.md
(Migration integrity), docs/decisions.md (new entry, pure insertion).

fixes #315

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:18:16 +02:00
timothyandClaude Opus 4.8 90c8348efe fix(process): #317 docs-only exemption stays passthrough, not silent auto-grant [decisions-edit]
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m30s
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 4m33s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m33s
Adversarial-review nit on the first commit: auto-granting the docs-only exemption
silently self-merges process-control PRs (.claude/.gitea/.husky — including the
gate hook itself) with no prompt and no review, bypassing human-in-the-loop for
exactly the files that control the gate. Restrict auto-grant to the genuinely-
satisfied (a+b+c) merge path; the docs/process exemption reverts to bare exit-0
passthrough (one normal prompt). Corrects this PR's own decisions.md entry
accordingly ([decisions-edit]: a not-yet-merged draft entry, not history).

8 pipe tests green (adds docs-only -> passthrough).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:58:26 +02:00
timothyandClaude Opus 4.8 8565f731cd fix(process): #317 merge-consent gate auto-grants on satisfied path (no double-prompt)
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m39s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The H6/H10 merge-consent hook's satisfied path did a bare `exit 0`, which does
not auto-approve a PreToolUse tool call — it only declines to block, so control
fell through to the normal permission system and the raw MCP prompt still fired.
A ready-to-merge PR was therefore confirmed twice (conversationally + a redundant
mechanical prompt). Emit permissionDecision "allow" (new `grant` decision) on the
satisfied and docs-exempt paths so the derived state IS the consent; deny/ask
unchanged (fail-closed); non-merge methods keep the exit-0 passthrough.

Docs: CLAUDE.md, kickoff HARD CONSTRAINTS, docs/decisions.md (append-only, pure insert).
Verified: 7 pipe tests (satisfied->allow, unticked->deny, stale->deny, red-CI->deny,
no-verdict->ask, no-creds->ask, non-merge->passthrough).

fixes #317

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:52:26 +02:00
timothy 9b3b3963fe Merge pull request 'process: #311 H11 rebase-hook + PR-scoped format CI + #312 H12 qualification audit' (#313) from ci/311-rebase-hook-format-ci into main
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / Build & test (.NET) (push) Successful in 5m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m24s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m35s
2026-07-12 18:32:19 +00:00
timothyandClaude Opus 4.8 f1b2521228 chore(process): #312 H12 — session-end issue-qualification audit
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / 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 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m31s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
scripts/issue-qualification-audit.sh: lists OPEN issues missing a `priority:` label
(the #237 ranking keys off priority:/gate labels, so an unlabeled issue is invisible
to it). "Fully qualified" = has a priority: label; gate-vs-backlog derives from the
review label / milestone, and a milestone is NOT required (backlog is unmilestoned).
Advisory (exit 1 if any unqualified); fail-open without Gitea creds. Wired into the
kickoff session-end protocol + a lore bullet. Tested live (flagged 2) + no-creds no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:21:20 +02:00
timothyandClaude Opus 4.8 19dbfe9a8b docs(process): #311 — formatting-as-you-touch (§7) + rebase-not-merge lore + decisions entry
- contributing.md §7: document fix-formatting-as-you-touch (normalize a legacy file you
  edit, incl. BOM strip; no big-bang), enforced by the pre-commit hook + the new format CI job.
- kickoff lore: flip "merge main into PR branch" -> "rebase on origin/main" (H11 enforces it);
  keep the regenerate-generated-artifacts-on-conflict nugget.
- decisions.md: new entry (+ TOC) for both decisions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:17:44 +02:00
timothyandClaude Opus 4.8 3d07c6818d ci(process): #311 H11 rebase-before-push hook + PR-scoped format-verify CI job
- H11: .husky/pre-push calls .claude/hooks/prepush-rebase-check.sh, which blocks a
  push whose branch is behind origin/main (rebase first; do not merge main in — a
  merge drags in files you didn't touch, e.g. legacy-BOM .cs, tripping the format
  hook on code that isn't yours). Fail-open; escape ETV_SKIP_REBASE_CHECK=1.
- New blocking `format` CI job: dotnet format --verify-no-changes scoped to the
  PR's changed .cs only (style + charset=utf-8/no-BOM), enforcing fix-as-you-touch
  without a big-bang reformat of the ~2500 legacy BOM files. .cs-free PRs skip and
  pass (always reports a status). Closes the "CI never checks charset" gap that let
  #269 land 17 BOM files (#310).

Docs (contributing.md §7 / decisions.md / lore) follow in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:16:00 +02:00
timothy cf4cb5117e Merge pull request 'docs(process): #303 follow-ups — Codex-skip rubric + write-path live-E2E requirement' (#309) from ci/303-process-followups into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / Build & test (.NET) (push) Successful in 8m16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m37s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m9s
2026-07-12 18:13:33 +00:00
timothyandClaude Opus 4.8 b20ee50b1f docs(process): #303 follow-ups — Codex-skip rubric + write-path live-E2E requirement
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 / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Two of the four non-hook #303 process follow-ups (the two docs items; the
security scan and migration-on-prod-copy smoke are deferred to their own
sessions):

1. Codex-skip rubric (kickoff workflow lore): an independent review pass is
   MANDATORY for diffs touching locks/concurrency, auth/security, API
   write-path handlers, or migrations, or >~150 changed C# lines; skippable
   only for a pure-SPA/docs leaf, and a skip must be stated + justified. Makes
   self-exemption an auditable claim (the correlated-blindspot net).

2. Live-E2E is now a STATED REQUIREMENT for API write-path handler changes:
   new "When live-E2E is required" section in docs/e2e-local.md + a decisions.md
   entry, formalizing the #229 lore bullet. The seeding recipe was already in
   e2e-local.md (added for #220), so the stale "recipe not yet in docs" lore
   bullet is pruned to a pointer.

Docs-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:02:36 +02:00
timothyandClaude Opus 4.8 7a9b30de71 fix(api): #269 review fixes — rebase force-write delta so rotation survives a race (Codex F1/F3)
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / EF migration integrity (SQLite + MySql) (push) Successful in 3m43s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m51s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m26s
Independent Codex review (reconciled by Fable against a MERGEABLE fork verdict) found
SaveChangesForcingVersion silently DROPPED a pending Version++ under a concurrent
versioned-write race: on DbUpdateConcurrencyException it adopted the DB's current
Version verbatim (original = current = dbVersion), so a bumping sibling committed at
dbVersion instead of dbVersion+1. Net: an editor holding the concurrent writer's ETag
was never invalidated by the sibling's change — the exact lost-update the #253/#269
contract exists to close, lost under the very condition the helper handles.

F1 fix (shared helper, corrects all 25 bumpers incl. the pre-existing Add*ToPlaylist /
schedule-item writers): rebase the pending delta on top of the stored token —
  pendingDelta = current - original; original = dbVersion; current = dbVersion + pendingDelta
Bumpers (delta 1) advance to dbVersion+1; non-bumpers/deletes (delta 0, e.g.
ErasePlayoutHistory) still adopt the stored token unchanged, so RootWriterForceVersionTests
is unaffected. Idempotent across the bounded retry loop.

F3: the force-race tests now assert Version==3 (rebase), not just membership survival;
added the missing Playout force-race+rotate test. Negative-controlled: with the helper
fix reverted, both strengthened tests go red.

F2 (Medium, deferred → #308): two concurrent same-item Add*ToCollection can both pass the
membership check and the loser 500s on the composite-PK violation (DbUpdateException, which
the helper doesn't catch). Pre-existing and narrow (no corruption); doc claims softened to
name it. Filed #308.

Docs: api-conventions §7a + decisions.md prose corrected from "adopt the stored token" to
the rebase semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:40:32 +02:00
timothyandClaude Opus 4.8 83f753b211 fix(api): #269 rotate aggregate ETag on Collection/Playout config siblings
Complete the #253 optimistic-concurrency contract's cross-editor ETag
rotation tail. The non-If-Match config siblings mutated editor-visible
state without bumping Version, so a concurrent editor of the same root
never invalidated. Now the Collection Add*/Remove handlers bump
Collection.Version, and UpdateCollection / UpdatePlayout / the three
ScheduleFile writers (which already force-wrote past a concurrent bump)
now bump too — all via SaveChangesForcingVersion (no If-Match → force
write, never 412/500).

No-op idempotence (Fable-caught trap): these gate reindex/BuildPlayout
fan-out on SaveChanges()>0, so an unconditional bump would fire spurious
rebuilds on an idempotent re-add / same-value re-submit. Each now
short-circuits a genuine no-op before the bump — Add handlers by an
explicit membership check (also fixing a latent duplicate-CollectionItem
insert), scalar writers by ChangeTracker.HasChanges().

Corrects #269's framing: the Add*ToCollection family is not
repository-mediated (IMediaCollectionRepository is read-only); each
handler writes via its own dbContext, so the scanner's separate
membership path is unaffected (a background scan does not rotate the
editor ETag).

Tests: CollectionEtagRotationTests + PlayoutScheduleFileEtagRotationTests
(rotation, no-op-without-bump-or-rebuild, force-write-past-concurrent-bump),
no-op guard proven non-vacuous by inverting the membership check.
Docs: api-conventions §7a + decisions.md. No new status codes / no
OpenAPI change (these endpoints take no If-Match, never 412).

The #265 RFC-7232 If-Match parser refinement is a separate PR.

fixes #269

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:40:32 +02:00
timothy 2879c012ba Merge pull request 'chore(process): #303 H10 — review-verdict merge-gate (latest commit must be reviewed)' (#306) from ci/303-h10-review-verdict-gate into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (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 / Build & test (.NET) (push) Successful in 8m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m16s
2026-07-12 17:31:59 +00:00
timothyandClaude Opus 4.8 938733c3d0 fix(process): #303 H10 — anchor is_pos to the leading marker (monotonic hardening)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Final re-review returned SHIP-IT with one contrived, pre-existing residual: a
line starting `Review-verdict: BLOCKED …` that ALSO contains a second literal
`review-verdict: mergeable` substring later on the same line read as positive.
Anchor the is_pos check to line-start so only the line's OWN leading verdict
word counts. Safe-by-construction: anchoring a positive matcher can only REDUCE
the allow-surface, so it cannot introduce a false-open (the dangerous
direction); the 21-case regression confirms no false-deny (all still
allow/deny/ask as before) + a new B6 case for this exact vector. No further
review round needed — the change is monotonic. shellcheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:14:37 +02:00
timothyandClaude Opus 4.8 ff3df39c43 fix(process): #303 H10 — anchor verdict marker to line-start (close self-reference false-open) [decisions-edit]
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 / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m59s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m13s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Second adversarial re-review (of the fix commit itself — dogfooding H10) found
a remaining false-open: the `review-verdict:` marker was matched anywhere on a
line, so a comment merely QUOTING the positive template — an instruction
("please post: Review-verdict: MERGEABLE @ <head>"), a blocking comment
explaining how to clear itself, or the gate's OWN suggestion text echoed into a
comment — was classified as a real head verdict and self-approved the merge.

Fix: anchor the marker to line-start (`^[[:space:]]*review-verdict:`). A real
verdict line starts with the marker; quoted/instructional uses have text before
it. Also drops the dead `nosha` var (SC2034).

Finding 2 (a BLOCKED mis-anchored to an OLDER sha doesn't retract a
MERGEABLE@head) is deliberately NOT "fixed": staleness is symmetric — a
pre-fix BLOCKED@oldsha must not block forever after the fix changes the sha and
earns a fresh MERGEABLE@head. To retract, re-review head and post BLOCKED@head.
Documented in decisions.md.

Pipe-tested 21 cases (5 new: instructional-quote, self-reference reason-text,
line-start-in-multiline, leading-whitespace, blocking-quotes-template), all
deny/ask/allow correct. bash -n + shellcheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:11:05 +02:00
timothyandClaude Opus 4.8 f87a2092c4 fix(process): #303 H10 — anchor sha match to the @<sha> field; retraction-wins [decisions-edit]
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review found false-opens in the first cut:
- `grep -F "$short"` was an unanchored substring test: a MERGEABLE verdict for
  a DIFFERENT/older commit was accepted whenever the head 7-prefix appeared
  anywhere on the line (inside a longer sha, or an unrelated commit URL). Now
  each verdict line's `@ <sha>` token is extracted and matched to head by git
  short-sha prefix semantics (head begins with token, token >=7 chars).
- No retraction semantics: a later `BLOCKED @ head` didn't override an earlier
  `MERGEABLE @ head`. Now a negative verdict on head wins -> deny.
- A 7-digit build number falsely tripped the "references an older commit" deny;
  the `@`-anchored parse fixes it -> a marker with no @sha now asks, not denies.

Also documents the issue-comment scope (gate reads issues/{pr}/comments, not
Gitea formal-review bodies). Pipe-tested: 16 cases incl. 4 adversarial
false-open reproductions, all now deny/ask. bash -n + shellcheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:01:51 +02:00
timothyandClaude Opus 4.8 9fd8f40541 chore(process): #303 H10 — review-verdict merge-gate (latest commit must be reviewed)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (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 8m4s
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
Folds condition (c) into pretooluse-merge-consent.sh (H6): a PR merge is
allowed only when a `Review-verdict:` comment references the PR's CURRENT
head sha — proving the latest commit was reviewed, not a stale earlier diff
(mechanizes the ersatztv#242 "re-review the fix commit" lesson).

Graceful adoption mirrors H6's Done-when tiering:
- positive verdict @ head        -> allow
- verdict @ older sha (stale)    -> deny  (#242 failure mode)
- head verdict negative          -> deny
- marker with no sha / none yet  -> ask
- comments unfetchable           -> ask

Reuses H6's PR fetch, docs-only exemption, and Gitea-auth-from-env (one hook,
no detection drift — per the #303 methodology review). Pipe-tested 12 cases.

Docs: decisions.md (new H10 entry + TOC), CLAUDE.md Task Completion Protocol.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:54:05 +02:00
timothy 0123e58914 Merge pull request 'chore(process): #303 Wave 3 — H3 root-screenshot guard + H9 decisions.md append-only guard' (#305) from ci/303-wave3-guards 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 / Build & test (.NET) (push) Successful in 8m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m23s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m24s
Merge pull request '#303 Wave 3 — H3 root-screenshot guard + H9 decisions.md append-only guard' (#305) from ci/303-wave3-guards into main

Part of #303. Adds the Husky/CI append-only guard for docs/decisions.md (with the [decisions-edit] escape, mark-and-keep supersession, and a 1800-line read-cost consolidation floor) and the root-screenshot pre-commit guard. Reviewed MERGEABLE; user-approved merge.
2026-07-12 16:38:40 +00:00
timothyandClaude Opus 4.8 9a6c98f629 ci: re-trigger — MySql migration-integrity flake on prior run (no code change)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 15s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Run 553 hit the known EF-migration MySql-apply flake (concurrent-runner contention;
MEMORY ci-migration-job-mysql-flake). This PR touches only hooks/docs/yaml — the
identical pipeline passed fully green on the parent commit (run 552). Empty commit to
get a clean run; Gitea 1.24 has no job-rerun API endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:26:41 +02:00
timothyandClaude Opus 4.8 6e92a951eb feat(process): #303 decisions.md consolidation size-floor (read-cost, non-blocking) [decisions-edit]
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) Failing after 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Timothy's refinement: the between-releases consolidation floor triggers on the file's
READ COST — its line count, i.e. the context an agent burns reading the log — not entry
count. The decisions-guard CI job now emits a non-blocking ::warning:: once decisions.md
exceeds 1800 lines (the point past which it no longer fits one default 2000-line agent
Read). Documented in the decisions.md header, the H9 entry, and ci-cd.md (job description
+ release-checklist note). Touches committed H9 lines, hence [decisions-edit].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:23:58 +02:00
timothyandClaude Opus 4.8 59ecafecce docs(process): #303 fold review nits — H3 case-insensitive, granularity wording, newline note [decisions-edit]
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 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review (PR #305, MERGEABLE) nits:
- H3: `grep -iE` so a root `Screenshot.PNG` is caught too (was lowercase-only).
- decisions-guard.sh: comment the trailing-newline assumption (dropping the final
  newline would make git render the next append as a last-line modify -> false-block;
  self-correcting via [decisions-edit], .editorconfig enforces the newline).
- docs: clarify CI is PR-wide (`range`) vs Husky per-commit (`staged`) — shared
  detection logic, deliberately different granularity; local hook is the stricter gate.
  Replaces the slightly-overstated "can't drift" wording. Touches the committed H9
  decisions.md entry, hence the [decisions-edit] token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:18:30 +02:00
timothyandClaude Opus 4.8 997f96c1f5 docs(process): #303 append-only supersession + consolidation convention [decisions-edit]
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
decisions.md header now documents the enforced append-only rule, the `[decisions-edit]`
escape, mark-and-keep supersession (banner on the reversed entry + `(superseded)` TOC
tag), and consolidation at each release. ci-cd.md documents H3/H9 hooks + the
decisions-guard job and adds a "consolidate decisions.md" step to the release checklist.
New decisions.md entry records the H9/H3 mechanization. The header edit modifies existing
lines, so this commit carries the [decisions-edit] token — the guard working as designed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:13:13 +02:00
timothyandClaude Opus 4.8 56ef1df936 feat(process): #303 H3 root-screenshot guard + H9 decisions.md append-only guard
H3 (Husky pre-commit): reject a staged root-level *.png — belt-and-suspenders with
the .gitignore screenshot rule so `git add -f` still can't land a review artifact.

H9 (append-only decisions.md): new shared hook `.claude/hooks/decisions-guard.sh`,
wired into Husky commit-msg (staged mode) and a new blocking `decisions-guard` CI job
(range mode). Blocks any commit/PR that deletes or modifies an existing line of
docs/decisions.md — detected via `git diff --numstat` deleted-count, robust to markdown
`-` list markers — unless the message carries the `[decisions-edit]` token. Pure
insertions (a normal new entry) always pass. One implementation for local + CI so they
can't drift. Fail-open on any tooling trouble.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:13:12 +02:00
timothy a81f024840 Merge remote-tracking branch 'origin/main' into ci/303-api-docs-blocking
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m21s
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 / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m30s
2026-07-12 17:43:29 +02:00
timothy 08d633c687 Merge pull request 'fix(api): #269 force-write non-If-Match root writers past a concurrent Version bump' (#302) from fix/269-force-version-on-root-writers into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m53s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m57s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-12 15:43:09 +00:00
timothy c34d2bdbf2 Merge remote-tracking branch 'origin/main' into ci/303-api-docs-blocking
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts:
#	docs/decisions.md
2026-07-12 17:34:48 +02:00
timothyandClaude Opus 4.8 0badff811d feat(process): #303 H6 merge-consent derived from ## Done-when checklist
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m48s
Wave 2 hook H6: derive merge-consent from state instead of memory. An
issue's ## Done-when checklist (issue body) becomes the machine-readable
source of truth for whether its PR may merge — the structural fix for the
queue-drift #303 tracks (status was living in append-only prose).

- pretooluse-merge-consent.sh (Claude PreToolUse on mcp__gitea__
  pull_request_write): a merge is ALLOWED only when the PR's CI is green
  AND every ## Done-when box on the linked issue (fixes #N) is ticked;
  DENY on an unticked box / red CI; ASK (human prompt) when state isn't
  derivable (no linked issue, no section, no creds, Gitea down). Docs/
  process-only PRs exempt.
- .husky/pre-push -> prepush-donewhen.sh: fail-open backstop for a direct
  `git push origin main`; blocks only on a positively-proven unticked box.

Gitea auth from env only (ETV_GITEA_BASICAUTH / ETV_GITEA_TOKEN,
ETV_GITEA_URL) — nothing committed; without creds the gate degrades to
today's manual confirmation, never a silent pass. Non-breaking rollout:
until issues adopt ## Done-when the merge hook simply asks.

Pipe-tested: non-merge->allow, no-creds->ask, docs-only->allow, checklist
parser (unit), linked-issue extraction, and a live end-to-end block path
(temp Done-when on #303 -> exit 1 -> restored). Docs: CLAUDE.md Task
Completion Protocol + decisions.md entry. Refs #303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:29:34 +02:00
timothyandClaude Opus 4.8 aa2e13fa51 ci: #303 H4/H5 blocking api-docs gate — fail on stale OpenAPI artifacts
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adds a blocking `api-docs` CI job: when a PR diff touches the API surface
(ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**) it rebuilds the
generated artifacts from source — v1.json, v1.d.ts, endpoint-index.md —
and fails if any is stale in the diff. Mechanizes the "docs-update in the
same PR" rule for the API contract (docs-reminder stays a non-blocking
route-parity nudge).

Path-gated INSIDE the job (per-step `if:` on a detect output), not via a
top-level `if:`, so the check always reports a status on every PR and is
safe as a required check: API-free PRs skip the dotnet/node setup + regen
and pass trivially.

Verified the gate reproduces the committed baseline: a fresh build
regenerates v1.json byte-identical to HEAD (incl. all 244 auth
security/401 blocks). The only footgun is local — update-openapi.sh runs
dotnet-getdocument against the already-built assembly, so a stale bin/
emits a stale spec; api-conventions.md §5 now flags "build first". CI is
immune (fresh checkout has no bin/).

Docs: api-conventions.md §5 (two-place CI enforcement + stale-assembly
note), decisions.md (new entry). Refs #303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:18:09 +02:00
timothy 5a12aae66e Merge remote-tracking branch 'origin/main' into fix/269-force-version-on-root-writers
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts:
#	docs/decisions.md
2026-07-12 17:17:46 +02:00
timothy 171d30c709 Merge remote-tracking branch 'origin/main' into fix/269-force-version-on-root-writers
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m25s
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
# Conflicts:
#	.gitignore
2026-07-12 17:14:56 +02:00
timothyandClaude Opus 4.8 21b49e6a42 chore(#269): remove accidental web/node_modules symlink from PR
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fix-commit re-review (cold fork) caught that e383c253 tracked a machine-specific
absolute-path symlink `web/node_modules -> /Users/.../web/node_modules` (created
for the eslint pre-push hook). It slipped past `.gitignore` because the
`web/node_modules/` trailing-slash pattern matches only a directory, not a
symlink; a real node_modules dir (a copy) would have been ignored. Untrack it and
tighten the ignore to `web/node_modules` (matches symlink or dir) so it can't
recur.

Also tightens the §7a / decisions.md "they already catch" phrasing (LOW review
nit): only BuildPlayoutHandler catches; PlayoutTimeShifter is insulated by running
solely on the background worker, never the request path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:09:08 +02:00
timothyandClaude Opus 4.8 48d256f83b Merge origin/main into feat/295-auth-pr1
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m36s
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 / Build & test (.NET) (push) Successful in 6m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m11s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m36s
Brings in the #303 process/rigor hooks + docs. Only conflict-free overlap was
docs/decisions.md (main added the ## Index TOC); reconciled by adding the #295
auth entry to the index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:07:52 +02:00
timothyandClaude Opus 4.8 35e41fdaad chore(process): #303 H7 worktree-owner guard + complete Wave 1 wiring
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m13s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Wave 2 hook H7: never commit/merge inside a sibling worktree another
session created (burned us on #289 path-leak + the plumbing-merge
workaround). Ownership = a per-session .claude-worktree-owner marker:
- posttooluse-worktree-marker.sh stamps a worktree with session_id on
  `git worktree add` (parses the <path> arg past -b/-B/--reason flags).
- pretooluse-worktree-guard.sh denies `git commit`/`git merge` whose
  effective dir (resolves `git -C <p>` and leading `cd <p> &&`) is a
  worktree whose marker names a DIFFERENT session. Fail-open: no marker,
  unparsable, or own session -> allow. Main tree + pre-convention
  worktrees are never marked, so unaffected.

Also completes Wave 1's rollout, which committed pretooluse-bash-guard.sh
but left .claude/settings.json and the agent-ram/nav-guard hooks
untracked (so nothing was actually wired). Adds the settings.json that
registers all five hooks (PreToolUse Bash x2, nav, Agent; PostToolUse
Bash) + the .gitignore worktree-marker line, screenshot-scratch rules,
and the decisions.md TOC left uncommitted last session.

All hooks pipe-tested (7 guard cases + 6 marker cases). Refs #303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:01:52 +02:00
timothyandClaude Opus 4.8 e383c253cc fix(api): #269 review — force-write ErasePlayoutHistory + document boundaries
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m54s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent adversarial review (cold fork + Codex) of the first cut converged
on one real miss and two boundaries to document.

- **ErasePlayoutHistoryHandler** (HIGH, both reviewers): modifies Playout ROOT
  scalars (Seed/Anchor/OnDemandCheckpoint) *without* bumping Version, inside an
  explicit transaction with no try/catch, so it 500s on a concurrent bump —
  reachable via POST /api/playouts/{id}/erase-items-and-history. My first sweep
  filtered on "Version-bumpers + deletes"; the true exposure surface is "any
  handler leaving a versioned root Modified/Deleted", so this slipped through.
  Now routes through SaveChangesForcingVersion (+ a non-vacuous through-handler
  test that exercises the explicit-transaction path). Re-swept with the correct
  filter: ErasePlayoutItems (AsNoTracking + ExecuteDelete children only) and
  ResetAllPlayouts (read-only + enqueue) are NOT exposed.

- **Background build/time-shift Playout-scalar writers** (BuildPlayout via
  PlayoutBuilder, PlayoutTimeShifter): token-guarded too, but intentionally left
  on plain save — they already catch (build-failure, not 500), and force-writing
  would persist output built from stale config (the concurrent config bump already
  enqueues a rebuild). Documented as a deliberate boundary, not a gap.

- **Item-add index collision** under force-write: documented as an accepted
  Phase-1 effect (non-corrupting, self-correcting; reload-recompute refinement
  is a #197 candidate).

Also corrects the docs' "every Version bumper" framing to the true filter and the
test docstring's over-broad non-vacuity claim. Full ErsatzTV.Tests green (1483).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:59:01 +02:00
timothyandClaude Opus 4.8 98e4767322 chore(process): #303 rigor hooks Wave 1 + decisions.md TOC + screenshot gitignore
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m9s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m22s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Wave 1 rigor-enforcement hooks (methodology review, #303), fail-open + pipe-tested:
- pretooluse-bash-guard.sh  — deny golden-baseline regen env var in command position (H1)
- pretooluse-nav-guard.sh   — deny browser-tab nav to /iptv,.m3u8,/artwork (H2)
- pretooluse-agent-ram.sh   — RAM-gate Agent/Task spawn, deny <10%% / ask <20%% (H8)
- .claude/settings.json wires the three PreToolUse matchers

Also: docs/decisions.md gains a 44-entry Index/TOC (additive); .gitignore ignores
stray root *.png + .playwright-mcp/ (E2E scratch). Wave 2/3 + follow-ups tracked in #303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:53:25 +02:00
timothyandClaude Opus 4.8 e8c3481ea5 fix(api): #295 PR1 — fold in fix-commit re-review (2nd Codex round)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fix-commit re-review confirmed the 1st-round fixes resolved and caught a 2nd round:

- 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 credential persists, defeating env recovery). Fixed
  structurally: the setup-claim endpoint is CLOSED (409) whenever
  Auth:LocalAdmin:Password is configured — the env seed owns the credential, so
  there's no claim to race (also strengthens the setup-claim TOFU posture).
  Config.setupRequired reflects it.
- LOW — a concurrent setup race-loser now returns 409 (not 422); ClaimLocalAdmin's
  DbUpdateException catch re-checks existence and rethrows genuine/transient DB
  errors instead of masking them as "already configured".
- MEDIUM (accepted, documented) — two simultaneous authenticated password changes
  are a non-serializable lost-update; accepted for a single-admin system
  (self-healing via re-login, implausible timing).

+3 AuthController tests (env-seed closes setup / setupRequired gating). Full
ErsatzTV.Tests green (1506); no generated drift. Docs updated.

Refs #295

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:52:23 +02:00
timothyandClaude Opus 4.8 9a9aaf0740 fix(api): #295 PR1 — logout ends the session server-side (E2E-caught)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Live E2E found that replaying a pre-logout cookie still authenticated (200, not
401): SignOutAsync only clears the CLIENT cookie, but the stateless encrypted
cookie ticket stays valid server-side because its security stamp is unchanged —
a captured cookie was replayable after logout until ticket expiry.

Fix: logout now rotates the local-admin security stamp (RotateLocalAdminSecurityStamp),
so every outstanding local session (old stamp) fails OnValidatePrincipal on its
next request. For the single admin this is "log out everywhere". Gated on an
authenticated local session so an unauthenticated caller can't force-revoke the
admin. OIDC sessions (no stamp) are unaffected; SignOutAsync still clears the
client cookie for UX.

+2 handler tests (rotate-when-configured / no-op-when-unconfigured). Auth suite
green (21). Docs: decisions.md note updated.

Refs #295

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:46:41 +02:00
timothyandClaude Opus 4.8 6ac5150fd0 fix(api): #295 PR1 — fold in cold-fork + Codex review findings
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent review (cold fork = MERGEABLE-WITH-NITS; Codex = BLOCKED, caught
concurrency defects the fork missed). All actionable findings folded in:

- HIGH (Codex) atomic first-claim-wins: ClaimLocalAdmin now writes the three
  credential rows in ONE transaction guarded by the unique ConfigElement.Key
  index (lost race -> DbUpdateException -> 409), so concurrent claims can't
  produce a mixed-state credential.
- HIGH (Codex) consistent login snapshot: VerifyLocalAdminLogin reads hash+stamp
  in one query and drops rehash-on-verify, so a login racing a password change
  can't capture a stamp newer than the hash it verified (concurrent change ->
  old password fails, or the issued cookie carries the pre-change stamp ->
  revoked next request).
- MEDIUM (Codex) env-seed migration race: LocalAdminSeedService is now a RunOnce
  BackgroundService that awaits SystemStartup.WaitForDatabase (the migrator is a
  BackgroundService; registration order didn't guarantee the schema) + try/catch.
- MEDIUM (fork M1) ForwardedHeaders: reverted the strict-opt-in flip — it would
  regress /iptv M3U/XMLTV/HLS absolute-URL generation (Request.Scheme) behind a
  proxy without KnownProxies. Kept #285 behavior; KnownProxies still recommended.
- LOW (Codex/fork) require X-CSRF on /api/auth/logout + /password (the
  [SkipApiAuthorization] surface isn't covered by the filter's CSRF check;
  closes forced-logout CSRF).
- ChangeLocalAdminPassword also writes hash+stamp atomically. Input length caps
  on username/password.

Deferred with a tracked gate: MEDIUM (Codex) side-effecting [RequiresAuthentication]
GETs (troubleshoot playback/archive) aren't CSRF-covered -> #301, gates PR2
(latent in PR1: the SPA still uses the machine key).

Verify: full ErsatzTV.Tests green (1501); no OpenAPI/generated drift. Docs updated
(api-conventions §9, decisions.md).

Refs #295 #301

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:40:25 +02:00
timothyandClaude Opus 4.8 d80bf886b2 fix(api): #269 force-write non-If-Match root writers past a concurrent Version bump
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Activating #253's `Version` as an `IsConcurrencyToken` made EF guard every
UPDATE *and DELETE* of a versioned root with `WHERE Version=@orig`, so any
writer outside the If-Match contract that saves via plain `SaveChangesAsync`
throws an unhandled `DbUpdateConcurrencyException`->500 when a replace-all
editor bumps the row in its narrow load->save window (ordinary two-tab UI).

A completeness sweep (grep every `Version` bumper + every root delete, not
just the handlers PR3's close note named) found 17 exposed writers, all now
routed through `ConcurrencyExtensions.SaveChangesForcingVersion` (Phase-1
force-write: adopt the stored token and retry; rethrow only on genuine
row-deletion):
  - 9 versioned-root delete handlers (a delete has no ETag to rotate -> force
    only, no bump)
  - UpdateProgramScheduleHandler (bumps then saved plainly - the ProgramSchedule
    case PR3 only suspected; its post-commit query/enqueue also moved to
    CancellationToken.None per section 7b)
  - 7 item add/remove bumpers PR2 left on plain save:
    Add/DeleteProgramScheduleItem + Add{Items,Movie,Show,Season,Episode}ToPlaylist

Force-write (not 412) is correct: these endpoints take no If-Match, so an
unconditional delete/edit should win. No API contract change (no new response
codes) -> no OpenAPI regen.

Still deferred to #197 (cross-editor ETag rotation only, not a 500): the
non-bumping config siblings + the scanner-shared Add*ToCollection family.

Tests: RootWriterForceVersionTests races a bump *through the handler* via a
pre-tracked context (non-vacuous - reverting a handler to plain save fails the
test, verified) for the Option-delete / Either-delete / bump+update shapes,
plus the genuine-conflict rethrow branch and an explicit negative control
proving the plain-save path throws. Full ErsatzTV.Tests green (1482).

Also strips a pre-existing UTF-8 BOM from the touched handlers to satisfy the
.editorconfig `charset=utf-8` rule the pre-commit format hook enforces.

Docs: api-conventions section 7a (fan-out completeness) + decisions.md entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:39:19 +02:00
timothyandClaude Opus 4.8 29c8b1cd83 docs(handoff): prune lore — drop what decisions.md/api-conventions already cover + dead Blazor-parity notes
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m13s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m22s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m38s
The lore had grown into a changelog (~40 verbose bullets). Removed bullets
whose substance lives in docs/decisions.md or api-conventions §7a/b/c
(#251 post-commit None, #252/#259 reconcile, #253 PR3/PR4 concurrency, addTo
layer, DTO nullable facts, disabled={saving}) and dead Blazor-parity process
notes (parity verdicts, screen!=parity, unwired-endpoint gaps, #205/#206
docs-staleness). Condensed the survivors. Added a scope banner: this section
is standing workflow/orchestration lore only; engineering decisions go in
decisions.md, never here (they duplicate and drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:32:34 +02:00
timothyandClaude Opus 4.8 aed4b5c056 docs(handoff): fix queue-drift root cause — live Gitea state is the sole source of truth
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m17s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Recent session pickups re-did finished work (thought closed issues were
still open). Root cause: DONE/OPEN status lived in append-only prose (arc
inline markers + each comment's 'Recommended next'), which lags real issue
state — worst across parallel sessions narrating each other's merges.

Kickoff prompt fixes:
- SOURCE OF TRUTH = live Gitea state, never prose; verify OPEN (issue AND
  milestone) before claiming.
- Gate/frontier defined structurally (lowest-numbered open arc item), not
  hardcoded to an issue number (an earlier pass re-planted the bug by
  hardcoding '#197 cluster'/'#91b milestone CLOSED').
- Label-based ranking: arc order -> gate(review label/milestone) -> priority.
- Pick order across arc-frontier / priority-pickup / audit pools.
- Bound the comment read (~6 newest); MCP-down REST fallback; #237 canonical.
- New lore bullet capturing the drift root cause + structural cure.

Companion edits to tracker #237 body (prune arc status -> 'Done (history)'
section) made live; noted in a session comment there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:22:57 +02:00
timothyandClaude Opus 4.8 0b23d4b6b1 feat(api): #295 PR1 — browser SPA session auth (session-OR-key gate, server-only)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Implements the ratified #295 design (PR1, server-only, backward compatible). The
/api surface now accepts a valid X-Api-Key (machine) OR an authenticated session
(browser cookie, local login or OIDC), gated by the evolved ApiAuthorizationFilter
(renamed from ApiKeyAuthorizationFilter; same fail-closed EndpointRequiresKey
predicate). Machine/key behavior is byte-identical and the SPA keeps working via
its stored key — the SPA login flow lands in PR2.

- ApiAuthorizationFilter: key-first (CSRF-immune) then session; session-authed
  mutations require the X-CSRF header (403 otherwise). Attributes renamed
  [RequiresApiKey]->[RequiresAuthentication], [SkipApiKeyAuthorization]->[SkipApiAuthorization].
- Cookie scheme ctv-session always registered (Lax/SameAsRequest/14d sliding, 401 not
  redirect for /api); OIDC handler revived when configured (profile scope, userinfo,
  auth-method claim); UseAuthentication/UseAuthorization/UseRateLimiter revived in the
  legacy MapWhen branch.
- Local admin = single credential in ConfigElement rows (username / PBKDF2 hash via
  Microsoft.Extensions.Identity.Core / rotating security stamp) — NO DB migration.
  Password change rotates the stamp; CookieSecurityStampValidator revokes stale local
  sessions. Env-seed recovery (Auth:LocalAdmin:*) via LocalAdminSeedService.
- AuthController /api/auth/{config,session,setup,login,logout,password} + browser-nav
  GET /auth/oidc/login; excluded from OpenAPI (machine-audience spec). Per-IP rate limit
  on login/setup/password; dummy-hash verify (no user enumeration).
- ForwardedHeaders now strict opt-in: X-Forwarded-* ignored unless KnownProxies/Networks
  configured (rate-limiter IP + cookie-Secure integrity). Deployment: operators behind a
  proxy must set ForwardedHeaders:KnownProxies.
- Tests: session/CSRF filter cases + 17 Application/Auth handler tests; full ErsatzTV.Tests
  green (1499). No OpenAPI/generated-artifact drift.
- Docs: api-conventions section 9 rewritten; decisions.md entry (supersedes #206 inert-OIDC note).

Refs #295 #197 #206 #58

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:14:54 +02:00
timothyandClaude Opus 4.8 4112413ca5 fix(spa): #271 disable all family rows on click (family-global lock parity)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m17s
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 / EF migration integrity (SQLite + MySql) (push) Successful in 4m29s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s
Cold-review nit fixes on PR #298:
- useCollectionsScan.scan() now guards on the whole family being busy (active OR
  any pending key of that family), not just the exact key — a sibling source of a
  family with a scan in flight no longer fires a redundant (benign-409) POST.
- LibrariesScreen ExternalCollectionsSection disables every row of a family that
  has a pending or active scan (derives pendingFamilies from pendingKeys), matching
  Blazor's instant all-rows-disabled behavior instead of waiting a poll RTT.
- Rewrite the promote test to actually observe the optimistic-pending window via a
  deferred POST (was only asserting the promoted end state), and add a test proving
  a sibling-source click fires no second POST while the family is pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:34:05 +02:00
timothyandClaude Opus 4.8 6d31758cca feat(api): #271 collections scan-status REST surface + authoritative SPA reconcile
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Add GET /api/media-sources/collections-scan-status (MediaSourcesController →
GetCollectionsScanStatus handler) reporting which media-source families
(plex/jellyfin/emby) currently hold their external-collections scan lock,
reading IEntityLocker.Are{X}CollectionsLocked(). The lock is family-global
(no source id) and boolean (no percent), so the DTO carries just {family} and
returns only active families — the counterpart to GET /api/libraries/scan-status.

SPA: useCollectionsScan now polls this endpoint and reconciles optimistic
pending against the active-family set (seeding on mount so an in-progress scan
disables buttons immediately), using the same grace-tick helper as library
scans (now generic over the pending key type). Drops COLLECTIONS_PENDING_TIMEOUT_MS
— a long deep scan no longer re-enables the button early, and a fast scan no
longer wedges it disabled for the full timeout. A row shows Scanning when its
family is active or it has an in-grace optimistic pending key.

Tests: handler (3), controller route+delegation (2), SPA api fn + hook reconcile
(mount-seed / 202-promote / 409-keeps-disabled / 404-error). OpenAPI + TS types
regenerated. Docs: api-conventions §3b, blazor-route-parity §5, decisions.md.

Unblocks #91b (arc item 4): Libraries.razor's collections-scan affordance now
has full authoritative parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:23:58 +02:00
timothyandClaude Opus 4.8 c40e78d840 fix(api): #197 Bundle C review nits — order-independent operationIds + nullable MediaSources fields
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m24s
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
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m18s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m21s
Refs #287 #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:08:50 +02:00
timothyandClaude Opus 4.8 a918ccd60c docs: #197 Bundle C — api-conventions/decisions/rest-api sync
§2 raw-VM wrapping + universal #nullable enable; §3a/§5/§7a/§9 updated for
reset re-key, DayOfWeek string, header-only Version, security-by-construction;
3 decisions.md entries (#287/#288/channel-key); rest-api.md reset route.

Refs #287 #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 02:38:43 +02:00
timothyandClaude Opus 4.8 70f357f8f9 feat(api): #288 ChannelDetailResponseModel for edit form + SPA repoints + final regen
Mint ChannelDetailResponseModel (faithful detail DTO exposing the raw editable
field set the channel editor reads: raw FFmpegProfileId/WatermarkId/FallbackFillerId
ids, the mode enums, logo, playoutCount, id) and route GetById/Create/Update through
it, replacing the lean list ChannelResponseModel that resolved the profile to a name
and dropped the editable ids (a functional regression for draftFromChannel). The lean
ChannelResponseModel stays unchanged for GET /api/channels. webEncodedName dropped
(SPA never reads it). Logo is mirrored as a Core ChannelLogoResponseModel since the
Application ArtworkContentTypeModel can't be referenced from Core.

Repoint the hand-written SPA client aliases now that the VMs are gone from the schema:
Channel -> ChannelDetailResponseModel, MediaCollection/SmartCollection -> *ResponseModel,
ProgramSchedule -> ProgramScheduleResponseModel. Fix #288 honest-nullability test fallout
in search.test.ts (null -> [] for now-non-null id arrays). Include the already-on-disk
playouts.ts WithDayNames removal and regenerate v1.json + v1.d.ts + endpoint-index.md
(authoritative final regen; the reset endpoint's {channelNumber}->{id} re-key surfaces
in the generated docs and the OpenApi error-contract test).

Refs #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 02:33:39 +02:00
timothy 74170611da Merge branch 'bundle-c/channel' into bundle-c-197-contract 2026-07-12 02:08:13 +02:00
timothy 9e5087b3cd Merge branch 'bundle-c/288' into bundle-c-197-contract 2026-07-12 02:08:12 +02:00
timothyandClaude Opus 4.8 5f89bfc1a0 feat(api): #288/#197 wrap ChannelViewModel + re-key playout/reset to {id}
GetById/Create/Update return ChannelResponseModel via new GetChannelByIdForApi
read-side query; POST /api/channels/{id:int}/playout/reset (new
GetPlayoutIdByChannelId; by-number kept for HlsSessionWorker broadcast).

Refs #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 02:08:12 +02:00
timothyandClaude Opus 4.8 171364d0e8 feat(api): #287 OpenAPI contract honesty by construction
ApiKey security scheme + per-op security/401 via shared EndpointRequiresKey
predicate (no drift from enforcement); synthesized stable operationIds;
400 ValidationProblemDetails on binding ops; DayOfWeek as string enum.

Refs #287 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 02:08:12 +02:00
timothyandClaude Opus 4.8 fecf16d3d2 feat(api): #288 wrap raw VMs, nullable-honest DTOs, search pageNum
24 #nullable enable flips across ErsatzTV.Core/Api; Collection/Schedule/
SmartCollection/Resolution VMs wrapped in ResponseModels (Version now
header-only, SPA-verified); pageNum threaded into GET /api/search.

Refs #288 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 02:08:12 +02:00
timothyandClaude Opus 4.8 9e2d160884 docs(handoff): #289 lore — runtime-posture guards, FF-onto-feature-branch, ResponseHeadersRead timeout
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m39s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:28:53 +02:00
timothy f8fd9084d1 Merge main (CI migration-job retry #294 + #197 tests) into fix/283
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m31s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m40s
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 / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m54s
# Conflicts:
#	docs/decisions.md
2026-07-12 00:52:20 +02:00
timothyandClaude Opus 4.8 fa2d787ac1 ci: make the MySql migration-apply resilient to concurrent-runner contention
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m12s
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 / Build & test (.NET) (push) Successful in 8m24s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Root cause (diagnosed from run logs 513/515/516): the EF migration-integrity
job's "MySql apply all migrations to a fresh DB" step flakes when two migration
jobs land on the SAME runner host at once — each `services: mysql:8.4` container
starves the other, so the 787-migration replay either exceeds MySqlConnector's
30s default command timeout ("Command Timeout expired", run 513 on ci-runner) or
has its connection dropped mid-replay ("MySqlEndOfStreamException", run 516 on
bumblebee-runner). It's pure infra contention: `has-pending-model-changes` (the
model check) passes both providers, and the identical tree passes on a quieter
host (run 515). Both runners have both passed and failed — not one bad runner.

Fix (runner-agnostic, repo-owned workflow only — no runner-host change needed):
- Raise `DefaultCommandTimeout` to 300s in the MySql connection string.
- Wrap the apply in a 3× retry that resumes from `__EFMigrationsHistory` (EF
  commits each migration in its own transaction, so an interrupted one rolls back
  and the retry continues). A real migration failure fails on every attempt, so
  the retry can't mask a genuine problem.

Docs: ci-cd.md migration-integrity section documents the contention + retry.

Refs #13 #236

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:42:49 +02:00
timothyandClaude Opus 4.8 db9fc59660 ci: re-trigger CI into idle runner (MySQL apply-all flake under load)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Run 516's MySQL "apply all migrations to a fresh DB" died with
MySqlEndOfStreamException (incomplete response) — the shared MySQL service
container under concurrent-run load, same class as run 513's Command Timeout.
Model-drift (has-pending-model-changes) passed for BOTH providers, so the diff
is model-clean; another branch's run (515) passed the identical job. Contention
has cleared; re-triggering. Tree unchanged from b6f12f7e.

Refs #283

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:34:20 +02:00
timothyandClaude Opus 4.8 2a6fa2694a ci: re-trigger CI (MySQL migration-integrity command-timeout flake)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 3m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Run 513's "EF migration integrity" job failed with "The Command Timeout
expired" applying all migrations to a fresh MySQL under concurrent-run
contention. The tree is unchanged from b6f12f7e (Build & test green there and
on the pre-clamp cf834d8b; the change touches no EF/model/migration code).
Empty commit to get a clean run.

Refs #283

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:28:02 +02:00
timothyandClaude Opus 4.8 ee6be81c22 test(#197): cover ApiKeyProvider unreadable-file rethrow + deleted-during-read race
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m28s
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 / EF migration integrity (SQLite + MySql) (push) Successful in 4m42s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m47s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m8s
Re-review of the fix commit (MERGEABLE-WITH-NITS) noted the headline L3 rethrow branch
itself had no test. Add a reader seam (internal ResolveKey Func overload) and two tests:
unreadable existing file throws + does not overwrite; a delete race between File.Exists
and the read falls back to generate rather than failing boot.

Refs #197 #280

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:22:26 +02:00
timothyandClaude Opus 4.8 0fdb2841b7 security(#197): harden ApiKeyProvider persistence + add provider tests (review fixes)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cold adversarial review of PR #292 = MERGEABLE-WITH-NITS (no BLOCKER/HIGH). Addresses:
- M1: ApiKeyProvider's never-empty invariant was untested. Add ApiKeyProviderTests
  covering WriteKey precedence, load-existing, empty-file regenerate, generate+persist,
  0600 mode, and still-usable-key-when-persist-fails. ResolveKey extracted to an
  internal seam taking the key path (InternalsVisibleTo ErsatzTV.Tests).
- L2: write-then-chmod race — the key was briefly world-readable. Persist now creates
  the file 0600 atomically via FileStreamOptions.UnixCreateMode (then re-asserts).
- L3: a transient read error on an EXISTING key file silently regenerated + clobbered
  it (invalidating every client key). ResolveKey now rethrows on an unreadable existing
  file (fail loud) and only regenerates when the file is absent or empty.

L4 (LocalhostOnly XFF-spoof under default trust-all) and N5 (length oracle on a
fixed-width key) accepted as documented/cosmetic.

Refs #197 #280

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:17:44 +02:00
timothyandClaude Opus 4.8 b6f12f7e2c security(#283): clamp served artwork MIME type to the image allow-list
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 6m8s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cold-review LOW (defense-in-depth): the serve path derived the Content-Type
from the stored file via Winista but only defaulted application/octet-stream on
a NULL sniff. A cache file whose bytes are HTML — a legacy entry poisoned before
the upload-sniff landed, or a hypothetical image/script polyglot — could still be
sniffed as text/html and served renderable (nosniff does not stop an explicitly
declared text/html). Clamp the sniffed type to ImageContentTypes.IsAccepted,
serving application/octet-stream for anything else, so the serve path can never
emit a renderable non-image type regardless of what bytes are on disk.

Refs #283

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:15:56 +02:00
timothyandClaude Opus 4.8 98ff9a59f5 docs(#197): record PR #292 in decisions entry
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m30s
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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:11:55 +02:00
timothyandClaude Opus 4.8 20d074e7e4 docs(#197): api-conventions §9 auth posture + decisions.md Bundle A entry
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:08:05 +02:00
timothyandClaude Opus 4.8 cf834d8b60 security(#283): sniff artwork content type from bytes, remove serve-side ?contentType= reflection
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
S4 stored-XSS + S9 upload-size DoS from the #197 cold API review.

The artwork path trusted client-supplied content types at both ends: upload
validated only the declared multipart Content-Type (never decoded the bytes),
and serving reflected a client `?contentType=` straight into the response
Content-Type on unauthenticated GET sinks (/iptv/logos, /artwork/watermarks).
Chain: upload <script> bytes as image/png -> GET ...?contentType=text/html
serves them as HTML in-origin. nosniff (#279) does not help because the server
explicitly declares text/html.

- Upload: derive the content type from the bytes via SkiaSharp SKCodec
  (header-only, no decode -> no decompression-bomb path); reject non-images 422.
  New ErsatzTV.Core/Images/ImageContentTypes as the single allow-list source.
  Dropped the untrusted declared Content-Type from the UploadArtwork command.
- Serve: removed the ?contentType= reflection structurally -- dropped ContentType
  from GetCachedImagePath and the [FromQuery] binding on GetImage/GetWatermark;
  the handler always sniffs the file, defaulting application/octet-stream.
  ArtworkContentTypeModel.UrlWithContentType is now the bare path; SPA previews
  no longer append the query.
- Defense-in-depth: channel-logo / watermark {path, contentType} DTOs run through
  ArtworkContentTypeModel.Sanitized(), blanking non-allow-listed types on write.
- S9: Kestrel MaxRequestBodySize from ETV_MAXIMUM_UPLOAD_MB rejects oversized
  bodies during read (controller file.Length check kept as friendly-error backstop).

Both serve sinks are IgnoreApi, so no OpenAPI change. Tests: byte-sniff accept/
reject, Sanitized() allow-list, Location no longer carries ?contentType=.
Docs: api-conventions §4a + decisions.md 2026-07-12.

Refs #283 #197 #66

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:07:55 +02:00
timothy b3e4c9ab5b merge(#197): SPA API-key entry + send key on all methods (Bundle A SPA slice) 2026-07-12 00:04:35 +02:00
timothyandClaude Opus 4.8 37155c866b security(#197): fail-closed API auth, sensitive-read tier, CORS/ForwardedHeaders lockdown (Bundle A)
Backend of #197 Bundle A (auth posture). Owner decisions: single API key;
Api:RequireKeyForReads defaults true (whole /api surface gated; /iptv streaming
+ guide unaffected — outside the filter's /api scope).

- #280 S1: writes are fail-closed. New IApiKeyProvider resolves the key once
  (Api:WriteKey config, else persisted /config/api.key, else a generated 256-bit
  key written 0600). The empty-key open branch is gone; there is no open mode.
- #282 S3/S5: reads under /api require the key when Api:RequireKeyForReads (default
  true) or the endpoint carries the new [RequiresApiKey]. Applied [RequiresApiKey]
  to Troubleshoot/Logs/Settings/Maintenance so the sensitive tier stays gated even
  if reads are opened. OPTIONS preflight is exempt.
- #281 S2: delete SortController (dead Blazor SortableJS residue; SPA uses PUT
  /api/collections/{id}/custom-order) and AccountController (dead OIDC logout) —
  both non-/api persistent surfaces that bypassed the key.
- #284 S6: replace CORS AllowAll with an opt-in exact-origin allowlist
  (Api:CorsAllowedOrigins; permits X-Api-Key/If-Match, exposes ETag). Default is
  no cross-origin (SPA is same-origin).
- #285 S7/S10: gc GET->POST (spec regenerated); ForwardedHeaders trust configurable
  via ForwardedHeaders:KnownProxies/KnownNetworks (warns when unrestricted);
  ScannerController gains [LocalhostOnly] (scanner always calls back over localhost).

Filter unit tests rewritten for fail-closed + read-gating + tier + OPTIONS;
ApiControllerSecurityTests assert the sensitive tier + scanner-loopback reflectively.
search/all-items paging deferred (SPA add-all coupling) — exposure closed by read-gating.

Refs #197 #280 #281 #282 #284 #285

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:03:55 +02:00
timothyandClaude Opus 4.8 ab6d31309f feat(spa): API key entry, send key on all requests, 401 pointer (#197)
Bundle A SPA slice: the /api surface is now gated behind X-Api-Key on
every request (reads too, RequireKeyForReads defaults true), so a wrong/
missing key 401s everything.

- #282: send X-Api-Key on ALL requests when a key is stored, not only
  mutations (removed the mutatingMethods split in api/client.ts).
- #280: new keyless API Key screen (/app/api-key, System nav) that reads/
  writes only localStorage via auth.ts and never calls /api, so it works
  on a fresh install where every read 401s. Masked key state, Save/Clear,
  points at server-generated /config/api.key.
- 401 UX: client emits one app-wide unauthorized signal (auth.ts
  notify/subscribeUnauthorized); a shell-level UnauthorizedBanner points
  the user at the API Key screen. DRY, no per-screen 401 branches.
- Tests: inverted the GET header assertion (key now sent on reads), added
  no-key and 401-signal client tests, auth signal tests, and screen +
  banner tests. spa-conventions.md §5e documents the new seams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:01:55 +02:00
timothyandClaude Opus 4.8 cc414dfd7c docs(#197): record Phase-0 hardening decisions (security headers, constant-time compare, playout clamps)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m35s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m41s
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 / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m1s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Follow-up to PR #279 — the adversarial diff review flagged that adding
baseline security headers to every response is an operational-behavior
decision worth a decisions.md entry. Records the SecurityHeadersMiddleware
placement + the deliberate CSP/HSTS deferral to the #197 posture design,
plus the constant-time key compare and playout paging clamps.

Refs #197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:34:19 +02:00
timothyandClaude Opus 4.8 c55a7fda36 security(#197): constant-time API-key compare, clamp playout paging, baseline security headers
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m15s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m15s
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 / Build & test (.NET) (push) Successful in 8m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m43s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Posture-independent safe hardening from the #197 cold API security review
(the clear-cut fixes that don't depend on the fail-closed/CORS/versioning
posture design, which is tracked separately):

- ApiKeyAuthorizationFilter: compare X-Api-Key with
  CryptographicOperations.FixedTimeEquals instead of ordinal string.Equals
  (removes the response-timing oracle on the write key). [S10]
- PlayoutController: clamp pageNum/pageSize on GET /api/playouts and
  /api/playouts/{id}/items to Math.Clamp(_, 1, 100), matching the documented
  api-conventions §1 convention every other paged endpoint already follows —
  these two were passing the raw value straight to EF Take(). [S8]
- SecurityHeadersMiddleware: emit X-Content-Type-Options: nosniff,
  X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin on
  every response (nosniff backstops the artwork content-type MIME-sniffing
  risk). CSP/HSTS deferred to the #197 posture design (CSP needs SPA
  validation; HSTS is proxy/TLS-owned). [S10]

Refs #197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:17:21 +02:00
timothyandClaude Opus 4.8 1b5efd7b9d ci: prod follows :prod (remove version-pin bump-prod-compose job)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m56s
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 / Build & test (.NET) (push) Successful in 8m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m1s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m24s
Timothy reversed the version-pin decision: prod's media-servers compose now
follows the floating :prod tag, redeployed by Komodo Global Auto Update. The
bump-prod-compose job (#275) rewrote a :<version> pin, which would flip :prod ->
:26.8.0 on the next release — remove it. docs/ci-cd.md reconciled to the :prod
model (+ flags the open caveat: verify Global Auto Update runs the #553
pre-deploy backup, else releases deploy without a backup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:20:14 +02:00
timothyandClaude Opus 4.8 57adb1b0cc docs(handoff): reframe soak-gate lore — single-client is the point, prod-tag is tactical (#253 PR4)
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m24s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m59s
Build ErsatzTV Image / Bump prod compose tag (server-management) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:15:30 +02:00
timothyandClaude Opus 4.8 2fae93c15a docs(handoff): lore — a 'soak' gate is meaningless until Phase-1 reaches prod (#253 PR4)
Build ErsatzTV Image / Docs update reminder (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 / Bump prod compose tag (server-management) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:13:35 +02:00
timothy 5e5a59317a Merge pull request '#259: content-aware stable child identity for schedule-item replace' (#276) from feat/259-child-identity into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Bump prod compose tag (server-management) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-11 20:11:27 +00:00
timothyandClaude Opus 4.8 9e48aaefea test(#259): cover id-mode subtype-change (delete+insert) with a same-type sibling
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review-caught coverage gap: the existing Subtype_Change test runs the POSITIONAL
path (id-less payload). Add a handler-level test for the id-mode branch — one
id-matched item changes subtype (One->Duration: delete+insert, new id) while a
sibling id-matched item keeps its subtype (Multiple: in place, id + fill-group
state preserved) in the same payload. Proves the delete pass + match-pass Remove/Add
don't double-handle and the survivor's state is retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:03:33 +02:00
timothy 1864e4e15f merge(#259): SPA round-trips schedule item server id
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m44s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 21:53:59 +02:00
timothyandClaude Opus 4.8 162b334e5d test(#259): id-based reconcile matrix + docs (api-conventions §7c, decisions)
Add the id-based reconcile tests to ReplaceProgramScheduleItemsReconcileTests:
reorder moves state with the logical item (the non-vacuous core — proven to fail
under forced-positional), insert-in-middle, delete-unreferenced, unknown-id→422,
duplicate-id→422, and stale-version+unknown-id→412 (412 precedes 422, §7c). The
GET→map→PUT lossless round-trip now round-trips r.Id so it exercises id-mode.
Threads the new int? Id through all command/wire construction sites in tests.

Docs: api-conventions §7c (stable child identity + the deliberate #2-#5 positional
asymmetry) and a decisions.md entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:53:50 +02:00
timothyandClaude Opus 4.8 b7428a9b95 feat(#259): SPA round-trips schedule item server id
The backend (already on this branch) added an optional int? Id to
ScheduleItemRequest so the server reconciles PUT /api/schedules/{id}/items
rows by identity instead of by array position. The SPA previously
discarded the server id on load (only a client-local _key survived) and
never sent one back, so a reorder could misattribute fill-group/shuffle
state onto the wrong persisted row.

- itemRules.ts: fromResponse now captures the response item's id onto
  the draft; normalizeForSave emits it back unchanged. newDraftItem and
  copyDraftItem explicitly set id: null (a brand-new/copied row was
  never persisted under an id, and copyDraftItem must not duplicate the
  source's id onto a second row).
- scheduleItem.ts (Add-to-schedule dialog, POST path): id: null for the
  same reason — it always creates a new row.
- SchedulesScreen.tsx save(): the PUT-response re-seed already existed
  (fromResponse over the response array) but now carries ids through.
  This matters because a subtype/playout-mode switch can be a
  delete+insert server-side, so the response id for that row can differ
  from what was submitted — a second save must use the *response's* id
  or the server 422s it as unknown. Added a comment documenting this.
- schedules.ts: replaced the stale "server reuses same-typed rows by
  position" comment with the current id-based reconcile contract.
- Added/updated tests in itemRules.test.ts, SchedulesScreen.test.tsx,
  and AddToScheduleDialog.test.tsx covering id round-tripping, the
  null-id-for-new/copied-item cases, and a second-save-reuses-the-
  response-id regression test.

Verified: npm run lint, tsc -b --noEmit, npm run build, and npm test
(680/680) all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:51:24 +02:00
timothyandClaude Opus 4.8 0ff21d5b8e ci: restore bump-prod-compose auto-deploy job on v* release
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m32s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Bump prod compose tag (server-management) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7m9s
Build ErsatzTV Image / Bump prod compose tag (server-management) (push) Has been skipped
The auto-pin-to-prod job designed on the unmerged `ci/auto-bump-prod-compose`
branch (3d6ac883) never landed on main — so v* releases (v26.5.0, v26.6.0) did
NOT auto-bump the server-management compose pin (it sat at 26.5.0). The docs
(homelab-docs Docker/ErsatzTV.md, ci-cd.md) described the auto-bump as if live.

Restore the job verbatim (its credentials already exist: the `ersatztv-ci-deploy`
write deploy key, id 5, on server-management + the SERVERMGMT_DEPLOY_KEY secret
here). On a v* tag, after the test-gated image builds, it rewrites the pinned
`ersatztv:<version>` tag in docker/bumblebee/stacks/media-servers/compose.yaml
and pushes to server-management `master` → the Gitea->Komodo webhook redeploys
prod with a pre-deploy backup. Idempotent (no-op if already pinned).

docs/ci-cd.md updated to match (release procedure + the stale ":prod pin" claim,
which was actually an immutable :<version> pin since 2026-07-07).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:44:36 +02:00
timothyandClaude Opus 4.8 02a493e95e feat(#259): id-based reconcile for schedule-items replace (backend + DTO)
Add optional `int? Id` to ScheduleItemRequest/ReplaceProgramScheduleItem so
a client can round-trip each existing item's server id. When ids are present,
ReplaceProgramScheduleItemsHandler reconciles by id (not 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 occupied its new slot (#259, split from
#252/#253). A fully id-less payload keeps the verbatim positional fallback.

Guards (inside PersistItems, after CheckVersion so 412 precedes 422): duplicate
id -> 422; id not in this schedule -> 422 (a stale id under Phase-1 force-write is
a live lost-update signal, not a new item). Index stays array-position derived.

Regenerated v1.json + TS client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:43:52 +02:00
timothy 78262ea4cb Merge remote-tracking branch 'origin/main' into feat/91b-blazor-removal
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m29s
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 / EF migration integrity (SQLite + MySql) (push) Successful in 4m23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s
2026-07-11 21:17:03 +02:00
timothyandClaude Opus 4.8 3c5d05908a chore(91b): clean up dead Blazor residue (Serilog override + stale comment)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Codex-review nits: drop the now-inert "MudBlazor" Serilog level override in
appsettings.json (package removed) and reword the PlaylistController comment
that referenced the deleted Blazor MultiSelectBase.AddItemsToPlaylist path. No
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:10:15 +02:00
timothyandClaude Opus 4.8 4407dd53af fix(91b): delete orphaned Blazor NavigationManagerExtensions
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cold-review Low-1: ErsatzTV/Extensions/NavigationManagerExtensions.cs survived
the removal — the last non-deleted .cs still importing
Microsoft.AspNetCore.Components/JSInterop and calling the deleted
blazorHelpers.scrollToFragment JS. Fully unreferenced (compiled only via the
shared framework). Removing it completes the Blazor deletion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:09:26 +02:00
timothyandClaude Opus 4.8 408b0deb89 feat(91b): remove legacy Blazor Server UI (#91 phase b)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The ChicoryTV React SPA (web/, served at /app) now has full parity for every
route the Blazor UI served, so the legacy Blazor Server / MudBlazor UI is
deleted. This is the milestone-capping removal of #91 phase (b).

Deleted: ErsatzTV/Pages/**, Shared/**, ViewModels/** (39 edit VMs),
Validators/** (10 edit-VM validators), App.razor, _Imports.razor,
Locals/{Shared,Pages}/** (Blazor loc resx; Locals/Resources.* kept),
wwwroot/css + wwwroot/lib, libman.json, and the orphaned MultiSelectBaseTests.

Startup.cs (surgical, not wholesale): removed AddRazorPages/AuthorizeFolder,
AddServerSideBlazor, AddMudServices, AddSortable, AddCourier, the HtmlSanitizer
registration, the Blazor-attached OIDC UseAuthentication/UseAuthorization
middleware (per the #206 auth-posture sign-off), MapBlazorHub, and
MapFallbackToPage("/_Host"). Renamed the branch blazor->legacy; it still
co-hosts MapControllers, /docs (Scalar), dev MapOpenApi and the redirect
middleware. Replaced the _Host fallback with a catch-all (MapFallback ->
302 /app) that excludes /api|/artwork|/docs|/openapi (genuine 404) per #204.
Kept all OIDC/JWT/API-key service wiring (inert unless configured; real auth
is #197), ConditionalIptvAuthorizeFilter, ApiKeyAuthorizationFilter.

Pruned 9 now-unused packages (all verified zero remaining consumers) from
Directory.Packages.props + ErsatzTV.csproj: MudBlazor, Heron.MudCalendar,
Blazored.FluentValidation, BlazorSortable, MediatR.Courier.DependencyInjection,
Markdig, HtmlSanitizer, Chronic.Core, NaturalSort.Extension. Also removed the
now-dead #25 razor-Sonar NoWarn.

LegacyUiRedirects: added the 14 /media/sources/* -> /app/libraries/* redirects
(SPA screens landed in #202) and lifted the #204-era /media/sources prefix ban.

Tests: Release build clean; full solution suite green. Updated Startup
source-text tests + added regression coverage that Blazor wiring is gone, the
catch-all is wired, and all 14 media-sources routes redirect.

Docs: blazor-route-parity.md (phase b COMPLETE), decisions.md (removal entry),
CLAUDE.md, contributing.md, README.md all updated in this PR.

Rollback: tag blazor-final is cut on pre-merge main as the first merge action.

Part of #91.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:01:04 +02:00
711 changed files with 26569 additions and 40781 deletions
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# ersatztv#303 H9 — docs/decisions.md is append-only. This blocks a commit / PR that DELETES or
# MODIFIES an existing line of that file; pure INSERTIONS anywhere are always allowed (adding a new
# entry inserts a TOC line near the top AND appends a block at the bottom — both are insertions, so
# numstat reports 0 deleted lines). A genuine factual fix to a past entry is the one legitimate edit:
# put the literal token [decisions-edit] in the commit message to override.
#
# Fail-open: any tooling trouble (unknown mode, non-numeric numstat, missing refs) -> allow. The point
# is to catch the accidental rewrite-history case, never to wedge a legitimate commit.
#
# Assumes decisions.md ends with a trailing newline (it does; .editorconfig enforces it). If that final
# newline were ever dropped, git would render the next append as a modify of the last line (deleted=1)
# and this would false-block the append until the author adds [decisions-edit] — cheap and self-correcting.
#
# Modes:
# staged <msgfile> pre-commit/commit-msg — staged diff vs HEAD; trailer read from <msgfile>
# range <base> <head> CI (PR) — merge-base diff base...head; trailer scanned across base..head msgs
set -euo pipefail
FILE="docs/decisions.md"
mode="${1:-}"
case "$mode" in
staged)
deleted=$(git diff --cached --numstat -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(cat "${2:-/dev/null}" 2>/dev/null || true)
;;
range)
base="${2:-}"; head="${3:-}"
[ -n "$base" ] && [ -n "$head" ] || exit 0 # missing refs -> fail-open
deleted=$(git diff --numstat "$base...$head" -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(git log --format='%B' "$base..$head" 2>/dev/null || true)
;;
*)
exit 0 # unknown mode -> fail-open
;;
esac
# Empty (no change to the file) or '-' (binary) -> treat as 0 (fail-open / nothing to guard).
deleted="${deleted:-0}"
case "$deleted" in ''|*[!0-9]*) deleted=0 ;; esac
[ "$deleted" -gt 0 ] || exit 0 # pure insertion / no change -> allow
# Explicit override for a documented factual fix.
if printf '%s' "$msg" | grep -qiF '[decisions-edit]'; then
exit 0
fi
{
echo "decisions-guard (ersatztv#303 H9): docs/decisions.md is append-only — this change deletes/modifies ${deleted} existing line(s)."
echo " Append new entries at the bottom (plus a TOC line in the Index); do not rewrite settled entries."
echo " To fix a genuine factual error in a past entry, add the token [decisions-edit] to the commit message."
} >&2
exit 1
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# PostToolUse / Bash — after a successful `git worktree add`, stamp the new worktree with
# this session's id (.claude-worktree-owner) so pretooluse-worktree-guard.sh (H7) can tell
# a sibling worktree another session created apart from this session's own.
# Fail-safe: any parse trouble → do nothing (the guard stays fail-open without a marker).
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
me=$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)
printf '%s' "$cmd" | grep -qE 'git[[:space:]]+worktree[[:space:]]+add\b' || exit 0
[ -z "$me" ] && exit 0
[ -z "$cwd" ] && cwd="$PWD"
# Extract the <path> arg of `git worktree add [flags] <path> [<commit-ish>]`.
# Skip flags; skip the values of the value-taking flags (-b/-B/--reason). Worktree paths
# in this repo have no spaces, so whitespace tokenization is safe.
add_args=$(printf '%s' "$cmd" | sed -E 's/.*git[[:space:]]+worktree[[:space:]]+add[[:space:]]+//')
path=""
skip=0
for tok in $add_args; do
if [ "$skip" = 1 ]; then skip=0; continue; fi
case "$tok" in
-b|-B|--reason) skip=1; continue ;;
--) continue ;;
-*) continue ;;
*) path=$(printf '%s' "$tok" | tr -d '"'"'"''); break ;;
esac
done
[ -z "$path" ] && exit 0
case "$path" in /*) abs="$path" ;; *) abs="$cwd/$path" ;; esac
[ -d "$abs" ] || exit 0
# Don't clobber a marker a different session already planted.
[ -f "$abs/.claude-worktree-owner" ] && exit 0
printf '%s\n' "$me" > "$abs/.claude-worktree-owner" 2>/dev/null || true
exit 0
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Husky pre-push backstop for ersatztv#303 H6 — the fast-forward-to-main path the Claude merge
# hook (pretooluse-merge-consent.sh) can't see. Reads git's pre-push ref lines on stdin; for a push
# to main it scans the pushed commits for a Gitea close-keyword (`fixes #N`), and if the linked
# issue's "## Done-when" checklist still has unticked boxes it BLOCKS the push.
#
# A git hook has no interactive "ask", so this is deliberately fail-OPEN: it only blocks when it can
# positively prove an unticked box (creds present, issue fetched, non-docs change). No creds, Gitea
# unreachable, docs-only diff, or no linked issue -> allow (a loud warning at most). The authoritative
# gate is the merge hook; this just catches a direct `git push origin main`.
#
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
set -euo pipefail
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
refs=$(cat || true)
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
exit 0 # can't verify -> fail-open (the merge hook is the real gate)
fi
gq() {
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$1" 2>/dev/null || true
else
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$1" 2>/dev/null || true
fi
}
zero=0000000000000000000000000000000000000000
blocked=""
while read -r localref localsha remoteref remotesha; do
[ "$remoteref" = "refs/heads/main" ] || continue
[ "$localsha" = "$zero" ] && continue # branch deletion
# Commit range being pushed. New branch (remotesha all-zero) -> just the tip, don't rescan history.
if [ "$remotesha" = "$zero" ]; then range="$localsha -1"; else range="$remotesha..$localsha"; fi
msgs=$(git log --format='%B' $range 2>/dev/null || true)
issues=$(printf '%s' "$msgs" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
[ -n "$issues" ] || continue
# Docs-only exemption over the pushed range.
changed=$(git diff --name-only $range 2>/dev/null || true)
if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
continue
fi
for n in $issues; do
ibody=$(gq "repos/timothy/ersatztv/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
[ -n "$ibody" ] || continue # can't fetch -> fail-open
unchecked=$(printf '%s\n' "$ibody" | awk '
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
grab && /^##[[:space:]]/ {grab=0}
grab {print}' | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
if [ "${unchecked:-0}" -gt 0 ]; then
blocked="${blocked} - issue #$n has $unchecked unticked ## Done-when box(es)\n"
fi
done
done <<EOF
$refs
EOF
if [ -n "$blocked" ]; then
printf 'husky - H6 merge-consent (ersatztv#303): push to main BLOCKED\n' >&2
printf '%b' "$blocked" >&2
printf 'Finish/tick every Done-when criterion (incl. adversarial review) first, or push a docs-only change.\n' >&2
exit 1
fi
exit 0
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# H11 (ersatztv#311) — refuse to push a branch that is BEHIND origin/main: rebase first, do NOT
# merge main in. A merge commit drags in files you never touched (e.g. the ~2500 legacy-BOM .cs),
# which then trips the pre-commit `dotnet format` hook on code that isn't yours (the #309 session).
# Rebasing keeps your diff to exactly what you changed.
#
# Fail-OPEN on anything we can't decide (a git pre-push hook has no "ask"): not a git repo,
# offline / fetch fails, no origin/main, HEAD unresolved -> allow the push. The only hard block is
# a positively-proven "behind origin/main". Deliberate exception: ETV_SKIP_REBASE_CHECK=1.
set -uo pipefail
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
# Best-effort fetch of the latest main; offline / no network -> don't block.
git fetch origin main --quiet 2>/dev/null || exit 0
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
# Pushing main itself, or a branch already rebased on top of it, means origin/main is an ANCESTOR
# of HEAD -> nothing to rebase, allow.
if git merge-base --is-ancestor origin/main HEAD 2>/dev/null; then
exit 0
fi
behind=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo '?')
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
echo "husky - push blocked (H11): '$branch' is behind origin/main by $behind commit(s)."
echo " Rebase before pushing — do NOT merge main in (a merge drags in files you didn't touch,"
echo " e.g. legacy-BOM .cs, and trips the format hook on code that isn't yours):"
echo " git fetch origin main && git rebase origin/main"
echo " Deliberate exception: ETV_SKIP_REBASE_CHECK=1 git push"
exit 1
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# PreToolUse / Agent (subagent spawn) — RAM-gate the fan-out.
# The historic 8-9-way crash was RAM starvation, not CPU load; gate on FREE RAM.
# Fail-open: if memory_pressure is unavailable/unparsable → allow.
set -euo pipefail
free=$(memory_pressure -Q 2>/dev/null | grep -oE 'free percentage: [0-9]+' | grep -oE '[0-9]+' || true)
[ -z "${free:-}" ] && exit 0
if [ "$free" -lt 10 ]; then
jq -n --arg f "$free" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("Free RAM \($f)% (<10%): do NOT spawn more agents — the historic crash was RAM starvation from an 8-9-way fan-out. Wait for memory_pressure -Q to recover, then retry.")}}'
elif [ "$free" -lt 20 ]; then
jq -n --arg f "$free" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("Free RAM \($f)% (<20%): near the fan-out ceiling. Confirm before adding another build/implementer agent (read-only recon agents are cheap).")}}'
fi
exit 0
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# PreToolUse / Bash — deny commands that violate a HARD RULE.
# Fail-open: any parse trouble → allow (exit 0 with no output).
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
# Match an actual env ASSIGNMENT in COMMAND POSITION — line start or right after a shell
# separator (; && || | ( ), optionally `export`. This deliberately does NOT match the name
# when it sits inside a quoted string (echo, git commit -m, jq test payloads), where the
# preceding char is a quote/word, not a separator — so mentions of the rule never false-trip.
if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(export[[:space:]]+)?ETV_UPDATE_GOLDENS='; then
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Blocked: ETV_UPDATE_GOLDENS regenerates golden-test baselines — HARD RULE (docs/handoffs lore); never set it in a session. Update a golden deliberately and reviewed, not via a guarded run."}}'
fi
exit 0
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# PreToolUse / mcp__gitea__pull_request_write — derive merge consent from STATE instead of
# trusting the agent's judgment (ersatztv#303 H6 + H10). A PR merge is the one irreversible op; allow it
# only when ALL are true:
# (a) the PR's CI combined status is green, AND
# (b) every checkbox in the linked issue's "## Done-when" section is ticked, AND
# (c) a review-verdict comment on the PR references the CURRENT head sha (H10) — proving the
# LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode:
# "re-review the fix commit, not just the initial PR diff").
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
# The H10 review-verdict convention: after reviewing a PR (or its latest fix commit), post a PR
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
#
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
# - state derivable and satisfied -> grant (auto-approve: permissionDecision "allow",
# so NO redundant permission prompt fires —
# the derived state IS the consent, ersatztv#314)
# - state derivable and NOT satisfied -> deny (actionable reason)
# - state NOT derivable (no creds, Gitea down,
# no linked issue, no Done-when section) -> ask (surface to a human/session judgment)
# Only a real merge is gated; every other pull_request_write method is passed through UNTOUCHED
# (bare exit 0 → normal permissioning still applies), NOT auto-granted.
#
# WHY "grant" (not a bare exit 0) on the satisfied path (ersatztv#314 root cause): 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 prompt still fires. The gate therefore only ever
# ADDED a deny/ask net; it never REMOVED the baseline prompt on the happy path, so a satisfied merge
# was confirmed twice (conversationally + a redundant mechanical prompt). Emitting permissionDecision
# "allow" is what actually suppresses the prompt — "derive consent from state" made real.
#
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
set -euo pipefail
input=$(cat)
decide() { # $1=grant|allow|deny|ask $2=reason
case "$1" in
# grant = the gate is SATISFIED → auto-approve so no redundant permission prompt fires.
grant) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}'; exit 0 ;;
# allow = not our concern (non-merge method) → pass through untouched; normal permissioning applies.
allow) exit 0 ;;
deny) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'; exit 0 ;;
ask) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'; exit 0 ;;
esac
}
method=$(printf '%s' "$input" | jq -r '.tool_input.method // ""' 2>/dev/null || true)
[ "$method" = "merge" ] || decide allow ""
owner=$(printf '%s' "$input" | jq -r '.tool_input.owner // ""' 2>/dev/null || true)
repo=$(printf '%s' "$input" | jq -r '.tool_input.repo // ""' 2>/dev/null || true)
pr=$(printf '%s' "$input" | jq -r '.tool_input.pull_number // ""' 2>/dev/null || true)
mwcs=$(printf '%s' "$input" | jq -r '.tool_input.merge_when_checks_succeed // false' 2>/dev/null || true)
[ -n "$owner" ] && [ -n "$repo" ] && [ -n "$pr" ] || decide ask "H6 merge gate: could not read owner/repo/pull_number from the merge call; confirm manually that CI is green and the issue's Done-when boxes are ticked."
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
# curl wrapper carrying whichever auth is configured; empty output on any failure.
gq() {
local path="$1"
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
else
return 1
fi
}
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
decide ask "H6 merge gate: no Gitea credentials in env (ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH), so CI/Done-when state can't be verified. Confirm manually that CI is green and the linked issue's Done-when boxes are all ticked, then approve."
fi
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
[ -n "$prjson" ] || decide ask "H6 merge gate: could not fetch PR #$pr from Gitea (unreachable or auth rejected). Verify CI-green + Done-when manually before merging."
sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
files=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=100" | jq -r '.[].filename // empty' 2>/dev/null || true)
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
# Docs/process-only PR: the Done-when + review-verdict gate doesn't apply — but this exemption is a
# file-TYPE bypass, NOT the a+b+c "provably reviewed & ready" proof, so it does NOT auto-grant. It
# passes through to normal permissioning (one prompt). This deliberately keeps a human in the loop for
# process-control files (.claude/ / .gitea/ / .husky/ — the gate, CI, and git hooks themselves): a PR
# that weakens the gate must not silently self-merge (ersatztv#317 review nit). Only the satisfied
# merge path below auto-grants.
decide allow "" # passthrough (exit 0 → normal prompt), NOT grant
fi
# --- Linked issue: Gitea auto-close keywords in the PR body. ---
issues=$(printf '%s' "$body" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
[ -n "$issues" ] || decide ask "H6 merge gate: PR #$pr has no linked issue (no 'fixes #N' / 'closes #N' in its body), so there is no Done-when checklist to derive consent from. Confirm the work is complete + reviewed, then approve."
# --- (b) Done-when checkboxes: every linked issue must have an all-ticked section. ---
for n in $issues; do
ibody=$(gq "repos/$owner/$repo/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
[ -n "$ibody" ] || decide ask "H6 merge gate: could not fetch linked issue #$n. Verify its Done-when checklist manually before merging."
# Slice the "## Done-when" section: from that header to the next "## " (or EOF).
section=$(printf '%s\n' "$ibody" | awk '
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
grab && /^##[[:space:]]/ {grab=0}
grab {print}')
if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
decide ask "H6 merge gate: linked issue #$n has no '## Done-when' checklist section (the merge-consent convention — see CLAUDE.md Task Completion Protocol). Add one, or confirm completion manually and approve."
fi
unchecked=$(printf '%s\n' "$section" | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
if [ "${unchecked:-0}" -gt 0 ]; then
decide deny "H6 merge gate: BLOCKED — linked issue #$n has $unchecked unticked box(es) in its ## Done-when checklist. Finish (or explicitly tick) every completion criterion — including the adversarial-review box — before merging PR #$pr."
fi
done
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
if [ "$mwcs" != "true" ]; then
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
state=$(gq "repos/$owner/$repo/commits/$sha/status" | jq -r '.state // ""' 2>/dev/null || true)
case "$state" in
success) : ;;
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
*) decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success'. Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging." ;;
esac
fi
# --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the
# CURRENT head sha, so the latest commit is proven-reviewed (ersatztv#242: re-review the fix
# commit, not just the initial diff). Graceful adoption mirrors (b): a verdict comment that
# references head must be positive -> allow; one that exists only for an OLDER commit -> deny
# (the stale-review failure mode); NO verdict comment at all -> ask (convention not yet used).
[ -n "$sha" ] || decide ask "H10 merge gate: could not resolve PR #$pr head sha to verify a review verdict. Confirm the review covered the latest commit before merging."
short=${sha:0:7}
comments=$(gq "repos/$owner/$repo/issues/$pr/comments?limit=100")
if [ -z "$comments" ]; then
decide ask "H10 merge gate: could not fetch PR #$pr comments to verify a head-referencing review verdict ($short). Confirm the adversarial/Codex review covered the latest commit before merging."
fi
# Verdict lines across all comment bodies: a real verdict line STARTS with the marker (after optional
# leading whitespace). Anchoring to line-start is deliberate — it rejects a comment that merely QUOTES
# the positive template mid-sentence (an instruction "please post: Review-verdict: MERGEABLE @ <sha>",
# or the gate's own suggestion text echoed back), which would otherwise self-approve the merge.
verdicts=$(printf '%s' "$comments" | jq -r '.[].body // empty' 2>/dev/null | grep -iE '^[[:space:]]*review-verdict:' || true)
if [ -z "$verdicts" ]; then
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve."
fi
# Classify each verdict line by the sha it references (its "@ <sha>" field) and its verdict word.
# A line references the CURRENT head iff head BEGINS WITH that sha token AND the token is >=7 chars
# (git short-sha prefix semantics) — NOT a loose substring test: an older sha that merely contains
# the head prefix, or the head prefix appearing in an unrelated URL on the line, must NOT count
# (adversarial false-opens). The verdict token must sit right after the marker on the same line.
head_pos=0; head_neg=0; stale=0
while IFS= read -r line; do
[ -n "$line" ] || continue
# The sha the line references: the hex token in its "@ <sha>" field (>=7 chars), lowercased.
ref=$(printf '%s' "$line" | grep -ioE '@[[:space:]]*[0-9a-f]{7,40}' | head -1 \
| grep -oiE '[0-9a-f]{7,40}' | tr 'A-F' 'a-f' || true)
is_pos=0
# Positive iff the line's OWN leading verdict word (right after the line-start marker) is positive —
# anchored so a second, later `review-verdict: mergeable` substring on a BLOCKED line can't flip it.
if printf '%s' "$line" | grep -iqE '^[[:space:]]*review-verdict:[[:space:]]*(mergeable|approved|lgtm)'; then is_pos=1; fi
[ -z "$ref" ] && continue # marker present but no @<sha> -> falls through to the final ask
case "$sha" in
"$ref"*) if [ "$is_pos" = 1 ]; then head_pos=1; else head_neg=1; fi ;;
*) stale=1 ;;
esac
done <<VERDICTS
$verdicts
VERDICTS
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier MERGEABLE
# on the SAME head; and if the head were fixed the sha would change, so this can't wrongly block).
if [ "$head_neg" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr."
fi
if [ "$head_pos" = 1 ]; then
# (a) CI green + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
fi
if [ "$stale" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'."
fi
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve."
# All derivable and satisfied -> auto-grant (defensive: the head_pos branch above already exits here).
decide grant "H6/H10 merge gate: satisfied — auto-granted."
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# PreToolUse / browser-navigate — deny opening download/stream endpoints in a tab
# (they hang the MCP session; curl them instead). Fail-open on parse trouble.
set -euo pipefail
input=$(cat)
url=$(printf '%s' "$input" | jq -r '.tool_input.url // ""' 2>/dev/null || true)
if printf '%s' "$url" | grep -qE '/iptv/|\.m3u8|/artwork/|playback\.m3u8'; then
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Blocked: do not open download/stream endpoints (/iptv, .m3u8, /artwork, playback.m3u8) in a browser tab — they stall the MCP session. curl them instead (docs/handoffs lore)."}}'
fi
exit 0
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# PreToolUse / Bash — deny `git commit`/`git merge` inside a sibling worktree that
# a DIFFERENT session created (burned us twice — #289 path-leak, the plumbing-merge
# workaround exists precisely because of this). Ownership is a `.claude-worktree-owner`
# marker (session id) written at `git worktree add` time by posttooluse-worktree-marker.sh.
#
# Fail-open by design: no marker, unparsable input, or marker == this session → allow.
# So the main tree (never marked) and pre-convention worktrees (no marker) are unaffected;
# only a commit/merge into another session's marked worktree is blocked.
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
me=$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)
# Only guard the state-mutating ops. Match `git commit`/`git merge` in command position
# (line start or after a shell separator) so a quoted mention never false-trips.
printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git[[:space:]]+(-C[[:space:]]+[^[:space:]]+[[:space:]]+)?(commit|merge)\b' || exit 0
[ -z "$cwd" ] && cwd="$PWD"
# Determine the effective directory the git op runs in. Two common redirections in the
# lore's usage move it off the session cwd: `git -C <path>` and a leading `cd <path> &&`.
effdir="$cwd"
cpath=$(printf '%s' "$cmd" | grep -oE 'git[[:space:]]+-C[[:space:]]+[^[:space:]&|;]+' | head -1 | sed -E 's/^git[[:space:]]+-C[[:space:]]+//' | tr -d '"'"'"'' || true)
cdpath=$(printf '%s' "$cmd" | grep -oE '^[[:space:]]*cd[[:space:]]+[^[:space:]&|;]+' | head -1 | sed -E 's/^[[:space:]]*cd[[:space:]]+//' | tr -d '"'"'"'' || true)
if [ -n "${cpath:-}" ]; then
effdir="$cpath"
elif [ -n "${cdpath:-}" ]; then
effdir="$cdpath"
fi
# Resolve a relative effective dir against the session cwd.
case "$effdir" in /*) : ;; *) effdir="$cwd/$effdir" ;; esac
root=$(git -C "$effdir" rev-parse --show-toplevel 2>/dev/null || true)
[ -z "$root" ] && exit 0
marker="$root/.claude-worktree-owner"
[ -f "$marker" ] || exit 0
owner=$(tr -d '[:space:]' < "$marker" 2>/dev/null || true)
[ -z "$owner" ] && exit 0
[ "$owner" = "$me" ] && exit 0
# Marker names a DIFFERENT session → deny.
jq -n --arg o "$owner" --arg r "$root" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("Blocked: worktree \($r) is owned by session \($o), not this one. Never commit/merge inside a sibling worktree another session created (#289 path-leak, plumbing-merge workaround). Commit from your own tree; if you genuinely own this worktree now, overwrite its .claude-worktree-owner marker with your session id.")}}'
exit 0
+64
View File
@@ -0,0 +1,64 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-bash-guard.sh\"",
"timeout": 10
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-worktree-guard.sh\"",
"timeout": 10
}
]
},
{
"matcher": "mcp__plugin_playwright_playwright__browser_navigate|mcp__claude-in-chrome__navigate",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-nav-guard.sh\"",
"timeout": 10
}
]
},
{
"matcher": "Agent|Task",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-agent-ram.sh\"",
"timeout": 10
}
]
},
{
"matcher": "mcp__gitea__pull_request_write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-merge-consent.sh\"",
"timeout": 15
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse-worktree-marker.sh\"",
"timeout": 10
}
]
}
]
}
}
+201 -3
View File
@@ -169,7 +169,12 @@ jobs:
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
- name: MySql — model drift + apply all migrations to a fresh DB
env:
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;"
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
# share a runner host (each spins its own mysql:8.4 service) and starve each other. That
# contention produced both "Command Timeout expired" and mid-replay connection drops
# (MySqlEndOfStreamException) — neither is a model problem. See #13 / #236.
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;"
run: |
set -euo pipefail
export PATH="$PATH:$HOME/.dotnet/tools"
@@ -178,8 +183,23 @@ jobs:
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
echo "::endgroup::"
echo "::group::MySql apply all migrations to a fresh DB"
dotnet ef database update --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
# Retry the apply: under concurrent-runner MySQL contention the server can drop the
# connection mid-replay. Each attempt resumes from __EFMigrationsHistory (EF wraps each
# migration in its own transaction, so an interrupted migration rolls back cleanly and the
# retry continues from the last committed one) — so this only papers over infra flakiness,
# never a real migration failure, which fails deterministically on every attempt.
attempt=1
max=3
until dotnet ef database update --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql; do
if [ "$attempt" -ge "$max" ]; then
echo "MySql apply failed after ${max} attempts" >&2
exit 1
fi
echo "MySql apply attempt ${attempt} failed (likely runner MySQL contention); retrying in 15s..." >&2
attempt=$((attempt + 1))
sleep 15
done
echo "::endgroup::"
build:
@@ -341,3 +361,181 @@ jobs:
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#303 H9): docs/decisions.md is an append-only log. Fails a PR that deletes or
# rewrites a settled entry (numstat reports >0 deleted lines) unless a commit in the range carries
# the [decisions-edit] override token for a documented factual fix. Same script the Husky commit-msg
# hook calls, so local and CI enforcement can't drift. Seconds-long git diff -> keep it off the build runners.
decisions-guard:
name: decisions.md append-only
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Enforce append-only
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
./.claude/hooks/decisions-guard.sh range "origin/${base_ref}" HEAD
- name: Consolidation-floor reminder (non-blocking)
run: |
# Consolidation is primarily a release step; this is the between-releases floor. The metric is
# the file's LINE COUNT — the context an agent actually burns reading the log — not entry count.
# Floor 1800 keeps the whole log inside one default 2000-line Read (headroom for the reader's
# own overhead). Nudge (never fail) past it so append-only can't grow past what agents can read.
n=$(wc -l < docs/decisions.md | tr -d ' ')
echo "docs/decisions.md is ${n} lines (consolidation floor: 1800; one Read caps at 2000)."
if [ "${n:-0}" -gt 1800 ]; then
echo "::warning::docs/decisions.md is ${n} lines (>1800) — larger than agents can comfortably read in one pass. Do a consolidation pass (prune/merge superseded entries with [decisions-edit]); don't wait for the next release. See the decisions.md header."
fi
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
# surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated
# artifacts — v1.json (OpenAPI spec), v1.d.ts (SPA client), endpoint-index.md — MUST
# already be regenerated in the diff. We rebuild them from source and fail on any drift.
# Also covers the "regenerate artifacts after merging main into a PR branch" lore bullet.
#
# Path-gated INSIDE the job (not via top-level `if:`) so the check always reports a
# status on every PR and can be a required check without stalling API-free PRs: when no
# API path changed, the expensive steps skip and the job passes trivially.
api-docs:
name: API docs in sync (OpenAPI + endpoint index)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect API-surface changes
id: detect
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
if printf '%s\n' "$changed" | grep -Eq '^ErsatzTV/Controllers/Api/|^ErsatzTV\.Core/Api/'; then
echo "api_changed=true" >> "$GITHUB_OUTPUT"
echo "API surface changed -> will verify generated artifacts are in sync."
else
echo "api_changed=false" >> "$GITHUB_OUTPUT"
echo "No API-surface change -> skipping regeneration (job passes)."
fi
- name: Setup .NET
if: steps.detect.outputs.api_changed == 'true'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
if: steps.detect.outputs.api_changed == 'true'
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
if: steps.detect.outputs.api_changed == 'true'
run: dotnet restore
- name: Setup Node
if: steps.detect.outputs.api_changed == 'true'
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install SPA dependencies
if: steps.detect.outputs.api_changed == 'true'
working-directory: web
run: npm ci
- name: Regenerate OpenAPI spec + endpoint index
if: steps.detect.outputs.api_changed == 'true'
run: ./scripts/update-openapi.sh
- name: Regenerate SPA API client types
if: steps.detect.outputs.api_changed == 'true'
working-directory: web
run: npm run generate:api
- name: Fail on stale generated artifacts
if: steps.detect.outputs.api_changed == 'true'
run: |
if ! git diff --exit-code -- \
ErsatzTV/wwwroot/openapi/v1.json \
web/src/api/generated/v1.d.ts \
docs/endpoint-index.md; then
echo "::error::This PR changes the API surface but its generated artifacts are stale. Run './scripts/update-openapi.sh && (cd web && npm run generate:api)' and commit v1.json / v1.d.ts / endpoint-index.md in THIS PR (CLAUDE.md → Conventions; ersatztv#303 H4/H5)."
exit 1
fi
echo "Generated API artifacts are in sync."
# Formatting-as-you-touch gate (ersatztv#311): verify the .cs files THIS PR changed conform to
# .editorconfig (style + charset=utf-8, i.e. no UTF-8 BOM). Scoped to changed files so it enforces
# "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500 pre-existing
# BOM files. A PR that touches no .cs skips the expensive steps and passes trivially (always reports
# a status, so it is safe as a required check).
format:
name: Formatting (changed .cs conform to .editorconfig)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed C# files
id: detect
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs' 2>/dev/null || true)"
echo "Changed .cs files in this PR:"; printf '%s\n' "$changed"
if [ -n "$changed" ]; then
printf '%s\n' "$changed" > /tmp/changed-cs.txt
echo "cs_changed=true" >> "$GITHUB_OUTPUT"
echo "-> will verify these files conform to .editorconfig."
else
echo "cs_changed=false" >> "$GITHUB_OUTPUT"
echo "No .cs change -> skipping format verify (job passes)."
fi
- name: Setup .NET
if: steps.detect.outputs.cs_changed == 'true'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
if: steps.detect.outputs.cs_changed == 'true'
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
if: steps.detect.outputs.cs_changed == 'true'
run: dotnet restore
- name: Verify formatting of changed .cs files
if: steps.detect.outputs.cs_changed == 'true'
shell: bash
run: |
mapfile -t files < /tmp/changed-cs.txt
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig..."
if ! dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"; then
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (formatting or a UTF-8 BOM). Run 'dotnet format ErsatzTV.sln --include <files>' and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
exit 1
fi
echo "All changed .cs files conform to .editorconfig."
+8 -1
View File
@@ -54,4 +54,11 @@ docker-compose.override.yml
ErsatzTV/wwwroot/v2/
ErsatzTV/wwwroot/app/
web/dist/
web/node_modules/
web/node_modules
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
/*.png
.playwright-mcp/
# Per-session worktree-ownership marker (H7, ersatztv#303) — local, never committed
.claude-worktree-owner
+5
View File
@@ -8,3 +8,8 @@ grep -q '^Co-Authored-By:' "$1" || {
echo 'husky - commit message missing Co-Authored-By trailer'
exit 1
}
# H9 (ersatztv#303) — docs/decisions.md is append-only. Block a commit that rewrites a settled
# entry unless the message carries [decisions-edit]. commit-msg runs after the index is final, so
# the staged diff is what's being committed; the message file ($1) supplies the override token.
./.claude/hooks/decisions-guard.sh staged "$1" || exit 1
+10
View File
@@ -1,6 +1,16 @@
cd web && npx lint-staged || exit 1
cd ..
# H3 (ersatztv#303) — never commit a screenshot dropped at the repo root. Belt-and-suspenders with
# .gitignore (catches a forced `git add -f`). Root-level *.png only; nested paths are legit assets.
root_png=$(git diff --cached --name-only --diff-filter=ACM | grep -iE '^[^/]+\.png$' || true)
if [ -n "$root_png" ]; then
echo "husky - refusing to commit root-level screenshot(s):"
printf ' %s\n' $root_png
echo " Move it out of the repo root or drop it (root *.png are review/debug artifacts; see .gitignore)."
exit 1
fi
# dotnet format on staged .cs files (repo root). Scoped to the staged files so we
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
# ~20-40s sln load for web-only commits).
+11
View File
@@ -1,9 +1,20 @@
# H6 merge-consent backstop (ersatztv#303): gate a direct push to main on the linked issue's
# ## Done-when checklist. Read git's pre-push ref lines FIRST (before the web checks below, which
# may consume stdin) and forward them. Fail-open: no creds / not main / docs-only -> allow.
_prepush_refs="$(cat)"
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-donewhen.sh || exit 1
# Git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE while running hooks. In a worktree
# (or any subdir), an explicit GIT_DIR makes nested `git` commands mislocate the working
# tree — notably `check:api`'s `git diff --exit-code` (run from web/) silently reports "no
# diff" and lets drift through. Unset them so nested git rediscovers the repo normally.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# H11 (ersatztv#311): refuse to push a branch that is BEHIND origin/main — rebase, don't merge
# main in (a merge drags in files you never touched, e.g. legacy-BOM .cs, and trips the format
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
./.claude/hooks/prepush-rebase-check.sh || exit 1
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
+13 -7
View File
@@ -5,7 +5,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture
- **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves the remaining un-migrated admin screens — playback troubleshooting, multi/rerun collections, and playlist editing depth; Blazor home = `/system/health`, reachable via the Settings → System "Classic UI" link. Media detail pages + image folder browser landed in the SPA via #141 (PR #183); its removal is #91 phase (b), gated on #145 (playback troubleshooting) and API gaps #151/#152/#153/#155 (scheduling parity #144/#162 DONE 2026-07-07: blocks/templates/decos/deco-templates/playout editors all in the SPA; #141/#158/#161/#180 also DONE)
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root `/` and every legacy route now 302 to `/app`, either via an explicit redirect in `ErsatzTV/LegacyUiRedirects.cs` or the Startup catch-all fallback (any unmatched non-`/api`/`/artwork`/`/docs`/`/openapi` path → `/app`). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE.
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
@@ -15,7 +15,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, legacy Blazor pages, DI setup |
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, DI setup |
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
@@ -35,10 +35,10 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Deployment
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz`/config` in container
- **Docker host**: bumblebee (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on bumblebee`/config` in container
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod deploys via **Komodo GitOps**: the `media-servers` compose in `timothy/server-management` (`docker/bumblebee/stacks/media-servers/compose.yaml`) pins the version tag (currently `26.5.0`, deployed 2026-07-07); releasing = tag here, wait for the image build, bump that pin and push (the Komodo pre-deploy hook backs up before recreating). Test container tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** `media-servers` stack follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually deploy the stack (Global Auto Update is the daily fallback). Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
## Development
@@ -55,7 +55,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
@@ -70,7 +70,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
- Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
- Keep UI thin: the SPA talks to `/api/*` only; controllers delegate to MediatR handlers. All UI is in the SPA (`web/`)
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
@@ -82,6 +82,12 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), post a PR comment with a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED> @ <head-sha>` — this proves the *latest* commit was reviewed, not a stale earlier diff (ersatztv#242).
- `.husky/pre-push``prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
+5 -9
View File
@@ -5,10 +5,7 @@
<ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageVersion Include="BlazorSortable" Version="6.0.2" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
<PackageVersion Include="CliWrap" Version="3.10.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.79" />
@@ -22,8 +19,6 @@
<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="Heron.MudCalendar" Version="3.4.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
<PackageVersion Include="Jint" Version="4.5.0" />
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
@@ -33,14 +28,17 @@
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="MediatR" Version="[12.5.0]" />
<PackageVersion Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<!-- Direct-pin over the 2.0.0 transitive (from Microsoft.AspNetCore.OpenApi + Scalar.AspNetCore):
2.0.0 is GHSA-v5pm-xwqc-g5wc (High — stack overflow parsing a circular $ref). Fixed in 2.7.5.
Referenced directly in ErsatzTV.csproj so the override actually resolves (CPM). See ersatztv#314/#8. -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="[9.0.12,10)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="[9.0.12,10)" />
@@ -61,8 +59,6 @@
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
<PackageVersion Include="MudBlazor" Version="8.15.0" />
<PackageVersion Include="NaturalSort.Extension" Version="4.4.1" />
<PackageVersion Include="NCalcSync" Version="6.3.2" />
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
@@ -1,5 +1,5 @@
using System.Net;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Images;
namespace ErsatzTV.Application.Artworks;
@@ -11,7 +11,14 @@ public record ArtworkContentTypeModel(string Path, string ContentType)
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
? Path
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
// The artwork serve routes now sniff the content type from the stored file and no longer honor a
// client-supplied ?contentType= (issue #283 — that reflection was the stored-XSS sink), so the
// directly-usable URL is just the path.
public string UrlWithContentType => Path;
// Defense-in-depth: never persist a content type outside the image allow-list, so a value that
// slipped in via the {path, contentType} JSON DTOs can't later be reflected anywhere. The serve
// path derives the type from the file regardless; this only keeps stored metadata honest.
public ArtworkContentTypeModel Sanitized() =>
ImageContentTypes.IsAccepted(ContentType) ? this : this with { ContentType = string.Empty };
}
@@ -9,5 +9,5 @@ namespace ErsatzTV.Application.Artworks;
/// landing it in the same on-disk cache the Blazor UI uses (via <c>IImageCache</c>),
/// so the returned path is equivalent to a Blazor-uploaded image.
/// </summary>
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
public record UploadArtwork(Stream Stream, ArtworkKind ArtworkKind)
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
@@ -1,22 +1,13 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.Images;
namespace ErsatzTV.Application.Artworks;
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
{
// png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
// formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
private static readonly System.Collections.Generic.HashSet<string> AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/png",
"image/jpeg",
"image/gif",
"image/webp"
};
private readonly IImageCache _imageCache;
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
@@ -25,15 +16,31 @@ public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseEr
UploadArtwork request,
CancellationToken cancellationToken)
{
string contentType = (request.ContentType ?? string.Empty).Trim();
if (!AcceptedContentTypes.Contains(contentType))
// Buffer the upload so we can sniff its true format before storing it. The request body is
// already bounded by the Kestrel MaxRequestBodySize / the controller's size check, so this
// is a bounded read.
byte[] bytes;
await using (var buffer = new MemoryStream())
{
return BaseError.New(
$"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
await request.Stream.CopyToAsync(buffer, cancellationToken);
bytes = buffer.ToArray();
}
// Derive the content type from the actual bytes, never from the client-declared value
// (issue #283 — a spoofed image/png header let a <script> payload be stored and later served
// as HTML). A payload that isn't a supported raster image is rejected here.
Option<string> maybeContentType = ImageContentTypes.DetectContentType(bytes);
if (maybeContentType.IsNone)
{
return BaseError.New(
$"Uploaded file is not a supported image; supported types are: {string.Join(", ", ImageContentTypes.Accepted)}");
}
string contentType = maybeContentType.IfNone(string.Empty);
using var toCache = new MemoryStream(bytes, writable: false);
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
request.Stream,
toCache,
request.ArtworkKind);
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
@@ -0,0 +1,28 @@
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Shared constants for the browser-SPA session authentication (issue #295): the cookie scheme name,
/// the custom claim types the local-login path stamps onto the principal, and the auth-method marker
/// values. The web host (cookie <c>OnValidatePrincipal</c>, <c>AuthController</c>) and the Application
/// handlers both reference these so the claim contract has a single definition.
/// </summary>
public static class AuthConstants
{
/// <summary>The cookie authentication scheme name shared by local login and the OIDC callback.</summary>
public const string CookieScheme = "cookie";
/// <summary>The OIDC challenge scheme name.</summary>
public const string OidcScheme = "oidc";
/// <summary>Claim type recording how the principal signed in (<see cref="MethodLocal" /> / <see cref="MethodOidc" />).</summary>
public const string AuthMethodClaim = "etv:auth_method";
/// <summary>Claim type carrying the local admin's security stamp (checked on every request to revoke sessions).</summary>
public const string SecurityStampClaim = "etv:security_stamp";
public const string MethodLocal = "local";
public const string MethodOidc = "oidc";
/// <summary>Minimum length for a local admin password.</summary>
public const int MinPasswordLength = 8;
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Changes the local admin password after verifying the current one. Rotates the security stamp so all
/// other sessions are revoked. <see cref="Username" /> is the signed-in principal's name.
/// </summary>
public record ChangeLocalAdminPassword(string Username, string CurrentPassword, string NewPassword)
: IRequest<Either<BaseError, LocalAdminPrincipal>>;
@@ -0,0 +1,68 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class ChangeLocalAdminPasswordHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILocalPasswordHasher passwordHasher)
: IRequestHandler<ChangeLocalAdminPassword, Either<BaseError, LocalAdminPrincipal>>
{
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
ChangeLocalAdminPassword request,
CancellationToken cancellationToken)
{
foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.NewPassword))
{
return error;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<ConfigElement> rows = await dbContext.ConfigElements
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
.ToListAsync(cancellationToken);
ConfigElement userRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminUsername.Key);
ConfigElement hashRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key);
ConfigElement stampRow = rows.Find(r => r.Key == ConfigElementKey.AuthSecurityStamp.Key);
if (hashRow is null)
{
return BaseError.New("No local administrator is configured");
}
string username = (request.Username ?? string.Empty).Trim();
bool userMatches = userRow is not null
&& string.Equals(userRow.Value, username, StringComparison.OrdinalIgnoreCase);
LocalPasswordVerification result =
passwordHasher.Verify(hashRow.Value, request.CurrentPassword ?? string.Empty);
if (!userMatches || result == LocalPasswordVerification.Failed)
{
return BaseError.New("Current password is incorrect");
}
// Atomic: the new hash and rotated stamp commit together, so a crash can't leave the new password
// active with the old stamp still authorizing revoked sessions.
string stamp = LocalAdminHelpers.NewSecurityStamp();
hashRow.Value = passwordHasher.Hash(request.NewPassword);
if (stampRow is null)
{
dbContext.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
}
else
{
stampRow.Value = stamp;
}
await dbContext.SaveChangesAsync(cancellationToken);
return new LocalAdminPrincipal(userRow.Value, stamp);
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// First-run setup-claim: creates the single local administrator. Fails if one already exists
/// (first-claim-wins), so a later anonymous call cannot take over the account.
/// </summary>
public record ClaimLocalAdmin(string Username, string Password) : IRequest<Either<BaseError, LocalAdminPrincipal>>;
@@ -0,0 +1,68 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class ClaimLocalAdminHandler(IDbContextFactory<TvContext> dbContextFactory, ILocalPasswordHasher passwordHasher)
: IRequestHandler<ClaimLocalAdmin, Either<BaseError, LocalAdminPrincipal>>
{
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
ClaimLocalAdmin request,
CancellationToken cancellationToken)
{
foreach (BaseError error in LocalAdminHelpers.ValidateNewCredentials(request.Username, request.Password))
{
return error;
}
string username = request.Username.Trim();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// Fast path for the common already-configured case (clean 409). The real first-claim-wins guard is
// the unique index on ConfigElement.Key + the single atomic SaveChanges below: two concurrent claims
// both pass this check, but only one INSERT of the three credential rows commits — the loser's
// SaveChanges violates the unique Key index and rolls back wholesale (no mixed-state credential).
bool alreadyConfigured = await dbContext.ConfigElements
.AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
if (alreadyConfigured)
{
return BaseError.New("A local administrator has already been configured");
}
string stamp = LocalAdminHelpers.NewSecurityStamp();
dbContext.ConfigElements.AddRange(
new ConfigElement { Key = ConfigElementKey.AuthLocalAdminUsername.Key, Value = username },
new ConfigElement
{
Key = ConfigElementKey.AuthLocalAdminPasswordHash.Key,
Value = passwordHasher.Hash(request.Password)
},
new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// A write conflict here is (almost always) a lost first-claim race — a concurrent claim inserted
// these keys first (unique Key index). Confirm the row now exists on a fresh context before
// reporting "already configured"; otherwise this was a genuine/transient DB error → rethrow rather
// than mask it.
await using TvContext verifyContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
bool nowConfigured = await verifyContext.ConfigElements
.AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
if (nowConfigured)
{
return BaseError.New("A local administrator has already been configured");
}
throw;
}
return new LocalAdminPrincipal(username, stamp);
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Application.Auth;
/// <summary>
/// The current local-admin security stamp, or <c>None</c> if no local admin is configured. The cookie
/// <c>OnValidatePrincipal</c> compares this to the principal's stamp claim on every request; a mismatch
/// (i.e. the password was changed) rejects the session.
/// </summary>
public record GetLocalAdminSecurityStamp : IRequest<Option<string>>;
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Auth;
public class GetLocalAdminSecurityStampHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetLocalAdminSecurityStamp, Option<string>>
{
public async Task<Option<string>> Handle(GetLocalAdminSecurityStamp request, CancellationToken cancellationToken) =>
await configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, cancellationToken);
}
@@ -0,0 +1,27 @@
namespace ErsatzTV.Application.Auth;
public enum LocalPasswordVerification
{
Failed,
Success,
SuccessRehashNeeded
}
/// <summary>
/// Wraps ASP.NET Core Identity's <c>PasswordHasher</c> (PBKDF2) behind a minimal, framework-agnostic
/// surface so the Auth handlers don't depend on Identity types directly.
/// </summary>
public interface ILocalPasswordHasher
{
/// <summary>Hashes a password for storage (random per-hash salt embedded in the returned string).</summary>
string Hash(string password);
/// <summary>Verifies a password against a stored hash in constant time (delegated to Identity).</summary>
LocalPasswordVerification Verify(string hash, string password);
/// <summary>
/// A stable, valid hash of a throwaway password. Verify against this when no real credential exists
/// so an unknown-username / unconfigured login costs the same as a real one (no user enumeration).
/// </summary>
string DummyHash { get; }
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Auth;
/// <summary>True once a local administrator credential has been set (first-run setup is complete).</summary>
public record IsLocalAdminConfigured : IRequest<bool>;
@@ -0,0 +1,15 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Auth;
public class IsLocalAdminConfiguredHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<IsLocalAdminConfigured, bool>
{
public async Task<bool> Handle(IsLocalAdminConfigured request, CancellationToken cancellationToken)
{
Option<ConfigElement> hash =
await configElementRepository.GetConfigElement(ConfigElementKey.AuthLocalAdminPasswordHash, cancellationToken);
return hash.IsSome;
}
}
@@ -0,0 +1,49 @@
using System.Security.Cryptography;
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
internal static class LocalAdminHelpers
{
public const int MaxUsernameLength = 256;
// Upper bound so an absurdly long password can't burn CPU in PBKDF2 (the request body is also capped
// by Kestrel, #283; this is defense-in-depth on the field itself).
public const int MaxPasswordLength = 1024;
/// <summary>128 bits of random, lowercase hex. Rotated on every password change to revoke sessions.</summary>
public static string NewSecurityStamp() =>
Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
/// <summary>Validates a new username + password. Returns the error, or None if valid.</summary>
public static Option<BaseError> ValidateNewCredentials(string username, string password)
{
string trimmed = (username ?? string.Empty).Trim();
if (trimmed.Length == 0)
{
return BaseError.New("Username is required");
}
if (trimmed.Length > MaxUsernameLength)
{
return BaseError.New("Username is too long");
}
return ValidatePassword(password);
}
public static Option<BaseError> ValidatePassword(string password)
{
if (string.IsNullOrEmpty(password) || password.Length < AuthConstants.MinPasswordLength)
{
return BaseError.New($"Password must be at least {AuthConstants.MinPasswordLength} characters");
}
if (password.Length > MaxPasswordLength)
{
return BaseError.New($"Password must be at most {MaxPasswordLength} characters");
}
return Option<BaseError>.None;
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Application.Auth;
/// <summary>
/// The identity of the single local administrator, as returned by a successful claim / login / password
/// change. The web host turns this into a cookie principal: <see cref="Username" /> becomes the name claim
/// and <see cref="SecurityStamp" /> is stamped as <see cref="AuthConstants.SecurityStampClaim" /> so a later
/// password change (which rotates the stamp) revokes the session.
/// </summary>
public record LocalAdminPrincipal(string Username, string SecurityStamp);
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Identity;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// <see cref="ILocalPasswordHasher" /> backed by ASP.NET Core Identity's <see cref="PasswordHasher{TUser}" />
/// (PBKDF2-HMAC-SHA512, per-hash random salt, format-versioned so a future work-factor bump is a
/// transparent rehash-on-verify). Stateless and thread-safe → registered as a singleton.
/// </summary>
public sealed class LocalPasswordHasher : ILocalPasswordHasher
{
// The generic user parameter is unused by the hasher (it takes no per-user data), so a shared sentinel
// is fine.
private static readonly object Sentinel = new();
private readonly PasswordHasher<object> _hasher = new();
private readonly Lazy<string> _dummyHash;
public LocalPasswordHasher() =>
_dummyHash = new Lazy<string>(() => _hasher.HashPassword(Sentinel, "not-a-real-password"));
public string DummyHash => _dummyHash.Value;
public string Hash(string password) => _hasher.HashPassword(Sentinel, password);
public LocalPasswordVerification Verify(string hash, string password) =>
_hasher.VerifyHashedPassword(Sentinel, hash, password) switch
{
PasswordVerificationResult.Success => LocalPasswordVerification.Success,
PasswordVerificationResult.SuccessRehashNeeded => LocalPasswordVerification.SuccessRehashNeeded,
_ => LocalPasswordVerification.Failed
};
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Rotates the local admin security stamp, revoking every outstanding local session server-side (their
/// cookies carry the old stamp and fail <c>OnValidatePrincipal</c> on their next request). Used by logout
/// so signing out actually ends the session server-side, not just client-side. A no-op when no local
/// admin is configured. OIDC sessions are unaffected (they carry no stamp).
/// </summary>
public record RotateLocalAdminSecurityStamp : IRequest<Unit>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class RotateLocalAdminSecurityStampHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<RotateLocalAdminSecurityStamp, Unit>
{
public async Task<Unit> Handle(RotateLocalAdminSecurityStamp request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
ConfigElement stampRow = await dbContext.ConfigElements
.FirstOrDefaultAsync(c => c.Key == ConfigElementKey.AuthSecurityStamp.Key, cancellationToken);
// No local admin configured → nothing to revoke.
if (stampRow is null)
{
return Unit.Default;
}
stampRow.Value = LocalAdminHelpers.NewSecurityStamp();
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Recovery/bootstrap path: (re)sets the local admin from configuration (env
/// <c>Auth:LocalAdmin:Username</c>/<c>Password</c>). Overwrites any existing credential and rotates the
/// stamp (revoking sessions), so an operator who is locked out can reset by setting the env and
/// restarting. Runs at startup only when a password is configured.
/// </summary>
public record SeedLocalAdminFromEnvironment(string Username, string Password) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,63 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class SeedLocalAdminFromEnvironmentHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILocalPasswordHasher passwordHasher)
: IRequestHandler<SeedLocalAdminFromEnvironment, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
SeedLocalAdminFromEnvironment request,
CancellationToken cancellationToken)
{
string username = (request.Username ?? string.Empty).Trim();
if (username.Length == 0)
{
username = "admin";
}
if (username.Length > LocalAdminHelpers.MaxUsernameLength)
{
return BaseError.New("Seed username is too long");
}
foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.Password))
{
return error;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<ConfigElement> rows = await dbContext.ConfigElements
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
.ToListAsync(cancellationToken);
// Overwrite (recovery/bootstrap) atomically: username + new hash + rotated stamp commit together.
Upsert(dbContext, rows, ConfigElementKey.AuthLocalAdminUsername.Key, username);
Upsert(dbContext, rows, ConfigElementKey.AuthLocalAdminPasswordHash.Key, passwordHasher.Hash(request.Password));
Upsert(dbContext, rows, ConfigElementKey.AuthSecurityStamp.Key, LocalAdminHelpers.NewSecurityStamp());
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
private static void Upsert(TvContext dbContext, List<ConfigElement> existing, string key, string value)
{
ConfigElement row = existing.Find(r => r.Key == key);
if (row is null)
{
dbContext.ConfigElements.Add(new ConfigElement { Key = key, Value = value });
}
else
{
row.Value = value;
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Auth;
/// <summary>
/// Verifies a local-login username/password. On success returns the principal (username + current
/// security stamp) to sign into a cookie. A generic error (no username enumeration) on any failure.
/// </summary>
public record VerifyLocalAdminLogin(string Username, string Password) : IRequest<Either<BaseError, LocalAdminPrincipal>>;
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Auth;
public class VerifyLocalAdminLoginHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILocalPasswordHasher passwordHasher)
: IRequestHandler<VerifyLocalAdminLogin, Either<BaseError, LocalAdminPrincipal>>
{
private static readonly BaseError InvalidCredentials = BaseError.New("Invalid username or password");
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
VerifyLocalAdminLogin request,
CancellationToken cancellationToken)
{
string username = (request.Username ?? string.Empty).Trim();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// Read the hash and stamp in ONE snapshot so they are consistent (issue: a login racing a password
// change must not return a stamp newer than the hash it verified). A concurrent change is then either
// wholly before this read (the old password fails to verify) or wholly after it (we return the
// pre-change stamp, so the cookie AuthController issues is revoked on its very next request by
// CookieSecurityStampValidator). No writes happen here, so there is nothing to clobber.
Dictionary<string, string> config = await dbContext.ConfigElements
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
.ToDictionaryAsync(c => c.Key, c => c.Value, cancellationToken);
config.TryGetValue(ConfigElementKey.AuthLocalAdminUsername.Key, out string storedUser);
config.TryGetValue(ConfigElementKey.AuthLocalAdminPasswordHash.Key, out string storedHash);
config.TryGetValue(ConfigElementKey.AuthSecurityStamp.Key, out string stamp);
// Always run exactly one PBKDF2 verify — against a dummy hash when unconfigured/unknown — so response
// timing does not reveal whether the account exists (no user enumeration).
string candidateHash = storedHash ?? passwordHasher.DummyHash;
LocalPasswordVerification result = passwordHasher.Verify(candidateHash, request.Password ?? string.Empty);
bool userMatches = storedUser is not null
&& string.Equals(storedUser, username, StringComparison.OrdinalIgnoreCase);
if (storedHash is null || !userMatches || result == LocalPasswordVerification.Failed)
{
return InvalidCredentials;
}
return new LocalAdminPrincipal(storedUser, stamp ?? string.Empty);
}
}
+35
View File
@@ -38,6 +38,41 @@ internal static class Mapper
channel.IsEnabled,
channel.ShowInEpg);
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(Channel channel, int playoutCount)
{
ArtworkContentTypeModel logo = GetLogo(channel);
return new ChannelDetailResponseModel(
channel.Id,
channel.Number,
channel.Name,
channel.Group,
channel.Categories,
channel.FFmpegProfileId,
channel.SlugSeconds,
new ChannelLogoResponseModel(logo.Path, logo.ContentType),
channel.StreamSelectorMode,
channel.StreamSelector,
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.PlayoutSource,
channel.PlayoutMode,
channel.MirrorSourceChannelId,
channel.PlayoutOffset,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
playoutCount,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate,
channel.SongVideoMode,
channel.TranscodeMode,
channel.IdleBehavior,
channel.IsEnabled,
channel.ShowInEpg);
}
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
channel.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelByIdForApi(int Id) : IRequest<Option<ChannelDetailResponseModel>>;
@@ -0,0 +1,15 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByIdForApi, Option<ChannelDetailResponseModel>>
{
public Task<Option<ChannelDetailResponseModel>> Handle(
GetChannelByIdForApi request,
CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0));
}
@@ -119,6 +119,7 @@ public class GetChannelGuideDataHandler(
responseChannels.Add(
new ChannelGuideChannelResponseModel(
channel.Id,
channel.Number,
channel.Name,
programmes.OrderBy(p => p.Start).ToList()));
@@ -0,0 +1,6 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelPlaybackSource(int ChannelId, DateTimeOffset At)
: IRequest<Option<ChannelPlaybackSourceResponseModel>>;
@@ -0,0 +1,171 @@
#nullable enable
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Resolves the physical playout item at a point in time without invoking the streaming or FFmpeg pipeline.
/// Guide projection is deliberately not used here: guide entries may merge filler or split a block differently
/// from the actual media-item boundaries a player must follow.
/// </summary>
public class GetChannelPlaybackSourceHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelPlaybackSource, Option<ChannelPlaybackSourceResponseModel>>
{
public async Task<Option<ChannelPlaybackSourceResponseModel>> Handle(
GetChannelPlaybackSource request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Channel? channel = await dbContext.Channels
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Id == request.ChannelId, cancellationToken);
if (channel is null)
{
return None;
}
// Deleting a mirror's source sets this nullable FK to null. Do not self-resolve to a stale
// playout that may remain attached to the mirror channel.
if (channel.PlayoutSource == ChannelPlayoutSource.Mirror && channel.MirrorSourceChannelId is null)
{
return None;
}
int sourceChannelId = channel.PlayoutSource == ChannelPlayoutSource.Mirror
? channel.MirrorSourceChannelId!.Value
: channel.Id;
TimeSpan playoutOffset = channel.PlayoutSource == ChannelPlayoutSource.Mirror
? channel.PlayoutOffset ?? TimeSpan.Zero
: TimeSpan.Zero;
DateTime sourceAtUtc = request.At.UtcDateTime - playoutOffset;
PlayoutItem? active = await ActiveItems(dbContext, sourceChannelId, sourceAtUtc)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync(cancellationToken);
DateTime? nextSourceTransition = active?.Finish;
if (nextSourceTransition is null)
{
nextSourceTransition = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Playout.ChannelId == sourceChannelId && pi.Start > sourceAtUtc)
.OrderBy(pi => pi.Start)
.Select(pi => (DateTime?)pi.Start)
.FirstOrDefaultAsync(cancellationToken);
}
DateTimeOffset resolvedAt = request.At.ToUniversalTime();
DateTimeOffset sourceAt = new(sourceAtUtc, TimeSpan.Zero);
DateTimeOffset? nextTransitionAt = nextSourceTransition.HasValue
? new DateTimeOffset(nextSourceTransition.Value + playoutOffset, TimeSpan.Zero)
: null;
ChannelPlaybackItemResponseModel? playbackItem = active is null
? null
: ToPlaybackItem(active, sourceAtUtc, playoutOffset);
return new ChannelPlaybackSourceResponseModel(
channel.Id,
sourceChannelId,
resolvedAt,
sourceAt,
nextTransitionAt,
playbackItem);
}
private static IQueryable<PlayoutItem> ActiveItems(TvContext dbContext, int sourceChannelId, DateTime sourceAtUtc) =>
dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Playout.ChannelId == sourceChannelId)
.Where(pi => pi.Start <= sourceAtUtc && pi.Finish > sourceAtUtc)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.AsSplitQuery();
private static ChannelPlaybackItemResponseModel ToPlaybackItem(
PlayoutItem item,
DateTime sourceAtUtc,
TimeSpan playoutOffset)
{
TimeSpan currentOffset = item.InPoint + (sourceAtUtc - item.Start);
if (currentOffset < item.InPoint)
{
currentOffset = item.InPoint;
}
if (item.OutPoint > item.InPoint && currentOffset > item.OutPoint)
{
currentOffset = item.OutPoint;
}
return new ChannelPlaybackItemResponseModel(
item.Id,
item.MediaItemId,
new DateTimeOffset(item.Start + playoutOffset, TimeSpan.Zero),
new DateTimeOffset(item.Finish + playoutOffset, TimeSpan.Zero),
item.InPoint.Ticks,
currentOffset.Ticks,
item.OutPoint.Ticks,
item.FillerKind,
GetSourceReference(item.MediaItem));
}
private static ChannelPlaybackSourceReferenceResponseModel GetSourceReference(MediaItem mediaItem) =>
mediaItem switch
{
JellyfinMovie movie => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: movie.ItemId),
JellyfinEpisode episode => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: episode.ItemId),
PlexMovie movie => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: movie.Key),
PlexEpisode episode => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: episode.Key),
PlexOtherVideo video => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: video.Key),
EmbyMovie movie => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: movie.ItemId),
EmbyEpisode episode => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: episode.ItemId),
RemoteStream stream => Reference(ChannelPlaybackSourceKind.RemoteUrl, isLive: stream.IsLive),
Movie movie => LocalFile(movie.MediaVersions),
Episode episode => LocalFile(episode.MediaVersions),
MusicVideo video => LocalFile(video.MediaVersions),
OtherVideo video => LocalFile(video.MediaVersions),
Song song => LocalFile(song.MediaVersions),
Image image => LocalFile(image.MediaVersions),
_ => Reference(ChannelPlaybackSourceKind.Unsupported)
};
private static ChannelPlaybackSourceReferenceResponseModel LocalFile(IEnumerable<MediaVersion> versions)
{
string? path = versions.FirstOrDefault()?.MediaFiles.FirstOrDefault()?.Path;
return string.IsNullOrWhiteSpace(path)
? Reference(ChannelPlaybackSourceKind.Unsupported)
: Reference(ChannelPlaybackSourceKind.LocalFile, path: path);
}
private static ChannelPlaybackSourceReferenceResponseModel Reference(
ChannelPlaybackSourceKind kind,
string? itemId = null,
string? path = null,
bool isLive = false) =>
new(kind, itemId, path, isLive);
}
+14 -6
View File
@@ -15,9 +15,10 @@ public static class ConcurrencyExtensions
/// UPDATE of that row with <c>WHERE Version=@orig</c>, so a concurrent bump from a replace-all
/// editor would otherwise surface as an unhandled <see cref="DbUpdateConcurrencyException" /> →
/// 500 (issue #253 / #269). Phase-1 semantics for a missing <c>If-Match</c> is <b>force-write</b>,
/// so on a concurrency failure we adopt the stored token as both original (the retry's WHERE then
/// matches) and current (so we don't revert the concurrent bump) and retry — a client-wins merge
/// scoped to the token; our own modified scalars still win. Bounded to avoid a livelock; if the row
/// so on a concurrency failure we rebase onto the stored token: original becomes the stored value
/// (the retry's WHERE then matches) and current becomes stored + our pending delta (a bumper's ++
/// still advances the ETag past the concurrent writer's value — #269 rotation; a non-bumper adopts
/// it unchanged) and retry; our own modified scalars still win. Bounded to avoid a livelock; if the row
/// was deleted out from under us, that's a genuine conflict and rethrows.
/// </summary>
public static async Task<int> SaveChangesForcingVersion(
@@ -48,9 +49,16 @@ public static class ConcurrencyExtensions
}
PropertyEntry version = entry.Property(nameof(IVersionedAggregate.Version));
object currentVersion = databaseValues[nameof(IVersionedAggregate.Version)]!;
version.OriginalValue = currentVersion;
version.CurrentValue = currentVersion;
int dbVersion = (int)databaseValues[nameof(IVersionedAggregate.Version)]!;
// Rebase our pending delta on top of the stored token instead of adopting it verbatim:
// a Version-bumping sibling (pending current = original + 1) must still advance the
// ETag PAST the concurrent writer's value, or an editor holding that writer's ETag is
// never invalidated by our change (#269 rotation silently lost under race). Non-bumpers
// (delta 0, e.g. ErasePlayoutHistory) still adopt the stored token unchanged.
int pendingDelta = (int)version.CurrentValue! - (int)version.OriginalValue!;
version.OriginalValue = dbVersion;
version.CurrentValue = dbVersion + pendingDelta;
resolvedAny = true;
}
@@ -15,6 +15,7 @@
<PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" />
<PackageReference Include="WebMarkupMin.Core" />
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
@@ -139,7 +139,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile)
{
if (updateFFmpegProfile.Name.Length > 50)
if (string.IsNullOrWhiteSpace(updateFFmpegProfile.Name) || updateFFmpegProfile.Name.Length > 50)
{
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
}
@@ -3,6 +3,6 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Images;
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, string ContentType, int? MaxHeight = null)
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null)
: IRequest<
Either<BaseError, CachedImagePathViewModel>>;
@@ -1,6 +1,7 @@
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -95,9 +96,13 @@ public class
}
else
{
mimeType = !string.IsNullOrWhiteSpace(request.ContentType)
? request.ContentType
: MimeTypes.GetMimeTypeFromFile(cachePath).Name;
// Always derive the type from the stored file — never from a client-supplied value
// (issue #283 — the old ?contentType= reflection was the stored-XSS sink). Clamp the
// sniffed type to the image allow-list 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 served as a non-renderable download, never as HTML/script.
string sniffed = MimeTypes.GetMimeTypeFromFile(cachePath)?.Name;
mimeType = ImageContentTypes.IsAccepted(sniffed) ? sniffed : "application/octet-stream";
}
return new CachedImagePathViewModel(cachePath, mimeType);
@@ -22,7 +22,7 @@ public abstract class CallLibraryScannerHandler<TRequest>(
IRuntimeInfo runtimeInfo,
ILogger logger)
{
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/v1/scan/{scanId}";
protected async Task<Either<BaseError, string>> PerformScan(
ScanParameters parameters,
@@ -42,17 +42,28 @@ public class AddArtistToCollectionHandler :
private async Task<Unit> ApplyAddArtistRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Artist);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Artist.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Artist);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -44,17 +44,28 @@ public class AddEpisodeToCollectionHandler :
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Episode);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Episode.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Episode);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,10 +15,10 @@ public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextF
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => ApplyAddEpisodeRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddEpisodeRequest(dbContext, parameters, cancellationToken));
}
private static async Task<Unit> ApplyAddEpisodeRequest(TvContext dbContext, Parameters parameters)
private static async Task<Unit> ApplyAddEpisodeRequest(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
{
int index = parameters.Playlist.Items.Count > 0 ? parameters.Playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -36,7 +36,7 @@ public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextF
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync();
await dbContext.SaveChangesForcingVersion(cancellationToken);
return Unit.Default;
}
@@ -41,17 +41,28 @@ public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection,
private async Task<Unit> ApplyAddImageRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Image);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Image.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Image);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -73,24 +73,35 @@ public class AddItemsToCollectionHandler :
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
// No-op when every requested item is already a member: don't rotate the ETag or fan out
// rebuilds for an idempotent re-add — #269.
if (toAddIds.Count == 0)
{
return Unit.Default;
}
List<MediaItem> toAdd = await dbContext.MediaItems
.Filter(mi => toAddIds.Contains(mi.Id))
.ToListAsync(cancellationToken);
collection.MediaItems.AddRange(toAdd);
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), CancellationToken.None);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a).
collection.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
@@ -72,7 +72,7 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
playlist.Version++;
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesForcingVersion(cancellationToken);
return Unit.Default;
}
@@ -42,17 +42,28 @@ public class AddMediaItemToCollectionHandler :
private async Task<Unit> ApplyAddMediaItemRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.MediaItem);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.MediaItem.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.MediaItem);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -42,17 +42,28 @@ public class AddMovieToCollectionHandler :
private async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Movie);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Movie.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Movie);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,10 +15,10 @@ public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFac
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters, cancellationToken));
}
private static async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
private static async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
{
int index = parameters.Playlist.Items.Count > 0 ? parameters.Playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -36,7 +36,7 @@ public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFac
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync();
await dbContext.SaveChangesForcingVersion(cancellationToken);
return Unit.Default;
}
@@ -44,17 +44,28 @@ public class AddMusicVideoToCollectionHandler :
private async Task<Unit> ApplyAddMusicVideoRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.MusicVideo);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.MusicVideo.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.MusicVideo);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -44,17 +44,28 @@ public class AddOtherVideoToCollectionHandler :
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.OtherVideo.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -42,17 +42,28 @@ public class AddSeasonToCollectionHandler :
private async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Season);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Season.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Season);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,10 +15,10 @@ public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFa
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => ApplyAddSeasonRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddSeasonRequest(dbContext, parameters, cancellationToken));
}
private static async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
private static async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
{
int index = parameters.Playlist.Items.Count > 0 ? parameters.Playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -36,7 +36,7 @@ public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFa
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync();
await dbContext.SaveChangesForcingVersion(cancellationToken);
return Unit.Default;
}
@@ -42,17 +42,28 @@ public class AddShowToCollectionHandler :
private async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Show);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Show.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Show);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,10 +15,10 @@ public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFact
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters));
return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters, cancellationToken));
}
private static async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
private static async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
{
int index = parameters.Playlist.Items.Count > 0 ? parameters.Playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -36,7 +36,7 @@ public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFact
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync();
await dbContext.SaveChangesForcingVersion(cancellationToken);
return Unit.Default;
}
@@ -42,17 +42,28 @@ public class AddSongToCollectionHandler :
private async Task<Unit> ApplyAddSongRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.Song);
if (await dbContext.SaveChangesAsync() > 0)
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Song.Id))
{
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]));
return Unit.Default;
}
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
parameters.Collection.MediaItems.Add(parameters.Song);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -35,7 +35,7 @@ public class CreatePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory
TvContext dbContext,
CreatePlaylist request)
{
if (request.Name.Length > 50)
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 50)
{
return BaseError.New($"Playlist name \"{request.Name}\" is invalid");
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
@@ -34,7 +34,7 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken)
{
dbContext.Collections.Remove(collection);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesForcingVersion(cancellationToken);
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
@@ -37,7 +37,7 @@ public class DeleteMultiCollectionHandler : IRequestHandler<DeleteMultiCollectio
CancellationToken cancellationToken)
{
dbContext.MultiCollections.Remove(multiCollection);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesForcingVersion(cancellationToken);
_searchTargets.SearchTargetsChanged();
return Unit.Default;
}
@@ -24,7 +24,7 @@ public class DeletePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory
}
dbContext.Playlists.Remove(playlist);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesForcingVersion(cancellationToken);
}
return maybePlaylist.Match(
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -27,7 +27,7 @@ public class DeleteRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
CancellationToken cancellationToken)
{
dbContext.RerunCollections.Remove(rerunCollection);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesForcingVersion(cancellationToken);
return Unit.Default;
}
@@ -59,21 +59,30 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
return new NotFoundError("Collection item does not exist.");
}
// No-op when nothing is actually removed: don't rotate the ETag or fan out rebuilds — #269.
if (itemsToRemove.Count == 0)
{
return Unit.Default;
}
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(
new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()),
CancellationToken.None);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this remove takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a).
collection.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(
new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()),
CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
@@ -6,5 +6,5 @@ public record ReplacePlaylistItems(
int PlaylistId,
string Name,
List<ReplacePlaylistItem> Items,
Option<int> ExpectedVersion = default)
Option<Seq<int>> ExpectedVersions = default)
: IRequest<Either<BaseError, List<PlaylistItemViewModel>>>;
@@ -22,7 +22,7 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
// LanguageExtensions.ToEither joins the Seq<BaseError> to a single BaseError (the native
// Validation.ToEither() would keep Seq and is called explicitly here to avoid that shadow).
Either<BaseError, Playlist> validated = LanguageExtensions.ToEither(validation)
.Bind(playlist => playlist.CheckVersion(request.ExpectedVersion));
.Bind(playlist => playlist.CheckVersion(request.ExpectedVersions));
return await validated.Match(
Right: playlist => Persist(dbContext, request, playlist, cancellationToken),
@@ -1,10 +1,10 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record UpdateCollectionCustomOrder(
int CollectionId,
List<MediaItemCustomOrder> MediaItemCustomOrders,
Option<int> ExpectedVersion = default) : IRequest<Either<BaseError, Unit>>;
Option<Seq<int>> ExpectedVersions = default) : IRequest<Either<BaseError, Unit>>;
public record MediaItemCustomOrder(int MediaItemId, int CustomIndex);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -36,7 +36,7 @@ public class UpdateCollectionCustomOrderHandler : IRequestHandler<UpdateCollecti
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
Either<BaseError, Collection> validated = LanguageExtensions.ToEither(validation)
.Bind(c => c.CheckVersion(request.ExpectedVersion));
.Bind(c => c.CheckVersion(request.ExpectedVersions));
return await validated.Match(
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
@@ -59,17 +59,25 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
c.UseCustomPlaybackOrder = useCustomPlaybackOrder;
}
// Force-write past a concurrent Version bump from the custom-order editor (this name/flag writer
// doesn't participate in If-Match, so the active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0 && request.UseCustomPlaybackOrder.IsSome)
// Only rotate the ETag when the name/flag actually changed — a no-op re-submit must not bump the
// Version (spurious rebuilds / editor invalidation) — #269. When it did change, force-write past a
// concurrent bump from the custom-order editor (this writer takes no If-Match, so a benign race
// must not 500) — #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId))
c.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
if (request.UseCustomPlaybackOrder.IsSome)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
@@ -13,4 +13,4 @@ public record UpdateMultiCollection(
int MultiCollectionId,
string Name,
List<UpdateMultiCollectionItem> Items,
Option<int> ExpectedVersion = default) : IRequest<Either<BaseError, Unit>>;
Option<Seq<int>> ExpectedVersions = default) : IRequest<Either<BaseError, Unit>>;
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -40,7 +40,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
Either<BaseError, MultiCollection> validated = LanguageExtensions.ToEither(validation)
.Bind(c => c.CheckVersion(request.ExpectedVersion));
.Bind(c => c.CheckVersion(request.ExpectedVersions));
return await validated.Match(
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
@@ -1,4 +1,4 @@
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -14,5 +14,5 @@ public record UpdateRerunCollection(
NamedMediaItemViewModel MediaItem,
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder,
Option<int> ExpectedVersion = default)
Option<Seq<int>> ExpectedVersions = default)
: IRequest<Either<BaseError, Unit>>;
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -26,7 +26,7 @@ public class UpdateRerunCollectionHandler(
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
Either<BaseError, RerunCollection> validated = LanguageExtensions.ToEither(validation)
.Bind(c => c.CheckVersion(request.ExpectedVersion));
.Bind(c => c.CheckVersion(request.ExpectedVersions));
return await validated.Match(
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.MediaSources;
namespace ErsatzTV.Application.MediaSources;
public record GetCollectionsScanStatus : IRequest<List<CollectionsScanStatusResponseModel>>;
@@ -0,0 +1,38 @@
#nullable enable
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Interfaces.Locking;
namespace ErsatzTV.Application.MediaSources;
// Reports which media-source families currently hold their external-collections scan lock. The lock
// is the running scan (the scan-collections controllers acquire it before enqueueing and the scanner
// releases it on completion), so IEntityLocker is the authoritative source — analogous to how
// GetLibraryScanStatus reads IScannerProxyService.GetActiveScans(). Collections locks are family-global,
// so this returns at most one entry per family, and only for families actively scanning.
public class GetCollectionsScanStatusHandler(IEntityLocker entityLocker)
: IRequestHandler<GetCollectionsScanStatus, List<CollectionsScanStatusResponseModel>>
{
public Task<List<CollectionsScanStatusResponseModel>> Handle(
GetCollectionsScanStatus request,
CancellationToken cancellationToken)
{
var result = new List<CollectionsScanStatusResponseModel>();
if (entityLocker.ArePlexCollectionsLocked())
{
result.Add(new CollectionsScanStatusResponseModel("plex"));
}
if (entityLocker.AreJellyfinCollectionsLocked())
{
result.Add(new CollectionsScanStatusResponseModel("jellyfin"));
}
if (entityLocker.AreEmbyCollectionsLocked())
{
result.Add(new CollectionsScanStatusResponseModel("emby"));
}
return Task.FromResult(result);
}
}
@@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
@@ -39,7 +39,7 @@ public class DeletePlayoutHandler(
}
dbContext.Playouts.Remove(playout);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesForcingVersion(cancellationToken);
// post-commit side effects run on CancellationToken.None so a late request cancellation can't
// abort them after the delete committed (#254)
@@ -5,5 +5,5 @@ namespace ErsatzTV.Application.Playouts;
public record ReplacePlayoutAlternateScheduleItems(
int PlayoutId,
List<ReplacePlayoutAlternateSchedule> Items,
Option<int> ExpectedVersion = default)
Option<Seq<int>> ExpectedVersions = default)
: IRequest<Either<BaseError, Unit>>;
@@ -44,7 +44,7 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
// PreconditionFailedError (→ 412) before any mutation. Introduced as a standalone Either,
// and returned directly (a value, not a throw) so it escapes the catch(Exception) below
// rather than being reshaped into a bare 422 (§9/H1).
Either<BaseError, Playout> versionCheck = playout.CheckVersion(request.ExpectedVersion);
Either<BaseError, Playout> versionCheck = playout.CheckVersion(request.ExpectedVersions);
if (versionCheck.IsLeft)
{
return versionCheck.Match<Either<BaseError, Unit>>(
@@ -40,10 +40,14 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
// Rotate the playout ETag only when the schedule-file actually changed — a no-op re-submit must
// not bump (#269). When it changed, force-write past a concurrent Version bump from a replace-all
// editor (this writer takes no If-Match, so a benign race must not 500) — #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
playout.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
@@ -35,9 +35,15 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.DailyRebuildTime = dailyRebuildTime;
}
// Force-write past a concurrent Version bump from a replace-all editor (this settings writer
// doesn't participate in If-Match, so a missing token = force-write, not a 500) — #253/#269.
await dbContext.SaveChangesForcingVersion(cancellationToken);
// Rotate the playout ETag only when the daily-rebuild-time actually changed — a no-op re-submit
// must not bump (#269). When it changed, force-write past a concurrent Version bump from a
// replace-all editor (this settings writer takes no If-Match, so a benign race must not 500) —
// #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
playout.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
}
return new PlayoutNameViewModel(
playout.Id,
@@ -35,10 +35,14 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
// Rotate the playout ETag only when the schedule-file actually changed — a no-op re-submit must
// not bump (#269). When it changed, force-write past a concurrent Version bump from a replace-all
// editor (this writer takes no If-Match, so a benign race must not 500) — #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
playout.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
@@ -40,10 +40,14 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
// Rotate the playout ETag only when the schedule-file actually changed — a no-op re-submit must
// not bump (#269). When it changed, force-write past a concurrent Version bump from a replace-all
// editor (this writer takes no If-Match, so a benign race must not 500) — #253/#269 §7a.
if (dbContext.ChangeTracker.HasChanges())
{
playout.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutIdByChannelId(int ChannelId) : IRequest<Option<int>>;
@@ -0,0 +1,18 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class GetPlayoutIdByChannelIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPlayoutIdByChannelId, Option<int>>
{
public async Task<Option<int>> Handle(GetPlayoutIdByChannelId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Filter(p => p.Channel.Id == request.ChannelId)
.Map(p => p.Id)
.ToListAsync(cancellationToken)
.Map(list => list.HeadOrNone());
}
}
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -55,7 +55,7 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
programSchedule.Version++;
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesForcingVersion(cancellationToken);
// refresh any playouts that use this schedule
// post-commit side effect runs on CancellationToken.None so a late request cancellation
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
@@ -24,15 +24,18 @@ public class DeleteProgramScheduleHandler : IRequestHandler<DeleteProgramSchedul
request,
cancellationToken);
return await maybeProgramSchedule.Match(
Some: programSchedule => DoDeletion(dbContext, programSchedule).Map(Right<BaseError, Unit>),
Some: programSchedule => DoDeletion(dbContext, programSchedule, cancellationToken).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"ProgramSchedule {request.ProgramScheduleId} does not exist.")));
}
private static Task<Unit> DoDeletion(TvContext dbContext, ProgramSchedule programSchedule)
private static Task<Unit> DoDeletion(
TvContext dbContext,
ProgramSchedule programSchedule,
CancellationToken cancellationToken)
{
dbContext.ProgramSchedules.Remove(programSchedule);
return dbContext.SaveChangesAsync().ToUnit();
return dbContext.SaveChangesForcingVersion(cancellationToken).ToUnit();
}
private static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
@@ -38,7 +38,7 @@ public class DeleteProgramScheduleItemHandler(
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
item.ProgramSchedule.Version++;
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesForcingVersion(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
@@ -1,10 +1,11 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
public record ReplaceProgramScheduleItem(
int? Id,
int Index,
StartType StartType,
TimeSpan? StartTime,
@@ -47,5 +48,5 @@ public record ReplaceProgramScheduleItem(
public record ReplaceProgramScheduleItems(
int ProgramScheduleId,
List<ReplaceProgramScheduleItem> Items,
Option<int> ExpectedVersion = default) : IRequest<
Option<Seq<int>> ExpectedVersions = default) : IRequest<
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>;
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -31,7 +31,7 @@ public class ReplaceProgramScheduleItemsHandler(
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
Either<BaseError, ProgramSchedule> validated = LanguageExtensions.ToEither(validation)
.Bind(ps => ps.CheckVersion(request.ExpectedVersion));
.Bind(ps => ps.CheckVersion(request.ExpectedVersions));
return await validated.Match(
Right: ps => PersistItems(dbContext, request, ps, cancellationToken),
@@ -48,16 +48,22 @@ public class ReplaceProgramScheduleItemsHandler(
ProgramSchedule programSchedule,
CancellationToken cancellationToken)
{
// Positional in-place reconcile (rather than delete-and-reinsert): a schedule item owns the
// persisted fill-group/shuffle enumerator state via PlayoutScheduleItemFillGroupIndex, whose
// In-place reconcile (rather than delete-and-reinsert): a schedule item owns persisted
// fill-group/shuffle enumerator state via PlayoutScheduleItemFillGroupIndex, whose
// ProgramScheduleItemId FK is OnDelete(Cascade). Removing and re-inserting every item — as the
// original handler did on every save, including a no-op PUT-back — cascade-deleted that state for
// all playouts using the schedule (#252). Reusing the existing item row for the same-typed slot
// keeps its id, so the cascade never fires and progression survives. The request DTO carries no
// stable item id, so position is the only key available here; true content-aware stable identity
// is deferred to the shared concurrency/round-trip contract in #253.
// all playouts using the schedule (#252). Reusing the existing item row keeps its id, so the cascade
// never fires and progression survives.
//
// Two reconcile modes (#259):
// * id-based (preferred) — when the client round-trips each existing item's server id, map request
// items to existing rows BY id, so per-child state follows the LOGICAL item across reorders and
// mid-list inserts rather than staying with whatever previously occupied a slot.
// * positional (legacy fallback) — a fully id-less payload keeps the original by-array-position
// reconcile. This preserves today's misattribution on legacy reorders; it is temporary and
// retires with the §7a Phase-2 If-Match flip.
// Index (ordering) is always derived from array position; identity (id) is a separate axis.
var orderedItems = request.Items.OrderBy(i => i.Index).ToList();
List<ProgramScheduleItem> existingItems = programSchedule.Items.OrderBy(i => i.Index).ToList();
// load the watermark/graphics join rows for the existing items so they can be rebuilt in place
await dbContext.Entry(programSchedule)
@@ -67,39 +73,112 @@ public class ReplaceProgramScheduleItemsHandler(
.Include(i => i.ProgramScheduleItemGraphicsElements)
.LoadAsync(cancellationToken);
int commonCount = Math.Min(existingItems.Count, orderedItems.Count);
for (var i = 0; i < commonCount; i++)
List<ProgramScheduleItem> existingItems = programSchedule.Items.ToList();
List<int> requestIds = orderedItems.Where(i => i.Id.HasValue).Select(i => i.Id.Value).ToList();
if (requestIds.Count == 0)
{
ProgramScheduleItem existing = existingItems[i];
ProgramScheduleItem built = BuildItem(programSchedule, i, orderedItems[i]);
if (existing.GetType() == built.GetType())
// ---- positional fallback (verbatim pre-#259 behavior) ----
List<ProgramScheduleItem> orderedExisting = existingItems.OrderBy(i => i.Index).ToList();
int commonCount = Math.Min(orderedExisting.Count, orderedItems.Count);
for (var i = 0; i < commonCount; i++)
{
// same TPT subtype: copy all scalar values in place (BuildItem is the single source of
// item construction, so no field is silently dropped) and rebuild the join rows, keeping
// the item's id — and with it the fill-group index that would otherwise cascade away.
built.Id = existing.Id;
dbContext.Entry(existing).CurrentValues.SetValues(built);
RebuildChildren(existing, orderedItems[i]);
ProgramScheduleItem existing = orderedExisting[i];
ProgramScheduleItem built = BuildItem(programSchedule, i, orderedItems[i]);
if (existing.GetType() == built.GetType())
{
built.Id = existing.Id;
dbContext.Entry(existing).CurrentValues.SetValues(built);
RebuildChildren(existing, orderedItems[i]);
}
else
{
dbContext.Remove(existing);
programSchedule.Items.Add(built);
}
}
else
for (int i = commonCount; i < orderedExisting.Count; i++)
{
// EF can't change a TPT row's type in place; this slot must be replaced (its fill-group
// index resets, which is acceptable — the item fundamentally changed).
dbContext.Remove(existing);
programSchedule.Items.Add(built);
dbContext.Remove(orderedExisting[i]);
}
for (int i = commonCount; i < orderedItems.Count; i++)
{
programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i]));
}
}
// remove surplus existing items
for (int i = commonCount; i < existingItems.Count; i++)
else
{
dbContext.Remove(existingItems[i]);
}
// ---- id-based reconcile ----
// Guards run HERE (inside PersistItems, after CheckVersion in Handle) so a client that is BOTH
// version-stale and id-stale gets 412 (reload signal) — not 422, which reads as a payload bug
// (§7c). Both guards persist nothing (plain BaseError → 422).
// add surplus incoming items
for (int i = commonCount; i < orderedItems.Count; i++)
{
programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i]));
// 7b: a duplicated id would map two request items onto one row (SetValues twice, last-writer-wins,
// one item's config silently lost). Always a client bug.
int? duplicateId = requestIds.GroupBy(id => id).Where(g => g.Count() > 1).Select(g => (int?)g.Key)
.FirstOrDefault();
if (duplicateId.HasValue)
{
return new BaseError($"Schedule item id {duplicateId.Value} appears more than once in the request.");
}
// 7a: an id not belonging to THIS schedule is a stale/foreign identity. Under Phase-1 (a missing
// If-Match force-writes) it is a live lost-update signal; inserting-as-new would silently duplicate
// the item and return a different id than the client sent. Reject loudly rather than mask it.
var existingIds = existingItems.Select(e => e.Id).ToHashSet();
foreach (int id in requestIds)
{
if (!existingIds.Contains(id))
{
return new BaseError($"Schedule item id {id} does not belong to this schedule.");
}
}
// delete pass: existing rows the request no longer references (their fill-group state cascades —
// correct, the logical item is gone)
var referencedIds = requestIds.ToHashSet();
foreach (ProgramScheduleItem existing in existingItems)
{
if (!referencedIds.Contains(existing.Id))
{
dbContext.Remove(existing);
}
}
// match/insert pass in array order (Index = i)
var existingById = existingItems.ToDictionary(e => e.Id);
for (var i = 0; i < orderedItems.Count; i++)
{
ReplaceProgramScheduleItem requestItem = orderedItems[i];
ProgramScheduleItem built = BuildItem(programSchedule, i, requestItem);
if (requestItem.Id.HasValue && existingById.TryGetValue(requestItem.Id.Value, out ProgramScheduleItem existing))
{
if (existing.GetType() == built.GetType())
{
// same TPT subtype: copy all scalars in place (BuildItem is the single source of item
// construction, so no field is dropped) and rebuild join rows, keeping the id — and
// with it the fill-group index that would otherwise cascade away. State follows the
// logical item regardless of its new position.
built.Id = existing.Id;
dbContext.Entry(existing).CurrentValues.SetValues(built);
RebuildChildren(existing, requestItem);
}
else
{
// EF can't retype a TPT row in place; replace it. Fill-group state resets and the
// response returns a NEW id (the item fundamentally changed — clients re-sync from it).
dbContext.Remove(existing);
programSchedule.Items.Add(built);
}
}
else
{
// id-less request item → new (unknown ids were already rejected above)
programSchedule.Items.Add(built);
}
}
}
// Unconditional bump: this handler frequently saves with only CHILD changes and no root-scalar
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -27,7 +27,7 @@ public class UpdateProgramScheduleHandler(
Some: async programSchedule =>
{
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule, cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, UpdateProgramScheduleResult>>(
new NotFoundError("Schedule does not exist")));
@@ -36,7 +36,8 @@ public class UpdateProgramScheduleHandler(
private async Task<UpdateProgramScheduleResult> ApplyUpdateRequest(
TvContext dbContext,
ProgramSchedule programSchedule,
UpdateProgramSchedule request)
UpdateProgramSchedule request,
CancellationToken cancellationToken)
{
// we need to refresh playouts if the playback order or keep multi-episodes has been modified
bool needToRefreshPlayout =
@@ -54,21 +55,25 @@ public class UpdateProgramScheduleHandler(
programSchedule.RandomStartPoint = request.RandomStartPoint;
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253).
// Force-write past a concurrent Version bump (e.g. a parallel schedule-items replace) instead of
// surfacing the IsConcurrencyToken guard as an unhandled DbUpdateConcurrencyException → 500 (#269).
programSchedule.Version++;
await dbContext.SaveChangesAsync();
await dbContext.SaveChangesForcingVersion(cancellationToken);
if (needToRefreshPlayout)
{
// post-commit side effect: run the affected-playout query + enqueue on CancellationToken.None
// so a late request cancellation can't abort the rebuild after the edit already committed (#254)
List<int> playoutIds = await dbContext.Playouts
.Filter(p => p.ProgramScheduleId == programSchedule.Id)
.Map(p => p.Id)
.ToListAsync();
.ToListAsync(CancellationToken.None);
foreach (int playoutId in playoutIds)
{
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
}
@@ -53,7 +53,7 @@ public class CreateBlockHandler(IDbContextFactory<TvContext> dbContextFactory)
TvContext dbContext,
CreateBlock request)
{
if (request.Name.Length > 50)
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 50)
{
return BaseError.New($"Block name \"{request.Name}\" is invalid");
}

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