diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 5482d6124..7a43edf2a 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -752,6 +752,8 @@ jobs: PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD - name: Active catalog in sync run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check + - name: Kickoff guard + run: bash scripts/check-kickoff-guard.sh # 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 diff --git a/CLAUDE.md b/CLAUDE.md index 466fe26f1..93b08c643 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,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, 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-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read the `docs/README.md` **task-signal map** and only the sections it points to for your task — not the whole corpus. **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. **Decision/convention lookups start at the active catalog**, `docs/decisions/README.md` — resolve by topic/key, never by chasing a file path named in a historical comment (the breadcrumb rule; see `docs/README.md` → "Knowledge retrieval"). - **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: | Change | Update in the same PR | @@ -91,11 +91,24 @@ Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pa 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. -4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated. +4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below). 5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed. 6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up. 7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle. +**`## Closing record` template** (step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval contract this feeds): + +```markdown +## Closing record +**Outcome:** +**Root cause:** +**Decisions/conventions changed:** +**Reusable knowledge:** +**Verification:** +**Deferred:** +**Docs updated:** +``` + ## Project Boundaries **ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management. diff --git a/docs/README.md b/docs/README.md index 6eba8dcd4..c3b42a611 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,34 +1,81 @@ -# docs/ — reading order +# docs/ — task-signal map -Purpose: index of `docs/` so a fresh contributor/agent knows what to read and in what order. -**Update this doc in the same PR that adds, removes, or retitles a doc below.** +Purpose: route a fresh contributor/agent to the minimal set of docs for the task at hand, instead of +a mandatory front-to-back read. **Update this doc in the same PR that adds, removes, or retitles a +doc below, or that changes which sections a task signal points to.** -Read in this order at session start: +## Start here, always -1. **`CLAUDE.md`** (repo root) — project intro: architecture, layout, dev commands, conventions. -2. **`docs/contributing.md`** — established code patterns (CQRS/MediatR, LanguageExt, the ChicoryTV - SPA, EF Core dual-provider migrations, FFmpeg pipeline, analyzers, testing). Read before - any non-trivial change. -3. **`docs/domain-model.md`** — what the app IS: entity glossary, channel→playout→schedule/block - concept map, where each concept is edited in the SPA. -4. **`docs/api-conventions.md`** — checklist for adding/changing a `/api/*` endpoint (controllers, - DTOs, error mapping, auth, OpenAPI regen, tests). -5. **`docs/spa-conventions.md`** — playbook for adding a screen to the ChicoryTV React SPA. -6. **`docs/e2e-local.md`** (+ `scripts/e2e-local.sh`) — how to run a live local instance for manual - or Playwright-MCP verification. -7. **`docs/testing.md`** — testing map: what each `*.Tests` project / `web` suite covers, - golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification - gate. -8. **`docs/blazor-route-parity.md`** — historical record of the completed #91 phase (b) cutover: - the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent - (or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory. -9. **`docs/decisions.md`** — lifecycle "why" log: active records plus retired/superseded ones - relocated to `docs/decisions/archive/`. Check here before challenging an existing convention; - start from the generated active catalog, `docs/decisions/README.md`. -10. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management. +- **`CLAUDE.md`** (repo root) — project intro: architecture, layout, dev commands, conventions, + Task Completion Protocol. +- **`docs/contributing.md`** — established code patterns (CQRS/MediatR, LanguageExt, the ChicoryTV + SPA, EF Core dual-provider migrations, FFmpeg pipeline, analyzers, testing). Read before any + non-trivial change. -Also present in `docs/`: +## Task signal → minimal sections +| Signal | Read | +| --- | --- | +| Session startup / "what's next" (no issue named) | `docs/handoffs/chicorytv-issue-queue.md` (standing kickoff — two concurrent tracks: orientation ‖ `scripts/select-queue.sh 5`) | +| Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body | +| Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` | +| Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` | +| Scheduling / playout engine work | `docs/domain-model.md` + decisions catalog rows keyed `sched.*` (`docs/decisions/README.md`) | +| Concurrency / optimistic-locking work | `docs/api-conventions.md` §7a/b/c + `docs/decisions/optimistic-concurrency.md` | +| Auth / security-surface work | `docs/decisions/api-auth-security.md` | +| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` | +| Live local run / Playwright-MCP verification | `docs/e2e-local.md` + `scripts/e2e-local.sh` | +| What does a test suite cover | `docs/testing.md` | +| Legacy Blazor route lookup | `docs/blazor-route-parity.md` (historical #91 phase (b) inventory) | +| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → `docs/decisions.md` + `docs/decisions/*.md` for full rationale. `docs/decisions/archive/` only for "what did the rule used to be." | + +## Knowledge retrieval (MemPalace + catalog + Gitea) + +These four rules are the seam agreed with server-management#642 (the Gitea→MemPalace exporter). +They apply whether the question comes up via MemPalace, a grep, or a stale comment: + +1. **Current conventions/decisions → catalog-first.** Start at `docs/decisions/README.md`; discover + via the `ErsatzTV-Decisions` wing (active) / `ErsatzTV-Decisions-Archive` (superseded/retired). + **Resolve by topic/key, never by chasing a file path.** +2. **Issue history → evidence, not authority.** The `Gitea-ErsatzTV` wing is historical narrative + that may be stale; it never overrides current Markdown. +3. **The breadcrumb rule (the crux behavior change).** A file path named inside a *historical issue + comment* (e.g. "grep `docs/decisions.md` 2026-07-17", "see …") is a **breadcrumb, not a live + pointer.** Find the current rule via the catalog / active wing **by concept**; do not treat the + named path as current. (Why it's safe: still-current → in the active wing, breadcrumb resolves; + superseded → the active wing returns the *successor* and a literal follow lands on a record that + announces its own `status: superseded`; retired → the active wing returns nothing, which is + itself the signal. The validator-enforced move-to-`archive/` is what prevents the catastrophic + "superseded rule read as current" case.) +4. **Fallback when MemPalace is stale/down:** `docs/decisions/README.md` catalog, then + `` rg '^`key: `' docs/decisions/ ``. MemPalace is never authority nor sole fallback. + +MemPalace is candidate discovery only — every passage is verified against its cited Markdown/Gitea +source before use. Never derive live queue state from MemPalace, #237, or historical comments; queue +state is live Gitea state, retrieved via `scripts/select-queue.sh` (see +`docs/handoffs/chicorytv-issue-queue.md`). Full retrieval contract (altitude/precedence, staleness +bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval". + +## Also present in `docs/` + +- **`docs/domain-model.md`** — what the app IS: entity glossary, channel→playout→schedule/block + concept map, where each concept is edited in the SPA. +- **`docs/api-conventions.md`** — checklist for adding/changing a `/api/*` endpoint (controllers, + DTOs, error mapping, auth, OpenAPI regen, tests). +- **`docs/spa-conventions.md`** — playbook for adding a screen to the ChicoryTV React SPA. +- **`docs/e2e-local.md`** (+ `scripts/e2e-local.sh`) — how to run a live local instance for manual + or Playwright-MCP verification. +- **`docs/testing.md`** — testing map: what each `*.Tests` project / `web` suite covers, + golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification + gate. +- **`docs/blazor-route-parity.md`** — historical record of the completed #91 phase (b) cutover: + the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent + (or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory. +- **`docs/decisions.md`** + **`docs/decisions/*.md`** — active decision records (lifecycle schema: + key/status/since/supersedes/superseded-by). **Generated active view**: `docs/decisions/README.md` + (catalog / task router) — start there. Superseded/retired records live in + `docs/decisions/archive/` and are read only for history, never for "what is the current rule." +- **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management. - **`docs/rest-api.md`** — REST API design doc for ersatztv#2 (goals, conventions, per-slice plan). Largely superseded day-to-day by `docs/api-conventions.md`; read this for the original rationale. - **`docs/mcp.md`** — the `ErsatzTV.Mcp` stdio JSON-RPC MCP server (#58): how it wraps `/api/v1` as @@ -41,6 +88,8 @@ Also present in `docs/`: OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` / `scripts/update-openapi.sh`. - **`docs/handoffs/chicorytv-issue-queue.md`** — static session kickoff prompt + workflow lore. - Queue state (goal, arc, what's in flight/next) lives in the pinned Gitea tracker ersatztv#237 - — read that, not this file, for current state (protocol: decisions.md 2026-07-11). + Queue state is **live Gitea state**, retrieved each session via `scripts/select-queue.sh` — see + that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237 + is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in + `docs/decisions.md`) — not a live pointer. - **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2). diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 63c2aa381..c952d912d 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -50,11 +50,13 @@ step is now continuous. The release boundary is instead where you: `docs/decisions/archive/` (the validator fails this, but eyeball it at the boundary too). 3. Regenerate the active catalog: `PYTHONPATH=. python3 scripts/build_decisions_catalog.py` and commit any drift. -4. Check the aggregate active-corpus budget (`decisions_validate.py --budget `, default 4200 +4. Check the aggregate active-corpus budget (`decisions_validate.py --budget `, default **4800** lines across `docs/decisions.md` + topic files + the catalog — replaces the old single-file - 1800-line floor). Going over budget is a **non-blocking warning** (`::warning::` to stderr, not a - validator error) — a ratchet/reminder to migrate more `legacy-unmigrated` records or extract a new - topic file, not a release gate. + 1800-line floor). **Re-baselined 2026-07-21 (#520)**: the corpus is now fully migrated at + ~4366 lines; 4800 gives headroom so the warning fires on real future growth, not on the expected + post-migration size. Going over budget is a **non-blocking warning** (`::warning::` to stderr, + not a validator error) — a ratchet/reminder to extract a new topic file or archive more history, + not a release gate. 5. Report the remaining `legacy-unmigrated` count (the validator prints it as a `::notice::`) so the backlog is visible, even though it isn't required to hit zero before a release. A genuine rationale-prose rewrite still needs `[decisions-edit]` in the commit message (see the diff --git a/docs/decisions.md b/docs/decisions.md index 41f85c7da..ec578d38c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -91,7 +91,6 @@ in-file entries. - [2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)](#2026-07-10--playout-api-mutations-return-409-while-the-build-lock-is-held-215) - [2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved](#2026-07-11--schedules-spa-editor-draftexplicit-save-over-instant-persist-copy-includes-multismartrerun-shuffled-get-normalization-preserved) - [2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212)](#2026-07-11--channel-editor-bare-create-entry-point--external-logo-mutual-exclusion-212) -- [2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file](#2026-07-11--queue-state-lives-in-the-pinned-gitea-tracker-237-not-in-the-handoff-file) - [2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)](#2026-07-11--entitylocker-atomic-flags--single-owner-release-discipline-no-owner-tokens-231) - [2026-07-11 — Media-source management REST write API + SPA (#202)](#2026-07-11--media-source-management-rest-write-api--spa-202) - [2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204)](#2026-07-11--legacyspa-redirect-matcher-exact-map--ordered-segment-template-patterns-204) @@ -130,10 +129,15 @@ in-file entries. - [2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)](#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502) - [2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)](#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511) - [2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)](#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521) +- [2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520)](#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520) --- ## 2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer +`key: api.mediatr-passthrough` · `status: active` · `since: 2026-06` · `supersedes: none` · `superseded-by: none` +**Rule:** The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. +**Signals:** CQRS passthrough, `Either` mapping, handler-level fixes not controller papering · paths: `docs/rest-api.md`, `docs/api-conventions.md` §3 · issues: #2, #172 +**Mechanics:** `docs/api-conventions.md` §3 The REST API (#2, `docs/rest-api.md`) is thin controllers over the existing MediatR Create/Update/Delete handlers — no new service/business-logic layer was introduced, since nearly @@ -146,6 +150,10 @@ that depth, to avoid N+1 validation queries — precedent set by the schedules e `docs/api-conventions.md` §3 for the up-to-date statement of this rule. ## 2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin +`key: spa.spa-rebuild-decision` · `status: active` · `since: 2026-06` · `supersedes: none` · `superseded-by: none` +**Rule:** The UI is a full React SPA (ChicoryTV) rebuild over the REST API, not a Blazor Server reskin. +**Signals:** Blazor removal phases (a)/(b), root-flip, legacy-route redirects · paths: `ErsatzTV/LegacyUiRedirects.cs`, `docs/blazor-route-parity.md` · issues: #59, #91, #148 +**Mechanics:** `docs/blazor-route-parity.md` #59 committed to a full SPA rebuild rather than reskinning Blazor Server pages. Blazor removal is split into two phases under #91: **(a)** root-flip (SPA becomes `/`) + legacy-route redirects — @@ -154,6 +162,10 @@ Blazor removal — gated on every route having an SPA equivalent; tracked route- `docs/blazor-route-parity.md`. ## 2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable` +`key: api.response-dtos` · `status: active` · `since: 2026-07` · `supersedes: none` · `superseded-by: none` +**Rule:** New REST response DTOs live in `ErsatzTV.Core/Api//*ResponseModel.cs` with a file-scoped `#nullable enable` pragma; controllers never expose Application VM types directly. +**Signals:** nullable-disabled Core project, ResponseModel mirroring ViewModel shape · paths: `ErsatzTV.Core/Api`, `docs/api-conventions.md` §2 · issues: none cited +**Mechanics:** `docs/api-conventions.md` §2 New REST response DTOs go in `ErsatzTV.Core/Api//*ResponseModel.cs` and mirror the shape of the corresponding Application-layer ViewModel — controllers never expose VM types directly. Because @@ -163,6 +175,10 @@ with an optional member needs its own `#nullable enable` pragma at the top (most there; that's a Core/Api-layer-only convention. Full detail: `docs/api-conventions.md` §2. ## 2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default +`key: api.put-replace-index-order` · `status: active` · `since: 2026-07` · `supersedes: none` · `superseded-by: none` +**Rule:** PUT-replace-the-whole-list endpoints derive each item's `Index` from its request-array position, never a client-supplied field; alternate-schedule/playout-template rows are evaluated in `Index` order with the least-conditional row placed last as the catch-all default. +**Signals:** `ReplaceScheduleItemsRequest.ToCommand`, `IAlternateScheduleItem`, first-match-wins · paths: `AlternateScheduleSelector.cs` · issues: #179 +**Mechanics:** PR #179, `AlternateScheduleSelector.cs` For "replace the whole list" endpoints (PUT over a collection — schedule items, template items, etc.), the item's `Index` is derived from its position in the request array, not from a @@ -174,6 +190,10 @@ so it acts as the catch-all default. Established by the alternate-schedules work `AlternateScheduleSelector.cs`). ## 2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar +`key: spa.templates-editor-table` · `status: active` · `since: 2026-07` · `supersedes: none` · `superseded-by: none` +**Rule:** The SPA templates editor renders day/block assignment as a table, not Blazor's drag-and-drop calendar grid — an accepted, deliberate parity deviation. +**Signals:** `TemplateEditor.razor` vs table UI · paths: `/app/templates/{id}` · issues: #173 +**Mechanics:** PR #173 The legacy Blazor `TemplateEditor.razor` used a drag-and-drop day-grid calendar UI. The SPA equivalent (`/app/templates/{id}`, PR #173) renders the same day/block assignment as a table @@ -181,6 +201,10 @@ instead. This is an accepted, deliberate parity deviation — don't "fix" it to interaction model without discussing it first. ## 2026-07-07 — API artwork contract: rooted URLs produced server-side +`key: api.artwork-rooted-urls` · `status: active` · `since: 2026-07-07` · `supersedes: none` · `superseded-by: none` +**Rule:** API response DTOs return artwork as rooted, directly-usable URLs (plus passthrough for absolute/proxy URLs), never relative Blazor-convention paths. +**Signals:** no SPA ``, `ApiArtwork` helper · paths: `ErsatzTV.Core/Api/ApiArtwork.cs`, `ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs` · issues: none cited (PR #181, PR #183) +**Mechanics:** `ErsatzTV.Core/Api/ApiArtwork.cs` API response DTOs return artwork as rooted, directly-usable URLs (`/artwork/posters/...`, `/artwork/thumbnails/...`, `/artwork/fanart/...`), plus passthrough for absolute `http(s)://` URLs @@ -195,6 +219,10 @@ DTOs — those still return old Blazor-convention relative paths; map from the d root the path via `ApiArtwork`. ## 2026-07-07 — Decode-style endpoints take a row id and look up server-side +`key: api.decode-by-id` · `status: active` · `since: 2026-07-07` · `supersedes: none` · `superseded-by: none` +**Rule:** Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. +**Signals:** `PlayoutHistoryDetailsResponseModel` · paths: `PlayoutController.GetHistoryDetails` · issues: none cited (PR #182) +**Mechanics:** `GET /api/playouts/history/{id}`, `PlayoutController.GetHistoryDetails` Endpoints that decode/expand opaque stored state accept a database row id and resolve server-side, rather than accepting client-supplied serialized state to decode. Established by @@ -203,6 +231,10 @@ JSON (`Key`/`Details`) is decoded server-side into `PlayoutHistoryDetailsRespons never round-trips the raw payload itself. ## 2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints +`key: api.parentid-drillin` · `status: active` · `since: 2026-07-07` · `supersedes: none` · `superseded-by: none` +**Rule:** Media drill-in (season/episode/artist/music-video) is served by an optional `parentId` query param on library-browse, not dedicated per-kind child-listing endpoints. +**Signals:** library-picker season drill-in, media-detail browsing · paths: library-browse endpoint · issues: none cited (PRs #181/#183) +**Mechanics:** library-browse endpoint `parentId` param Rather than adding dedicated child-listing endpoints per media kind (e.g. "list episodes of a season"), the library-browse endpoint takes an optional `parentId` query param and the SPA drills @@ -211,6 +243,10 @@ media-detail's season/episode/artist/music-video browsing). Avoids a combinatori per-kind child endpoints. ## 2026-07-07 — Convention docs read at session start, updated in-PR +`key: docs.convention-docs-session-start` · `status: active` · `since: 2026-07-07` · `supersedes: none` · `superseded-by: none` +**Rule:** Docs-first, not source-first: conventions (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) are read from docs, not reverse-engineered from code, via `docs/README.md`'s task-signal map — only the sections it points to for the task at hand, not the whole set. Each doc is updated in the same PR that changes what it documents, replacing deferred/follow-up doc updates. +**Signals:** `docs/README.md` index, `ApiControllerSecurityTests` drift · paths: `docs/README.md`, `docs/api-conventions.md` §6 · issues: #184, #185. Session-start *reading order* is now the task-signal map — see `startup.parallel-orientation`. +**Mechanics:** `docs/README.md` `docs/api-conventions.md`, `docs/spa-conventions.md`, `docs/e2e-local.md`, `docs/blazor-route-parity.md`, `docs/domain-model.md`, `docs/decisions.md`, and `docs/README.md` @@ -226,6 +262,10 @@ follow-up under #184 rather than fixed inline, since it's a pre-existing gap, no doc-drafting pass caused. ## 2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel +`key: spa.playback-troubleshoot-poll` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none` +**Rule:** The playback-troubleshooting screen reports FFmpeg completion by polling `GET /api/troubleshoot/playback/status` (~2s) rather than a server push channel. +**Signals:** `PlaybackTroubleshootingScreen.tsx`, `ICourier`/`ISnackbar` parity · paths: `web/src/screens/PlaybackTroubleshootingScreen.tsx` · issues: #145 +**Mechanics:** `GET /api/troubleshoot/playback/status` The SPA playback-troubleshooting screen (`PlaybackTroubleshootingScreen.tsx`, #145) reports FFmpeg completion by **polling `GET /api/troubleshoot/playback/status` every ~2s** while a session is @@ -240,6 +280,10 @@ real-time transport. Speed thresholds and the "(Speed: Nx)" badge colors are cop Blazor `GetSpeedClass` (red <0.9, green >1.1, amber otherwise). ## 2026-07-09 — datetime-local instead of Chronic natural-language start parsing +`key: spa.datetime-local-input` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none` +**Rule:** The channel-mode date/time input uses a native `` instead of free-text Chronic natural-language parsing. +**Signals:** `playback.m3u8` `start` param, ISO-8601 round-trip · paths: none beyond the screen itself · issues: none cited +**Mechanics:** playback-troubleshooting screen, `playback.m3u8` `start` param The channel-mode "Date and Time" input in the SPA playback-troubleshooting screen uses a native ``, a **deliberate deviation** from the Blazor page, which parsed a @@ -249,6 +293,10 @@ Chronic dependency and a picker is unambiguous; the selected local datetime is s controller binds to `DateTimeOffset?` exactly as the Blazor round-trip (`"o"`) format did. ## 2026-07-09 — SPA gates Download Media Sample while a session is active +`key: spa.download-sample-gate` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none` +**Rule:** The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results). +**Signals:** I/O contention with the live transcode · paths: none · issues: none cited +**Mechanics:** playback-troubleshooting screen Minor intentional deviation: the SPA playback-troubleshooting screen disables **Download Media Sample** (alongside Download Results) while a troubleshooting session is starting/running; Blazor @@ -257,6 +305,10 @@ archiver reads the same media file, so gating both during a session is strictly nothing (sessions are short). ## 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198) +`key: api.openapi-mirrors-runtime` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none` +**Rule:** The generated OpenAPI spec is made to match the runtime Newtonsoft wire contract (via `NewtonsoftSchemaNamingTransformer`), not the reverse. +**Signals:** `CustomContractResolver`, `CustomNamingStrategy`, System.Text.Json drift · paths: `OpenApiSerializerContractTests` · issues: #198 +**Mechanics:** `NewtonsoftSchemaNamingTransformer`, `OpenApiSerializerContractTests` The generated OpenAPI document is made to follow the **runtime** JSON contract, not the reverse. Runtime `/api/*` responses are serialized by Newtonsoft via `CustomContractResolver`/`CustomNamingStrategy` @@ -273,6 +325,10 @@ real contract resolver.** This also fixed a latent SPA bug (the channel-list "FF `fFmpegProfile` and always showed "Unassigned"). Issue #198. ## 2026-07-09 — YAML playout validator: paste-textarea instead of a server file path +`key: spa.yaml-validator-textarea` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none` +**Rule:** The YAML playout validator takes pasted YAML via a `