3467 lines
326 KiB
Markdown
3467 lines
326 KiB
Markdown
# Decisions — lifecycle log
|
||
|
||
Purpose: why the codebase does what it does, so agents don't "fix" an established convention or
|
||
relitigate a settled call. **Update this doc (or a topic file under `docs/decisions/`) in the same
|
||
PR that changes any fact below** (or that establishes a new convention worth recording).
|
||
|
||
**Lifecycle, not append-only (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic).**
|
||
A migrated record is an H2 whose first non-blank content line is a metadata block:
|
||
|
||
> YYYY-MM-DD — Title … (#issue)
|
||
> `` `key: area.topic` · `status: active` · `since: YYYY-MM-DD` · `supersedes: none` · `superseded-by: none` ``
|
||
> **Rule:** one-line current rule.
|
||
> **Signals:** concept · paths: a/b.yml · issues: #issue
|
||
> **Mechanics:** docs/some-doc.md → section
|
||
> \<rationale prose …\>
|
||
|
||
(Blockquoted here so the illustrative `key`/heading don't parse as a real record; the `## 2026-07-17
|
||
— No persistent compiler servers …` entry below is a live example.) Five metadata fields: `key`
|
||
(dotted, e.g. `ci.runner-placement`), `status`, `since`, `supersedes`, `superseded-by`. The
|
||
**`Signals:`** line is also required (validator-enforced, ersatztv#545): it is the keywords/paths
|
||
MemPalace's recall matches on, so a record without it ingests with weak recall metadata and
|
||
under-surfaces — pack it with synonyms, symbol names, `paths:`, and issue refs. `status` is
|
||
one of:
|
||
- **`active`** — the current, authoritative record for its `key`. Exactly one active record per key.
|
||
- **`superseded`** — reversed by a newer record; relocated to `docs/decisions/archive/` with
|
||
`superseded-by: <new key>` pointing forward, and the successor's `supersedes` pointing back.
|
||
- **`retired`** — no longer applicable (not reversed, just obsolete); also lives in the archive.
|
||
|
||
(An H2 with no metadata block is `legacy-unmigrated` — not yet ported to this schema. The validator
|
||
tolerates these and reports a count; they trend to zero over time, not required to hit zero at once.)
|
||
|
||
**Supersession is same-PR, not a later consolidation pass**: add the new active record, then relocate
|
||
the predecessor's file (or section) into `docs/decisions/archive/` with the metadata above rewritten
|
||
to `status: superseded` (or `retired`) and the reciprocal `supersedes`/`superseded-by` links filled
|
||
in on both records. Never silently rewrite a record's rationale prose in place.
|
||
|
||
**Generated active catalog**: `docs/decisions/README.md` is generated from every `active` record
|
||
(this file + the topic files, excluding the archive) via `scripts/build_decisions_catalog.py` — run
|
||
it after any status change; CI's `decisions lifecycle` job fails on drift (`--check`).
|
||
|
||
**Enforcement**: `scripts/decisions_validate.py` checks metadata well-formedness (including a required
|
||
`Signals:` line), one-active-record-per-key, reciprocal links, that no record vanishes from the active
|
||
set without an archive copy, that the active catalog is in sync, and an aggregate active-corpus line
|
||
budget (replaces the old 1800-line
|
||
floor on this single file). The Husky `pre-commit` hook runs the structural checks over the working
|
||
tree; the CI `decisions lifecycle` job additionally runs the body-diff check with `--base`/`--head`.
|
||
|
||
**`[decisions-edit]`** is now narrow: it is required ONLY when a commit changes the **rationale
|
||
prose** of a surviving or archived record — a factual correction to already-written history. Routine
|
||
lifecycle writes (adding a new active record, relocating a superseded/retired record to the archive,
|
||
updating metadata fields, regenerating the catalog) are token-free; the validator proves they're
|
||
legitimate structurally instead of gating on the token.
|
||
|
||
---
|
||
|
||
## Index
|
||
|
||
Decisions are split between the chronological log **in this file** and four **topic files** under
|
||
`docs/decisions/` — large same-topic clusters extracted at the v26.9.0 consolidation (full
|
||
rationale preserved). Check the relevant topic file below for its subject; otherwise scan the
|
||
in-file entries.
|
||
|
||
**Topic files:**
|
||
|
||
- [`decisions/optimistic-concurrency.md`](decisions/optimistic-concurrency.md) — ETag / If-Match / `Version` optimistic concurrency (#253, #259, #265, #269). Mechanics: `api-conventions.md` §7a–§7c.
|
||
- [`decisions/api-auth-security.md`](decisions/api-auth-security.md) — API/SPA auth & security posture (#197 bundles + #279 headers, #206, #283, #292, #295, #301, #319, #330).
|
||
- [`decisions/release-ci-governance.md`](decisions/release-ci-governance.md) — release / CI / merge governance (#303 H3/H4/H5/H6/H9/H10, #311, #314, #315, #335).
|
||
- [`decisions/spa-modularization.md`](decisions/spa-modularization.md) — App.tsx screen/shell extraction epic #243 (#244, #245, #247).
|
||
- [`decisions/workflow-process.md`](decisions/workflow-process.md) — session workflow, CI-run triage, review routing, worktree/parallel-session hygiene and tooling gotchas (#542). Extracted from the kickoff handoff doc, which was their only copy.
|
||
|
||
**In this file:**
|
||
|
||
- [2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer](#2026-06--rest-api-wraps-existing-mediatr-handlers-11-no-service-layer)
|
||
- [2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin](#2026-06--ui-rebuild-is-a-react-spa-chicorytv-on-the-rest-api-not-a-blazor-reskin)
|
||
- [2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable`](#2026-07--response-dtos-live-in-ersatztvcoreapi-file-scoped-nullable-enable)
|
||
- [2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default](#2026-07--put-replace-list-endpoints-derive-index-from-array-order-alternate-schedules-last-row--catch-all-default)
|
||
- [2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar](#2026-07--templates-editor-in-the-spa-is-a-table-not-blazors-drag-calendar)
|
||
- [2026-07-07 — API artwork contract: rooted URLs produced server-side](#2026-07-07--api-artwork-contract-rooted-urls-produced-server-side)
|
||
- [2026-07-07 — Decode-style endpoints take a row id and look up server-side](#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side)
|
||
- [2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints](#2026-07-07--seasonepisodemusic-video-drill-in-via-parentid-not-new-child-listing-endpoints)
|
||
- [2026-07-07 — Convention docs read at session start, updated in-PR](#2026-07-07--convention-docs-read-at-session-start-updated-in-pr)
|
||
- [2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel](#2026-07-09--playback-troubleshooting-completion-feedback-poll-status-no-push-channel)
|
||
- [2026-07-09 — datetime-local instead of Chronic natural-language start parsing](#2026-07-09--datetime-local-instead-of-chronic-natural-language-start-parsing)
|
||
- [2026-07-09 — SPA gates Download Media Sample while a session is active](#2026-07-09--spa-gates-download-media-sample-while-a-session-is-active)
|
||
- [2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198)](#2026-07-09--openapi-spec-mirrors-the-runtime-newtonsoft-serializer-198)
|
||
- [2026-07-09 — YAML playout validator: paste-textarea instead of a server file path](#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path)
|
||
- [2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder](#2026-07-09--channel-numbers-prompt-driven-sequential-renumber-instead-of-drag-to-reorder)
|
||
- [2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor](#2026-07-09--table-not-calendar-convention-also-covers-the-deco-templates-editor)
|
||
- [2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)](#2026-07-11--trash-see-all-reuses-library-browse-paging-search-stays-capped-per-kind-213)
|
||
- [2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement](#2026-07-11--logs-page-size-is-a-client-local-preference-not-a-server-configelement)
|
||
- [2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`](#2026-07-11--logs-column-sorting-allow-listed-sortfieldsortdirection-on-get-apilogs)
|
||
- [2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode](#2026-07-09--per-playout-schedule-reset-button-dropped-reset-uses-the-server-default-build-mode)
|
||
- [2026-07-09 — Collection custom order: move up/down buttons, any-kind collections](#2026-07-09--collection-custom-order-move-updown-buttons-any-kind-collections)
|
||
- [2026-07-10 — Shared "Add to…" layer lives in `web/src/media/addTo/`; select-mode is an explicit toggle](#2026-07-10--shared-add-to-layer-lives-in-websrcmediaaddto-select-mode-is-an-explicit-toggle)
|
||
- [2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)](#2026-07-10--schedule-item-get-returns-a-flat-non-polymorphic-dto-scheduleitemresponsemodel)
|
||
- [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 — 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)
|
||
- [2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205)](#2026-07-11--pre-removal-blazor-rollback-tag-blazor-final-205)
|
||
- [2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235)](#2026-07-11--async-op-api-contract-normalization--playout-build-observability--f9-scan-endpoints-235)
|
||
- [2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)](#2026-07-11--post-commit-side-effects-run-on-cancellationtokennone-generalized-from-251-to-254)
|
||
- [2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired](#2026-07-12--external-collections-scans-get-an-authoritative-status-surface-271-the-spa-timeout-is-retired)
|
||
- [2026-07-11 — Blazor Server UI removed (#91 phase b)](#2026-07-11--blazor-server-ui-removed-91-phase-b)
|
||
- [2026-07-12 — Live-E2E is a required step for API write-path handler changes (#303)](#2026-07-12--live-e2e-is-a-required-step-for-api-write-path-handler-changes-303)
|
||
- [2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)](#2026-07-12--topbar-primary-action-button-wire-creates-drop-the-rest-238)
|
||
- [2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)](#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286)
|
||
- [2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)](#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172)
|
||
- [2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)](#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299)
|
||
- [2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)](#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340)
|
||
- [2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)](#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69)
|
||
- [2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)](#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71)
|
||
- [2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred](#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred)
|
||
- [2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380)](#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380)
|
||
- [2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented](#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented)
|
||
- [2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)](#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384)
|
||
- [2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)](#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406)
|
||
- [2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)](#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72)
|
||
- [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70)
|
||
- [2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)](#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385)
|
||
- [2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)](#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164)
|
||
- [2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)](#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386)
|
||
- [2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)](#2026-07-18--smartcollection-rule-builder-compile-only-closed-subset-no-stored-ast-one-level-nesting-176)
|
||
- [2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)](#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425)
|
||
- [2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)](#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293)
|
||
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
|
||
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
|
||
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
|
||
- [2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)](#2026-07-19--weightedshuffle-spa-weights-edited-on-the-multi-collection-order-offered-only-on-classic-multicollection-schedule-items-fair-share-is-a-reset-not-a-mode-404)
|
||
- [2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)](#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431)
|
||
- [2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)](#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412)
|
||
- [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-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)](#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525)
|
||
- [2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)](#2026-07-21--check-the-worked-issue-before-the-decision-corpus-a-closed-trackers-comments-need-no-retrofit-524)
|
||
- [2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)](#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536)
|
||
- [2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)](#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541)
|
||
|
||
---
|
||
|
||
## 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<BaseError, T>` 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
|
||
every handler already returns `Either<BaseError, T>`, which maps cleanly to HTTP status codes.
|
||
Latent handler bugs (missing existence checks, `KeyNotFoundException` risk, etc.) are fixed **at
|
||
the handler**, converting what would have 500'd into a proper 404/422 — not papered over in the
|
||
controller. Established across the #2a–#2e gap-issue PRs. Deep FK ids nested inside item-list
|
||
request bodies (e.g. a schedule item's `CollectionId`) are deliberately **not** existence-checked at
|
||
that depth, to avoid N+1 validation queries — precedent set by the schedules endpoints (#172); see
|
||
`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 —
|
||
DONE, merged via PR #148 (`ErsatzTV/LegacyUiRedirects.cs`, `feat/91-cutover` → main). **(b)** full
|
||
Blazor removal — gated on every route having an SPA equivalent; tracked route-by-route in
|
||
`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/<Domain>/*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/<Domain>/*ResponseModel.cs` and mirror the shape of
|
||
the corresponding Application-layer ViewModel — controllers never expose VM types directly. Because
|
||
`ErsatzTV.Core.csproj` sets `<Nullable>disable</Nullable>` project-wide, any response-model file
|
||
with an optional member needs its own `#nullable enable` pragma at the top (most already have one).
|
||
`ErsatzTV.Application` has no nullable context at all — do not add `?` annotations to types living
|
||
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
|
||
client-supplied index/order field — established by `ReplaceScheduleItemsRequest.ToCommand`
|
||
(`Items.Select((item, index) => item.ToReplaceCommand(index))`). Separately, `ProgramScheduleAlternate`
|
||
and `PlayoutTemplate` rows (both `IAlternateScheduleItem`) are evaluated in `Index` order,
|
||
first-match-wins; the convention is to place the least-conditional (or unconditional) row **last**
|
||
so it acts as the catch-all default. Established by the alternate-schedules work (PR #179,
|
||
`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
|
||
instead. This is an accepted, deliberate parity deviation — don't "fix" it to match Blazor's
|
||
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 `<base href>`, `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
|
||
and Jellyfin/Emby proxy variants. Established by PR #181
|
||
(`ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs`, private `Artwork(...)`
|
||
helper — comment: *"Returns a rooted, directly-usable artwork URL for the SPA's `<img src>`... the
|
||
SPA [needs it pre-rooted]"*), then generalized into the reusable `ApiArtwork` helper
|
||
(`ErsatzTV.Core/Api/ApiArtwork.cs`, PR #183). Root cause: the SPA has no `<base href>`, unlike
|
||
Blazor, so relative artwork paths that worked for Blazor pages 404 in the SPA. Do **not** reuse the
|
||
Application-layer Mappers used by Blazor (e.g. `MediaCards`/`Television` mappers) for new API
|
||
DTOs — those still return old Blazor-convention relative paths; map from the domain/VM directly and
|
||
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
|
||
`GET /api/playouts/history/{id}` (`PlayoutController.GetHistoryDetails`, PR #182) — the row's raw
|
||
JSON (`Key`/`Details`) is decoded server-side into `PlayoutHistoryDetailsResponseModel`, the client
|
||
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
|
||
in by re-querying with it. Established across PRs #181/#183 (library-picker season drill-in, then
|
||
media-detail's season/episode/artist/music-video browsing). Avoids a combinatorial explosion of
|
||
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`
|
||
are the standing reference set every ChicoryTV session should read before starting work, and each
|
||
one carries an explicit "update this doc in the same PR" rule rather than deferring doc updates to
|
||
a follow-up. These docs **replace per-session recon** — an agent reads the index
|
||
(`docs/README.md`) and the relevant convention doc instead of re-deriving conventions from the code
|
||
each time it starts API/SPA/E2E/parity work. A testing map and a generated-endpoint index are
|
||
tracked as still-to-come under #185. Drafting this doc set also surfaced a drift in
|
||
`ApiControllerSecurityTests.cs`'s hardcoded controller registry (several controllers under
|
||
`ErsatzTV/Controllers/Api/` are missing from it — see `docs/api-conventions.md` §6) — tracked as a
|
||
follow-up under #184 rather than fixed inline, since it's a pre-existing gap, not something this
|
||
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
|
||
running (plus one poll on mount so a session started elsewhere still gates Play), rather than a
|
||
server push. The status endpoint returns `{ state, exitCode, speed, logs }`; the screen captures the
|
||
running→completed/failed transition in local component state and surfaces a completion notice
|
||
(success on exit 0, warning otherwise) — the SPA equivalent of the Blazor page's MediatR
|
||
`ICourier`/`ISnackbar` `PlaybackTroubleshootingCompletedNotification`. Chosen over SignalR/SSE
|
||
because the SPA has **no push channel** and troubleshooting sessions are short and user-initiated, so
|
||
a lightweight poll (started on Play, stopped on settle/unmount) is simpler than standing up a new
|
||
real-time transport. Speed thresholds and the "(Speed: Nx)" badge colors are copied verbatim from the
|
||
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 `<input type="datetime-local">` 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
|
||
`<input type="datetime-local">`, a **deliberate deviation** from the Blazor page, which parsed a
|
||
free-text field with `Chronic.Core.Parser` (natural language like "yesterday at 8pm"). The SPA has no
|
||
Chronic dependency and a picker is unambiguous; the selected local datetime is sent to
|
||
`playback.m3u8` as an ISO-8601 `start` param via `new Date(value).toISOString()`, which the
|
||
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
|
||
only gated Download Results. Both downloads compete with the live transcode for I/O and the sample
|
||
archiver reads the same media file, so gating both during a session is strictly safer and costs
|
||
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`
|
||
(camelCase + a `FFmpegProfileId`→`ffmpegProfileId` special case + `[JsonProperty]` overrides such as
|
||
`ChannelResponseModel.FFmpegProfile`→`ffmpegProfile`), while `Microsoft.AspNetCore.OpenApi` generates the
|
||
spec from System.Text.Json metadata, whose camelCase drifted (`fFmpegProfileId`, `fFmpegProfile`). That
|
||
drift fed the SPA the wrong key. Rather than hand-patch the spec or change the wire format (breaking clients),
|
||
we added `NewtonsoftSchemaNamingTransformer` — an OpenAPI schema transformer registered on all three
|
||
documents that renames each schema property through the *same* Newtonsoft contract resolver the runtime uses,
|
||
so the spec matches the wire format by construction. A contract test
|
||
(`OpenApiSerializerContractTests`) serializes representative DTOs through the real runtime settings and pins
|
||
the spec property sets to them. Decision: **the wire format is the source of truth; the spec follows it via the
|
||
real contract resolver.** This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read
|
||
`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 `<textarea>`, not a server-side file path, since the SPA has no filesystem access.
|
||
**Signals:** `YamlValidatorScreen.tsx` · paths: `web/src/screens/YamlValidatorScreen.tsx` · issues: none cited
|
||
**Mechanics:** `YamlValidatorScreen.tsx`
|
||
|
||
The legacy Blazor YAML playout validator took a **server-side file path** (read directly off the
|
||
container's filesystem). The SPA's `YamlValidatorScreen.tsx` instead uses a paste `<textarea>` — a
|
||
deliberate deviation, not an oversight. The SPA runs entirely client-side against `/api/*` and has
|
||
no access to the server's filesystem, so a file-path field would either need a new
|
||
filesystem-browsing endpoint or silently fail; pasting the YAML directly is simpler and matches how
|
||
every other SPA editor already round-trips content through the API instead of the disk.
|
||
|
||
## 2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder
|
||
`key: spa.channel-renumber-prompt` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Channel renumbering uses a sequential `prompt()`-driven "Renumber" action instead of drag-to-reorder.
|
||
**Signals:** `App.tsx` Renumber action · paths: `web/src/App.tsx` · issues: none cited
|
||
**Mechanics:** `App.tsx` Renumber action
|
||
|
||
The legacy Blazor channel list let you drag-and-drop rows to reorder channel numbers. The SPA
|
||
(`App.tsx`) instead offers a "Renumber" action that walks the list and asks for each channel's new
|
||
number via a sequence of native `prompt()` calls. Deliberate deviation: drag-to-reorder needs a
|
||
dedicated drag library and a bespoke reorder-persistence endpoint; a sequential prompt reuses the
|
||
existing per-channel update call and needs no new UI dependency. Revisit only if channel counts grow
|
||
large enough that prompt-per-channel becomes tedious.
|
||
|
||
## 2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor
|
||
`key: spa.deco-templates-table` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention.
|
||
**Signals:** `DecoTemplatesScreen.tsx`, same accepted-deviation status · paths: `web/src/screens/DecoTemplatesScreen.tsx` · issues: none cited
|
||
**Mechanics:** `DecoTemplatesScreen.tsx`
|
||
|
||
Extends the 2026-07 "Templates editor in the SPA is a table, not Blazor's drag-calendar" entry
|
||
above (not editing that entry — this generalizes it): the deco-templates editor
|
||
(`DecoTemplatesScreen.tsx`) follows the same convention, rendering its day/deco assignment as a
|
||
table rather than reproducing Blazor's drag-and-drop calendar grid. Same rationale, same
|
||
accepted-deviation status — don't "fix" either editor to match Blazor's interaction model without
|
||
discussing it first.
|
||
|
||
## 2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)
|
||
`key: api.search-paging-cap` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Search stays capped at 100 items per media kind; an overflowing kind's "See all" reuses library-browse paging instead of adding new API surface.
|
||
**Signals:** `GetLibraryBrowseItems`, `state:FileNotFound` query · paths: `GET /api/v1/search`, `GET /api/v1/library/browse` · issues: #213
|
||
**Mechanics:** `GET /api/v1/library/browse`
|
||
|
||
`GET /api/v1/search` still returns at most 100 items per media kind, which is the cheap first page for
|
||
the common case. For an overflowing kind, the SPA's "See all N …" action pages `GET
|
||
/api/v1/library/browse` with `query=state:FileNotFound`, `mediaType`, `pageNum`, and `pageSize=100`, then
|
||
appends the results client-side. This reuses the same `GetLibraryBrowseItems` query behind search,
|
||
adds no API surface, and only pays for follow-up requests when a kind exceeds the first-page cap.
|
||
|
||
## 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement
|
||
`key: spa.logs-page-size-local` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The Logs page rows-per-page preference is stored in `window.localStorage` (`ctv-logs-page-size`), not a server `ConfigElement`.
|
||
**Signals:** `LogsScreen.tsx`, wrapped-`Storage` pattern, `designSystem.ts`/`auth.ts` precedent · paths: `LogsScreen.tsx`, `designSystem.ts` · issues: none cited
|
||
**Mechanics:** `LogsScreen.tsx` localStorage key `ctv-logs-page-size`
|
||
|
||
The legacy Blazor Logs page persisted the user's chosen rows-per-page via
|
||
`ConfigElementKey.LogsPageSize` (`SaveConfigElementByKey`/`GetConfigElementByKey`), a
|
||
per-server-instance setting stored in the DB. `LogsScreen.tsx` instead persists it to
|
||
`window.localStorage` under `ctv-logs-page-size` (same wrapped-`Storage` pattern as
|
||
`designSystem.ts`'s theme preference: try/catch getter, validated against the known option set,
|
||
falls back to a default) and restores it on mount. Deliberate deviation: this is a per-browser UI
|
||
preference, not server/business state — no other client should see or be affected by it, so there
|
||
is no reason to round-trip it through the API and grow a new `/api/*` surface (or reuse the
|
||
generic config-element endpoints) just to store a page-size number. Follows the existing SPA
|
||
localStorage convention (`designSystem.ts` theme, `auth.ts` token) rather than introducing a new
|
||
persistence mechanism.
|
||
|
||
## 2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`
|
||
`key: api.logs-sort-params` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `GET /api/logs` takes allow-listed `sortField` (`timestamp`|`level`) and `sortDirection` (`asc`|`desc`) query params, normalized (not rejected) on an unrecognized value.
|
||
**Signals:** `MudTableSortLabel` parity, clamp-not-422 normalization · paths: `LogsController.GetLogs`, `LogsScreen.tsx` · issues: none cited
|
||
**Mechanics:** `LogsController.GetLogs`
|
||
|
||
Parity for `Logs.razor`'s `MudTableSortLabel` columns (Timestamp, Level — Message was never
|
||
sortable in Blazor either). `LogsController.GetLogs` adds `sortField` (`timestamp` | `level`,
|
||
default `timestamp`) and `sortDirection` (`asc` | `desc`, default `desc`) query params, normalized
|
||
server-side the same way `pageNum`/`pageSize` are clamped rather than rejected with a 422: an
|
||
unrecognized `sortField` silently falls back to `timestamp`, an unrecognized `sortDirection` falls
|
||
back to `desc` — the pre-existing default behavior (newest-first) is unreachable to break via a bad
|
||
query string. `LogsScreen.tsx` renders the two sortable headers as buttons with a chevron
|
||
indicating the active field/direction; clicking the active column toggles direction, clicking the
|
||
other column switches to it ascending.
|
||
|
||
## 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode
|
||
`key: spa.playout-reset-button` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The SPA keeps a single Reset action (server picks the default build mode) and drops Blazor's separate "Schedule reset" button since its capability already exists via the playout's Edit-details flow.
|
||
**Signals:** `POST /api/channels/{channelNumber}/playout/reset`, no `mode` param · paths: none · issues: #210
|
||
**Mechanics:** `POST /api/channels/{channelNumber}/playout/reset`
|
||
|
||
Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control
|
||
(setting the daily rebuild time). The SPA keeps Reset — `POST
|
||
/api/channels/{channelNumber}/playout/reset` with no `mode` param, so the server picks the same
|
||
default Blazor used (Classic → Refresh, everything else → Reset) — but drops the dedicated
|
||
"Schedule reset" button: the daily rebuild time is already editable through the playout's
|
||
Edit-details flow, so a second entry point would duplicate an existing capability. Deliberate
|
||
deviation, not a lost capability. Issue #210.
|
||
|
||
## 2026-07-09 — Collection custom order: move up/down buttons, any-kind collections
|
||
`key: spa.collection-custom-order-ui` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Collection custom ordering uses per-row Move up/Move down buttons (not drag) and is offered for any manual collection with custom order enabled, not just movies-only.
|
||
**Signals:** `CollectionsScreen.tsx`, `PUT /api/collections/{id}/custom-order`, `CustomOrderCollectionEnumerator` · paths: `CollectionsScreen.tsx` · issues: #211
|
||
**Mechanics:** `PUT /api/collections/{id}/custom-order`
|
||
|
||
Blazor reordered collection items with SortableJS drag-and-drop and only enabled custom ordering
|
||
for movies-only collections. The SPA (`CollectionsScreen.tsx`) uses per-row **Move up / Move
|
||
down** buttons in an explicit reorder mode instead of drag (no new drag dependency; the mode loads
|
||
ALL items first because `PUT /api/collections/{id}/custom-order` replaces the whole order from
|
||
array position — submitting a partial page would scramble the rest), and does **not** replicate
|
||
the movies-only gate: the API and the playback-side `CustomOrderCollectionEnumerator` sort by
|
||
`CustomIndex` regardless of item kind, so the SPA offers reorder for any manual collection with
|
||
custom order enabled. Issue #211.
|
||
|
||
## 2026-07-10 — Shared "Add to…" layer lives in `web/src/media/addTo/`; select-mode is an explicit toggle
|
||
`key: spa.add-to-layer` · `status: active` · `since: 2026-07-10` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** All add-to-collection/playlist/schedule affordances share one component layer at `web/src/media/addTo/`; multi-select is an explicit screen-level toggle, and the per-card menu offers schedule only for the server-validated kinds.
|
||
**Signals:** `AddToCollectionDialog`, `AddToPlaylistDialog`, `AddToScheduleDialog`, `AddToMenu`, `ProgramScheduleItemCommandBase.CollectionTypeMustBeValid` · paths: `web/src/media/addTo/` · issues: #208, #209
|
||
**Mechanics:** `web/src/media/addTo/`
|
||
|
||
The media mutation surface (#208/#209) is built on one reusable component group,
|
||
`web/src/media/addTo/` (media-domain components, like `MediaPosterCard` — not generic
|
||
`components/`): `AddToCollectionDialog` (existing-collection Select + inline "(New collection)"
|
||
create, Blazor `AddToCollectionDialog.razor` parity), `AddToPlaylistDialog` (group → playlist
|
||
Selects, no inline create), `AddToScheduleDialog` (schedule Select; payload replicates Blazor's
|
||
`AddProgramScheduleItem.ForMediaItem` defaults — see `addTo/scheduleItem.ts`),
|
||
`SaveAsSmartCollectionDialog`, and `AddToMenu` (the drop-in popover for cards/detail pages via
|
||
`MediaPosterCard`'s `actions` slot). New screens wanting add-to affordances use this layer —
|
||
don't build screen-local pickers. Two deliberate deviations from Blazor, applied consistently on
|
||
the search and browse screens: **(1) multi-select is an explicit screen-level "Select" toggle**
|
||
(off = cards open, on = cards select) rather than Blazor's always-on corner-select, because
|
||
`MediaPosterCard`'s select handler takes over the card's single click gesture; **(2) the
|
||
per-card menu offers collection/playlist for a single item of any kind, plus schedule only for
|
||
shows/seasons/artists** — collection/playlist is a superset of Blazor's per-card collection-only
|
||
menu, while the schedule target is gated to exactly the kinds Blazor's
|
||
`AddProgramScheduleItem.ForMediaItem` call sites offer, because the server validator
|
||
(`ProgramScheduleItemCommandBase.CollectionTypeMustBeValid`) accepts only the
|
||
TelevisionShow/TelevisionSeason/Artist per-media-item CollectionTypes and 422s the rest.
|
||
"Add All" (query-wide) mirrors Blazor's two-step: materialize ids
|
||
via `GET /api/search/all-items`, then reuse the id-list add endpoints — no query-based add
|
||
command exists server-side. Issues #208/#209.
|
||
|
||
## 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)
|
||
`key: api.schedule-item-flat-dto` · `status: active` · `since: 2026-07-10` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Schedule-item GET/POST/PUT use a flat, non-polymorphic `ScheduleItemResponseModel` (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching `ScheduleItemRequest` 1:1 for a lossless round-trip.
|
||
**Signals:** `ScheduleItemResponseMapper`, `ScheduleItemResponseRoundTripTests`, `EnforceProperties` normalization · paths: `ErsatzTV.Core/Api/Scheduling/` · issues: #126, #207, #212
|
||
**Mechanics:** `ScheduleItemResponseRoundTripTests`
|
||
|
||
`GET/POST/PUT /api/schedules/{id}/items` return `ScheduleItemResponseModel` /
|
||
`ScheduleItemsResponseModel` (`ErsatzTV.Core/Api/Scheduling/`), **not** the Application-layer
|
||
`ProgramScheduleItemViewModel` hierarchy (One/Flood/Multiple/Duration subtypes). The polymorphic VM
|
||
only described its base shape in OpenAPI, so the SPA couldn't see the subtype fields (issue #126).
|
||
The flat DTO promotes every subtype field to a nullable top-level member — `multipleMode`,
|
||
`multipleCount` (renamed from the VM's `Count`), `playoutDuration`, `tailMode`,
|
||
`discardToFillAttempts` — mapped by pattern-matching the concrete VM in
|
||
`ScheduleItemResponseMapper` (`ErsatzTV.Application/ProgramSchedules/`). Its **mutation fields are
|
||
named 1:1 with `ScheduleItemRequest`** so a GET maps losslessly back to a PUT/POST
|
||
(`ScheduleItemResponseRoundTripTests` is the release gate proving the fixed point). It also carries
|
||
picker-hydration fields the editor needs: `collectionName`/`smartCollectionName`/…/`playlistName`,
|
||
`playlistGroupId` (to preselect the playlist's group), per-filler names, `watermarks` /
|
||
`graphicsElements` as `NamedIdResponseModel` lists, the computed `name`, and `durationEstimate`.
|
||
`GetProgramScheduleItemsHandler.EnforceProperties` still rewrites StartType→Dynamic, Flood→One and
|
||
Playlist/Rerun→PlaybackOrder None when `ShuffleScheduleItems` is on — that lossy normalization is
|
||
deliberate and lives on the read side (documented + tested). New shared `NamedIdResponseModel`
|
||
(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212.
|
||
|
||
## 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
|
||
`key: api.playout-build-lock-409` · `status: active` · `since: 2026-07-10` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Every id-keyed playout/channel mutation endpoint checks `IEntityLocker.IsPlayoutLocked(id)` and returns 409 Conflict while a build is in-flight, mirroring Blazor's disabled-buttons behavior; reset-all stays 202 and silently skips locked playouts.
|
||
**Signals:** `ApiResults.ConflictProblem`, `PlayoutListItemResponseModel.IsLocked`, advisory check-then-act · paths: `PlayoutController.cs`, `ChannelController.cs` · issues: #215, adversarial-reviewer#18
|
||
**Mechanics:** `ApiResults.ConflictProblem`
|
||
|
||
Blazor disabled per-playout Reset/Erase/Delete/Edit while a `BuildPlayout` was in flight
|
||
(`EntityLocker.IsPlayoutLocked`, `Playouts.razor` + per-kind editors); the REST API had no
|
||
equivalent, so a client could race an in-flight build with a destructive `ExecuteDelete` and leave
|
||
a half-built playout. Adversarial-reviewer#18 promoted this to a #91-phase-(b) removal gate: after
|
||
Blazor is deleted the invariant would vanish entirely.
|
||
|
||
Decision: enforce the invariant **server-side** on the API rather than re-implementing a live push
|
||
channel. `PlayoutController` and `ChannelController` inject `IEntityLocker`; every id-keyed mutation
|
||
— `PUT /api/playouts/{id}`, `.../deco`, `.../alternate-schedules`, `.../templates`,
|
||
`POST .../erase-items`, `.../erase-items-and-history`, `DELETE /api/playouts/{id}`, and
|
||
`POST /api/channels/{channelNumber}/playout/reset` — checks `IsPlayoutLocked(id)` first and returns
|
||
**409 Conflict** (`ApiResults.ConflictProblem`, new shared helper mirroring `NotFoundProblem`) while
|
||
the build lock is held. The PUTs are gated too (not just the destructive ops): the target invariant
|
||
is "no mutation during a build", matching Blazor's edit-disable.
|
||
|
||
- **The guard is advisory check-then-act, not mutual exclusion** — same posture as Blazor's disabled
|
||
buttons. A `BuildPlayout` already sitting in the worker queue can take the lock a few milliseconds
|
||
after the check passes, so the original race is *narrowed*, not eliminated; consequences remain
|
||
self-healing (the next rebuild corrects a half-mutated playout). True prevention — having each
|
||
mutation acquire the playout lock for its duration — was deliberately not taken: `LockPlayout`
|
||
publishes `PlayoutUpdatedNotification` (UI churn per mutation) and would make mutations block
|
||
builds, a semantics change out of scope for restoring Blazor parity.
|
||
|
||
- **`reset-all` is deliberately NOT gated** — it stays 202. `ResetAllPlayoutsHandler` already
|
||
*silently skips* locked playouts, which matches Blazor and the handler semantics; a fire-and-forget
|
||
bulk enqueue always accepts.
|
||
- **SPA mirrors the lock via data, not a push channel** — `PlayoutListItemResponseModel` gains an
|
||
`IsLocked` bool (set from `IsPlayoutLocked` in the controller's list projection). The playouts
|
||
screen disables Reset/Erase/Erase-and-history/Delete for a locked row and shows a "Building…"
|
||
Badge; on a 409 from any mutation it surfaces the error and calls `query.refresh()` so the row
|
||
picks up the flag. No new polling was added (the existing 30s channel-state poll is unchanged).
|
||
|
||
Precedent for the 409 shape: `TraktController` (left as-is with its own private `ConflictProblem()`
|
||
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
|
||
|
||
## 2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved
|
||
`key: spa.schedules-editor-draft-save` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The schedules SPA editor mutates a local draft and flushes one explicit Save (`PUT /api/schedules/{id}/items`) instead of instant-persisting each action; Copy deep-copies all source references (fixing a Blazor omission); the shuffled-schedule GET's `EnforceProperties` lossy normalization is preserved and mirrored in the SPA's option lists.
|
||
**Signals:** draft/dirty guard, `navigationGuard.ts`, `copyDraftItem`, `EnforceProperties` · paths: `web/src/screens/SchedulesScreen.tsx`, `web/src/schedules/`, `docs/spa-conventions.md` §8 · issues: #207
|
||
**Mechanics:** `docs/spa-conventions.md` §8
|
||
|
||
The ChicoryTV schedules editor (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`,
|
||
issue #207) rebuilds the schedule-item lineup to full mutation parity with the legacy Blazor editor.
|
||
Three deliberate decisions:
|
||
|
||
**(a) Draft model with one explicit Save, replacing instant-persist.** All add/edit/copy/remove/
|
||
reorder mutate a **local draft list only**; a single **Save** issues one
|
||
`PUT /api/schedules/{id}/items` (the replace endpoint). This is intentional because that PUT is
|
||
**destructive server-side** — it deletes and recreates every item row (new ids) and triggers playout
|
||
rebuilds — so batching edits into one flush (vs. the old per-action POST/DELETE/PUT) minimizes churn
|
||
and gives the user a Discard/dirty affordance. On 422/network error the draft is kept and the error
|
||
surfaced; on success the draft is replaced with the server response. A **Discard** action and a dirty
|
||
guard (native `confirm()` on schedule-switch + in-app nav via `navigationGuard.ts`, plus a
|
||
`beforeunload` listener) protect the draft. See `docs/spa-conventions.md` §8. This retires the old
|
||
inline `ScheduleScreen` in `App.tsx` and its instant-persist add/delete/reorder tests.
|
||
|
||
**(b) Copy item deep-copies ALL source references, including multi/smart/rerun collections.** Blazor's
|
||
`CopyItem` omitted the multi-collection / smart-collection / rerun-collection references when
|
||
duplicating an item (copying only the plain collection/media-item/playlist refs) — a latent bug. The
|
||
SPA's `copyDraftItem` (`web/src/schedules/itemRules.ts`) copies every source field + display name, so
|
||
copying a MultiCollection/SmartCollection/Rerun item preserves its source. Deliberate deviation
|
||
fixing the Blazor omission.
|
||
|
||
**(c) The shuffled-schedule GET normalization (`EnforceProperties`) is preserved lossiness, matching
|
||
Blazor.** When a schedule has `ShuffleScheduleItems`, `GET .../items` still rewrites startType→Dynamic,
|
||
Flood→One, and Playlist/Rerun playbackOrder→None (and zeroes discardToFillAttempts for non-random
|
||
Duration items). The SPA does **not** fight this — it hides the Fixed start type and Flood playout
|
||
mode from the option lists (and disables the reorder arrows) for shuffled schedules, mirroring Blazor,
|
||
so a GET→edit→PUT round-trip stays consistent with the server's read-side normalization. Issue #207.
|
||
|
||
## 2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212)
|
||
`key: spa.channel-editor-create-logo` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Bare-channel create is a "New blank channel" action on the channels list (reusing Blazor's add-mode defaults) that navigates into the full editor, and an external logo URL always wins over an uploaded logo, matching `ChannelEditViewModel` precedence.
|
||
**Signals:** `ChannelEditor.razor` add-mode defaults, `ArtworkContentTypeModel.isExternalUrl`, `EMPTY_LOGO` · paths: `web/src/App.tsx`, `ChannelEditScreen.tsx` · issues: #212
|
||
**Mechanics:** `ChannelEditScreen.tsx`
|
||
|
||
Two Blazor-parity decisions closing the channel-editor gaps (`ChannelEditor.razor` +
|
||
`ChannelEditViewModel`):
|
||
|
||
1. **Bare-channel create lives on the channels list, not a form-first route.** Blazor's
|
||
`/channels` (no `Id`) is a full add form; the SPA instead adds a "New blank channel" action next
|
||
to "Add Channel" on `ChannelsScreen` (`web/src/App.tsx`) that POSTs `CreateChannelRequest` with
|
||
Blazor's computed add-mode defaults (`(max existing int-parsed channel number) + 1`, `name: "New
|
||
Channel"`, `group: "ErsatzTV"`, `ffmpegProfileId` = `GET /api/settings/ffmpeg`'s
|
||
`defaultFFmpegProfileId`, `streamingMode: "TransportStreamHybrid"`, `isEnabled`/`showInEpg: true`,
|
||
every other field at its C# `default(T)` — see `ChannelEditor.razor`'s `else` branch for the
|
||
source of truth) directly, then navigates to `/app/edit-channel/{id}` for the rest of the fields.
|
||
This is a deliberate equivalent, not a parity gap: it reuses the existing full editor instead of
|
||
duplicating its ~20 fields into a second form. "Add Channel" (`/app/new-channel`, the
|
||
library-to-lineup `ChannelBuilder` flow) is unrelated and untouched.
|
||
2. **External logo URL wins over an uploaded logo, mirroring
|
||
`ChannelEditViewModel.ToUpdate/ToCreate`.** The channel's logo is `ArtworkContentTypeModel {
|
||
path, contentType, isExternalUrl }`; on hydration, `isExternalUrl: true` populates a separate
|
||
"External logo URL" field and the uploaded-logo draft state is treated as empty (`EMPTY_LOGO =
|
||
{ path: '', contentType: '' }` in `ChannelEditScreen.tsx`) so the two never disagree. On submit,
|
||
a non-blank URL always wins: `logo: { path: url, contentType: '' }`, exactly matching
|
||
`ExternalLogoUrl`'s precedence in the C# view model. Uploading a file clears the URL field (the
|
||
last-set field wins, Blazor parity via `UploadLogo`'s `_model.ExternalLogoUrl = null`). The URL
|
||
is validated as http(s) client-side before save is enabled (Blazor has no equivalent validation;
|
||
added because the field is free text with no server-side format check surfaced to the SPA).
|
||
|
||
Also landed with #212: `preferredAudioLanguageCode`/`preferredSubtitleLanguageCode`,
|
||
`musicVideoCreditsTemplate`, and `streamSelector` moved from free-text `Input`s to `Select`s fed by
|
||
`GET /api/languages` / `/api/channels/music-video-credits-templates` /
|
||
`/api/channels/stream-selectors`. Each keeps the channel's currently-stored value selectable even if
|
||
it's absent from the reference list (`optionsKeepingCurrent` in `ChannelEditScreen.tsx`) so loading
|
||
an existing channel never silently changes the value out from under an unmanaged language code or a
|
||
template/selector file removed from disk since save.
|
||
|
||
## 2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)
|
||
`key: locking.entitylocker-atomic-flags` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `EntityLocker` uses `Interlocked.CompareExchange`-guarded atomic flags plus a documented single-owner-release discipline (no owner tokens/leases); `Unlock*` on an already-unlocked slot returns `false` and logs a Warning rather than throwing.
|
||
**Signals:** six `bool`→`int` flags, `TraktController.EnqueueWithTraktLock`, `unlock: last` tail pattern · paths: `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`, `IEntityLocker` · issues: #231, #232, #234, adversarial-reviewer#20/F5
|
||
**Mechanics:** `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`
|
||
|
||
`EntityLocker` (process-wide singleton, `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`) is the
|
||
advisory "operation on entity X is in progress" mutex layer. Adversarial review (#20/F5, →#231)
|
||
found three defects: six plain-`bool` flags with a non-atomic check-then-set (two threads could both
|
||
acquire and both return `true`), tokenless `Unlock*` letting any caller release another owner's lock
|
||
(e.g. `BuildPlayoutHandler` ignored `LockPlayout`'s return then unconditionally unlocked in
|
||
`finally`), and one batch taking a single `LockLibrary` released after the *first* of two enqueued
|
||
scan units (so the second ran unlocked).
|
||
|
||
**Model chosen: tokenless atomic flag + documented single-owner release discipline.** The six bools
|
||
became `int`s guarded by `Interlocked.CompareExchange` (0/1), so `Lock*`'s `bool` return is now a
|
||
reliable "this call won the transition" signal and the change event fires exactly once per
|
||
transition. The contract (XML-doc'd on `IEntityLocker`): a `true` from `Lock*` confers ownership of
|
||
exactly one release — performed either in the acquiring scope (`finally`, gated on the captured
|
||
bool) or by the single designated releaser the acquirer hands off to (the consumer of the message
|
||
enqueued while holding the lock, with a compensating unlock if the enqueue throws — the established
|
||
`TraktController.EnqueueWithTraktLock` / scheduler `unlock: last` tail pattern). Callers must never
|
||
release a lock they did not acquire. `Unlock*` on an already-unlocked slot returns `false`, fires no
|
||
event, and logs a Warning — the loud tripwire for double-release bugs; it does not throw or
|
||
`Debug.Assert`, because an advisory flag must stay safe to release in `finally` paths. The three
|
||
`ConcurrentDictionary`-backed kinds (Library/Playout/RemoteMediaSource) were already atomic
|
||
(`TryAdd`/`TryRemove`) and kept their semantics (the redundant `ContainsKey` pre-checks were dropped
|
||
as tidy-up); the interface signature is unchanged across its ~40 call sites.
|
||
|
||
**Rejected:** owner tokens/leases — the acquirer and releaser for Library/Trakt/Plex/Collections
|
||
locks are different code correlated only by entity id across in-memory `Channel<T>` queues, so a
|
||
token would have to travel inside ~8 background-request message types for a defect that discipline
|
||
plus the now-trustworthy atomic return value already prevents. Counted/reentrant locks — wrong
|
||
semantics: these are exclusive in-progress flags; two holders is the failure mode, not a feature.
|
||
Call-site fixes this model prescribes land separately: #232 (scan lifecycle — enqueue only after a
|
||
successful lock, compensating unlock on enqueue failure, one release per acquisition in batches) and
|
||
#234 (BuildPlayout/subtitles gate their `finally` unlock on the captured acquire result).
|
||
|
||
## 2026-07-11 — Media-source management REST write API + SPA (#202)
|
||
`key: media.source-mgmt-write-api` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Media-source management (local/Plex/Jellyfin/Emby) is a REST write API + SPA under `/app/libraries/*`, wrapping existing MediatR commands 1:1 with no new commands or DB migration; connection GETs never leak a stored `apiKey`, and each PUT-replace family's identity contract is documented per-family (not assumed uniform).
|
||
**Signals:** `RemoteConnectionResponseModel {hasApiKey}`, per-family identity contracts, Plex pin-flow polling, EntityLocker non-owner-token discipline · paths: `LocalLibrariesController`, `Plex|Jellyfin|EmbyMediaSourcesController` · issues: #202, #197, #231
|
||
**Mechanics:** `docs/handoffs/` session record, issue #202
|
||
|
||
Replaced the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages (14 routes) with SPA
|
||
screens under `/app/libraries/*` over new write controllers (`LocalLibrariesController`,
|
||
`Plex|Jellyfin|EmbyMediaSourcesController`), wrapping existing MediatR commands 1:1 (no new
|
||
commands, no DB migration). Full design + adversarial-review reconciliation:
|
||
`docs/handoffs/` session record and issue #202. The design surfaced and fixed several pre-existing
|
||
Application/Infrastructure bugs newly reachable from a programmatic client; each is recorded here
|
||
because it changes documented behavior, not just adds a route.
|
||
|
||
**Secure `apiKey` contract (Jellyfin/Emby connection).** The connection GET
|
||
(`RemoteConnectionResponseModel`) returns `{ address, hasApiKey }` — the stored key **never**
|
||
leaves the server, closing a leak where the old design would have served the raw key from an
|
||
unauthenticated GET under any-origin CORS. On the connection PUT, a blank/omitted `apiKey` means
|
||
*retain the existing key*; a non-blank value sets a new one; the key is **required on first
|
||
connect** (no existing secret) → 422. Rationale: GETs aren't behind `X-Api-Key`
|
||
(`ApiKeyAuthorizationFilter` only guards mutating verbs), so a secret-bearing GET is a real
|
||
exposure regardless of how obscure the route is. **Stated explicitly as an input to #197** (the
|
||
planned read-side-auth review for secret-bearing GETs) — #197 should treat "does any GET return a
|
||
credential" as one of its checks, not just this one instance.
|
||
|
||
**Three list-replace identity contracts, not one uniform one.** An earlier draft assumed a single
|
||
"`Id<1`=add / missing=delete / id-preserved" contract across all three PUT-replace families; source
|
||
inspection proved that false for two of them:
|
||
- *Remote library sync preferences* (`PUT .../{id}/libraries`) — the command carries no source id
|
||
and the handler toggles only the ids present in the body; a row **absent** from the request is
|
||
left untouched, not deleted (libraries are sync-discovered, never created via this PUT, so there
|
||
are no `Id=0` adds either). The controller validates the submitted id set against
|
||
`Get{Family}LibrariesBySourceId(id)` (422 on any id not owned by the route's source — closes a
|
||
cross-source hole). Identity is **not stable across a disable**: `Disable{Family}LibrarySync`
|
||
removes and re-adds the row with a fresh id, so the SPA keys its draft to `(name, mediaKind)`,
|
||
never to `Id`, and refetches after every save (the PUT returns the reloaded list).
|
||
- *Path replacements* (`PUT .../{id}/path-replacements`) — id-based (existing `Id`=update,
|
||
`Id<1`=add, absent=delete) as documented, **but** the repo UPDATE SQL had no source-id predicate
|
||
(`WHERE Id = @id`, no `AND {Family}MediaSourceId = @id`), so a PUT to source A could silently
|
||
overwrite source B's row with the same numeric id. Fixed with a handler-level ownership guard
|
||
(reject any incoming positive id not in this source's current set → 422, no partial mutation)
|
||
**and** the repo SQL predicate itself (defense-in-depth for any other caller of that repo method).
|
||
- *Local library paths* (`PUT /api/libraries/local/{id}`) — identity is the **normalized path
|
||
string** (full path, trailing-separator/case-insensitive), not `Id`; `Id` in the request is
|
||
advisory. Renaming a path is delete-old+add-new under the hood (its `LibraryPath.Id` changes).
|
||
Kept as-is (matches the entrenched, tested Blazor behavior and how the SPA edits by value); not
|
||
rewritten to id-based identity, which would be a bigger, riskier change out of #202's scope.
|
||
|
||
**Plex pin-flow as REST: poll until the lock releases, exception-safe non-handoff unlock.** The
|
||
SPA polls `GET /api/media-sources/plex` rather than a per-pin status resource (no pin-addressable
|
||
server state exists to expose; SSE/push was already rejected, 2026-07-09). Polling contract is
|
||
`isLocked && !isAuthorized` = waiting on the user; `isLocked && isAuthorized` = finalizing
|
||
(discovering servers — **do not** stop here, the server list is still empty);
|
||
`!isLocked && isAuthorized` = success; `!isLocked && !isAuthorized` = timed out/abandoned. This
|
||
required fixing a **latent lock-leak bug**: `TryCompletePlexPinFlowHandler` threw
|
||
`OperationCanceledException` on its 2-minute timeout instead of returning `false`, and nothing
|
||
unlocked on that path — an abandoned sign-in wedged the Plex lock until restart or manual sign-out.
|
||
Fix releases `UnlockPlex()` on the timeout-throw, a poll-exception, and an enqueue-exception — but
|
||
**deliberately not** in an unconditional `finally`: on success the lock is handed off to
|
||
`SynchronizePlexMediaSources`, the sole releaser after server discovery; a blanket `finally` would
|
||
double-release and release *before* discovery completes, re-opening the same race the fix closes.
|
||
This is the same non-owner-token discipline as the #231 `EntityLocker` model (2026-07-11 entry
|
||
above), applied to the pin-flow's handoff-vs-terminal distinction specifically.
|
||
|
||
**404 comes from the controller pre-check, not the handler.** `Apply`/`ToEitherAsync` both
|
||
`.Join()` errors, which flattens any `NotFoundError` inside a joined `Validation` down to a plain
|
||
422. So every id-taking endpoint's real 404 is a controller-side pre-check
|
||
(`Get...ById(id)`-is-`None` → `ApiResults.NotFoundProblem`, the `TemplateController.DeleteGroup`
|
||
pattern), not a handler-level conversion — converting the joined validators to `NotFoundError`
|
||
would be dead code, since the join discards the distinction anyway. This is check-then-act (a
|
||
delete racing between the pre-check and the command falls through to the handler's own 422, not a
|
||
404); accepted and tested against the actual runtime error type rather than a hoped-for handler 404.
|
||
|
||
**"Scan All" dropped, not implemented.** The disabled SPA header button on the libraries hub was
|
||
speculative UI with **no Blazor equivalent** (`Libraries.razor` only ever supported per-library
|
||
scan). Removed rather than backed with a new bulk-scan endpoint; per-library scan (#232) and the
|
||
new per-source refresh-libraries endpoints (P8/J9/E9) cover the real capability set.
|
||
|
||
**App-owned popstate for guarded sub-path routes.** `LocalLibraryEditScreen` and the other
|
||
`/app/libraries/*` editors are the first screens to both register a dirty-navigation guard *and*
|
||
track their own sub-path pathname — the combination `spa-conventions.md` §8 had flagged as
|
||
unvalidated. React commits child passive effects before parent ones, so a sub-path wrapper that
|
||
self-registers `popstate` would fire (and switch sub-screen) *before* App's guard-restore listener
|
||
could veto. Resolution: App owns `popstate` centrally for the `libraries` route and only pushes an
|
||
approved sub-path down to the wrapper (which no longer self-registers `popstate`); on a vetoed pop
|
||
App re-pushes the pre-pop URL and the wrapper never sees the rejected path. This is scoped to the
|
||
`libraries` route only (gated on `activeRoute === 'libraries'`) so unguarded sub-path routes
|
||
(Playouts, Media) stay byte-identical. See `spa-conventions.md` §2/§8 for the updated exemplar
|
||
list and the resolved caveat text.
|
||
|
||
## 2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204)
|
||
`key: spa.legacy-redirect-matcher` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `LegacyUiRedirects.TryGetRedirect` is a two-tier matcher — an exact `OrdinalIgnoreCase` `Map` (Tier 1) then an ordered segment-template pattern list (Tier 2, first-match-wins) — collision-free by construction, with a guard invariant that no rule may prefix-match `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`, or `/media/sources`.
|
||
**Signals:** `{id}` strict-positive-integer token, `{any}` token, `AppendQueryString` merge · paths: `ErsatzTV/LegacyUiRedirects.cs` · issues: #204
|
||
**Mechanics:** `LegacyUiRedirects.TryGetRedirect`, `AppendQueryString`
|
||
|
||
`LegacyUiRedirects.TryGetRedirect` grew from a single exact-path dictionary to a **two-tier matcher**
|
||
behind the unchanged `(PathString, out string)` signature. **Tier 1** is the existing
|
||
`OrdinalIgnoreCase` `Map` (now 52 entries — the parameterless (A)/(B) routes plus the (E-base) browse
|
||
roots whose *targets* carry `?kind=…`). **Tier 2** is an ordered `IReadOnlyList<PatternRule>` of 36
|
||
segment-template rules ((C)/(C2)/(D)/(E-page)), consulted only on a Tier-1 miss; declaration order is
|
||
match order (first-match-wins).
|
||
|
||
Template tokens are minimal: `{id}` matches a **strict positive integer**
|
||
(`int.TryParse(seg, NumberStyles.None, InvariantCulture, out id) && id > 0` — rejects signs,
|
||
whitespace, separators, `0`, negatives, and overflow like `999999999999`; the raw segment text,
|
||
e.g. `007`, is substituted, not re-formatted); `{any}` matches any non-empty segment and is dropped
|
||
(only `/playouts/add/{any}`); everything else is a literal compared `OrdinalIgnoreCase`. The request
|
||
path is split with `StringSplitOptions.None` and **empty segments are rejected** (load-bearing: so
|
||
`/channels//5` cannot match `/channels/{id}`); templates themselves use `RemoveEmptyEntries`. The
|
||
existing single-trailing-slash normalization runs before both tiers, so `/channels/5/` matches.
|
||
|
||
The set is **collision-free by construction** — exact-before-pattern plus strict numeric `{id}` means
|
||
no two tiers/rules can match the same path. **Guard invariant** (comment + `Map`-keys meta-test): no
|
||
Tier-1 key or Tier-2 template may begin with `/api`, `/artwork`, `/docs`, `/openapi`, `/iptv`, `/app`,
|
||
or `/media/sources`; rules are always full, specific templates — **never prefix wildcards** (a bare
|
||
`/media/{any}` rule is forbidden). The blazor branch does not prefix-guard `/api|/artwork|/docs|
|
||
/openapi`, so the matcher's specificity is part of their protection.
|
||
|
||
**Query-string merge**: the incoming request query is now merged into the target via a new public
|
||
`AppendQueryString(target, QueryString)` helper (one-line Startup change:
|
||
`context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target, context.Request.QueryString)`).
|
||
A target that already carries `?` (the `?kind=…` browse roots) is `&`-joined instead of producing a
|
||
malformed double `?`; plain targets keep verbatim-append behavior byte-for-byte. A duplicated key
|
||
after a merge (`?kind=movies` + incoming `?kind=shows`) is first-wins in the SPA
|
||
(`URLSearchParams.get` returns the first value) — acceptable. Extracting the merge into
|
||
`LegacyUiRedirects` keeps it unit-testable without a TestServer while preserving the PathBase
|
||
re-application invariant the Startup source-text test protects.
|
||
|
||
**Rejected**: regex pairs (harder to audit for the `/api`/`/artwork` greediness invariant, noisier
|
||
tests, no benefit — every parameterized route here is "fixed segments + one variable segment");
|
||
ASP.NET `TemplateMatcher`/`RouteMatcher` (pulls routing machinery into a static helper for 36 rules);
|
||
a single unified rule list (loses the O(1) dictionary hit for the ~52 exact routes that dominate real
|
||
traffic). No `/api` change, no OpenAPI regen, no SPA change.
|
||
|
||
## 2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205)
|
||
`key: blazor.rollback-tag` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path.
|
||
**Signals:** `git tag -a blazor-final`, `ersatztv:blazor-final` test-image restore path · paths: none · issues: #205
|
||
**Mechanics:** `git tag blazor-final`
|
||
|
||
Removal-gate item #205: the removal PR deletes both the Blazor reference implementation and the
|
||
`/system/health` escape hatch, so a post-deletion parity gap would otherwise be an archaeology exercise
|
||
(guessing which release tag still matches `main` minus Blazor). Decision + procedure, to run as the **first
|
||
action of the Step 2 deletion PR merge** (not before — `main` moves until then):
|
||
|
||
1. On the `main` commit **immediately preceding** the removal merge (the last commit that still contains
|
||
`ErsatzTV/Pages/**`), cut an annotated tag and push it:
|
||
`git tag -a blazor-final -m "Last commit with the legacy Blazor Server UI (pre-#91-phase-b removal)"`
|
||
then `git push origin blazor-final`. The tag name is **`blazor-final`** (not `v*`) so it does **not**
|
||
trigger the `v*` prod-release build in `.gitea/workflows/docker-build.yml`.
|
||
2. **Restore path** (if a gap surfaces post-removal): `git checkout blazor-final` → `docker build -f
|
||
docker/Dockerfile -t ersatztv:blazor-final .` → pin the **test** container to that image while the gap is
|
||
fixed forward on `main`. Alternatively `git revert` the single deletion merge commit (keep the deletion as
|
||
one squash/merge commit specifically to make this a one-liner).
|
||
3. Document the tag + restore path in the removal PR body; update this entry with the tag's commit sha when
|
||
cut.
|
||
|
||
Not cut this session — `main` still carries Blazor and will advance before the removal PR.
|
||
|
||
## 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235)
|
||
`key: api.async-op-contract` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Queue-triggering `/api/*` endpoints normalize onto one contract — 202 Accepted (queued), 404 (missing entity), 409 (lock held), 422 (domain precondition) — with Trakt as the reference implementation; playout list/detail GETs also carry an `isLocked` observability flag as the HTTP-observable substitute for a live push channel.
|
||
**Signals:** `QueueShowScanResult`, `ResetAllPlayoutsResponseModel`, `MaintenanceController.EmptyTrash`/`CleanArtwork` · paths: `LibrariesController.ScanShow`, `ChannelController.ResetPlayout`, `PlayoutController.ResetAll` · issues: #235, adversarial-reviewer#20 F7/F8/F9, #232, #215
|
||
**Mechanics:** `docs/api-conventions.md` §3a/§3b
|
||
|
||
Reviewer#20 F7/F8/F9. Normalizes the queue-triggering `/api/*` endpoints onto one contract, closes the two
|
||
F9 `Libraries.razor` parity gaps, and hardens the Trakt batch-lock lifecycle. Much of the F8 surface was
|
||
**already normalized** by #232 (library scan → `QueueLibraryScanResult` 202/404/409/422) and #215 (per-id
|
||
playout mutations + reset → 409 lock guard) — this issue finished the remaining outliers.
|
||
|
||
**Normalized async-op contract** (queue-triggering endpoints): **202 Accepted** = work queued; **404
|
||
ProblemDetails** = entity missing (controller pre-check); **409 ProblemDetails** = lock held (the running
|
||
job, or a mutation racing it — §3a/§3b); **422 ProblemDetails** = domain precondition (sync disabled /
|
||
unsupported / start failed). Trakt was the reference implementation. Changes made:
|
||
- `MaintenanceController.EmptyTrash` — error path **500 text/plain → 404/422 ProblemDetails** (`ToErrorResult`).
|
||
- `MaintenanceController.CleanArtwork` — silent **200 → 202** (fire-and-forget enqueue). No SPA consumer.
|
||
- `LibrariesController.ScanShow` — conflated **400 `{error}` → 202/404/409/422** via a new
|
||
`QueueShowScanResult` enum (6 outcomes incl. an honest `ScanFailed`→422, distinct from `Unsupported`).
|
||
- `ChannelController.ResetPlayout` — **200 → 202** (queue-triggering; 404/409 unchanged).
|
||
- `PlayoutController.ResetAll` — **202 (no body) → 202 + `ResetAllPlayoutsResponseModel`** reporting
|
||
`queuedPlayoutIds` / `skippedLocked` / `skippedUnsupported` (replaces the silent skip; still 202, still
|
||
skips locked/ExternalJson by design per §3a — now it *reports* what it skipped).
|
||
- `TroubleshootController.TroubleshootPlayback` — bare body-less `NotFound()` → **404/422 ProblemDetails**
|
||
with distinguishing detail. **Status codes the SPA HLS player depends on were preserved** — verified
|
||
`HlsPlayer.tsx` never branches on this endpoint's status (playback state comes from the separate
|
||
`/api/troubleshoot/playback/status` poll); only the error *body* was enriched.
|
||
|
||
**Playout build observability**: the list endpoint (`GET /api/playouts`) already stamped `isLocked` +
|
||
`BuildStatus` on `PlayoutListItemResponseModel` (#215); this issue adds **`isLocked` to the single-playout
|
||
`GET /api/playouts/{id}`** (`PlayoutResponseModel`), so the detail poll surface carries the §3a lock flag
|
||
too. No dedicated `GET /api/playouts/{id}/status` push channel was added — the flag on the existing GETs is
|
||
the HTTP-observable substitute for Blazor's live lock event, matching the `GET /api/trakt/status` precedent.
|
||
|
||
**F9 parity endpoints** (the `Libraries.razor` deletion gate — #202 did NOT close these):
|
||
- **Deep scan**: `POST /api/libraries/{id}/scan` gains `?deep=false`, threaded through
|
||
`QueueLibraryScanByLibraryId(LibraryId, DeepScan=false)` into `ForceSynchronize{Plex,Jellyfin,Emby}LibraryById(id, deep)`
|
||
(was hardcoded `false`). Non-breaking: existing callers omit it.
|
||
- **External-collections scan**: new `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false`
|
||
on the three #202 media-source controllers, dispatching `Synchronize{X}Collections(id, ForceScan:true, deep)`.
|
||
Each pre-checks source existence (404), acquires the per-source **collections** lock (`Lock{X}Collections()` —
|
||
the lock *is* the running scan, so a false = **409**), then enqueues and returns 202; the controller
|
||
compensating-unlocks in a `catch` if the enqueue throws (§3b), and `ScannerService` releases in its `finally`.
|
||
Thin SPA clients shipped (`scanLibrary(id, deep)`, `scanCollections`); **the SPA deep-scan / collections
|
||
buttons are the removal PR's remaining parity work** (parity doc §5).
|
||
|
||
**F7 Trakt batch-lock leak fix**: the global Trakt lock was released only when the *terminal* batch message
|
||
(`Unlock: true`) was processed; a `WorkerService` shutdown/cancellation before that message leaked the lock
|
||
permanently (subsequent Trakt ops 409 until restart — same class as #231/#233/#234). Fix: `WorkerService`
|
||
now releases the Trakt lock in a `finally` on read-loop exit if still held. Non-vacuous regression test proven
|
||
against an inverted-condition control.
|
||
|
||
**Accepted-by-design** (per the issue's decision-record ask): the worker's channels are **unbounded** and
|
||
there is **no shutdown drain** — messages still queued at process exit are dropped. This is acceptable because
|
||
the entity locks are **in-memory singletons that die with the process**, so a dropped message can't strand a
|
||
lock across restarts (the F7 `finally` covers the *within-process* shutdown-break leak, which is the only way
|
||
a lock outlives its batch while the process keeps running). Adding a bounded-channel backpressure / graceful
|
||
drain is out of scope and would not fix a correctness bug.
|
||
|
||
## 2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)
|
||
`key: api.postcommit-cancellation-none` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Once a mutation commits, its entire compensating side effect (enqueues, publishes, reindexes, cache refresh, and any post-commit lookup gating one of those) runs on `CancellationToken.None` so a late client disconnect can't half-abort an already-committed change.
|
||
**Signals:** `WriteAsync` enqueue, `mediator.Publish`, search-index reindex · paths: `docs/api-conventions.md` §7b, `MediaCollections/`, `ProgramSchedules/`, `Playouts/`, `Channels/` handlers · issues: #251, #254, adversarial-reviewer#22
|
||
**Mechanics:** `docs/api-conventions.md` §7b
|
||
|
||
Audit #22 (adversarial-reviewer) found ~20 command handlers threading the request `cancellationToken`
|
||
into work that runs **after** `SaveChangesAsync` commits — the post-commit `WriteAsync` enqueue that
|
||
rebuilds/refreshes the affected entity, `mediator.Publish`, search-index reindex, cache refresh. A late
|
||
HTTP-client disconnect cancels that token, so the *already-committed* mutation throws on the way out
|
||
**and silently drops its side effect** (the playout rebuild is never queued → the persisted edit never
|
||
takes visible effect until a manual Reset). #251 fixed this for the deco handlers; #254 generalizes the
|
||
policy across the codebase.
|
||
|
||
**Decision.** Once a mutation has committed, the *entire* compensating side effect — enqueues, publishes,
|
||
reindexes, cache refreshes, and any post-commit lookup that **gates** one of those enqueues — runs on
|
||
`CancellationToken.None`. The commit is the point of no return; past it the side effect must not be
|
||
half-abortable. Full convention + the two boundaries in `docs/api-conventions.md` §7b.
|
||
|
||
**Scope of the #254 sweep (this PR).** Swept the single-`SaveChanges` handlers under `MediaCollections/`,
|
||
`ProgramSchedules/`, `Playouts/`, `Channels/` (20 handlers). Deliberately **excluded**:
|
||
- **`BuildPlayoutHandler`** — a background/worker handler; its token is the worker shutdown token, not a
|
||
client-disconnect token, so its downstream enqueues *correctly* honor cancellation.
|
||
- **`UpdateFFmpegSettingsHandler` + the two `Configuration/` settings handlers** — they commit via several
|
||
sequential `IConfigElementRepository.Upsert` calls with an interleaved enqueue; "when is it committed"
|
||
is a partial-commit-under-cancellation question broader than the clean single-`SaveChanges` F4 pattern.
|
||
Left for a separate follow-up.
|
||
- **Response-projection reloads** (`ReplaceProgramScheduleItemsHandler` / `AddProgramScheduleItemHandler`
|
||
post-commit graph reload that builds the *returned* view model) keep the request token — a cancelled
|
||
response after a durable commit + `None`-enqueue loses nothing.
|
||
- Handlers a no-token `WriteAsync()` already makes behaviorally correct (`default` == `None`) were left
|
||
alone (explicit-`None` there is cosmetic).
|
||
|
||
Also folded in the two other #254 items on the same handlers: the channel-guide `{number}.xml` delete in
|
||
`DeleteChannelHandler`/`DeletePlayoutHandler` now routes through `IFileSystem.File.Delete` (observable
|
||
under `MockFileSystem`) **before** the commit (a post-commit delete orphans the xml on a crash; the guide
|
||
xml is regenerable on demand, so a pre-commit delete is the safe ordering), and
|
||
`ReplacePlayoutAlternateScheduleItemsHandler` now rejects an empty item list in the handler (not only at
|
||
the controller pre-guard) so a direct caller can't trip the `Max()`-on-empty crash.
|
||
|
||
**Coordination note for #253 PR2–PR4.** Those PRs add `Version++` (pre-commit) to the mutating handlers of
|
||
the versioned aggregates — several of which this sweep also touched (post-commit token, a different line
|
||
region). Low git-conflict risk, but merge `main` in and expect to see the `CancellationToken.None`
|
||
convention already present on the post-commit enqueues.
|
||
|
||
## 2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired
|
||
`key: scan.collections-scan-status` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout.
|
||
**Signals:** `CollectionsScanStatusResponseModel`, `useCollectionsScan`, `pruneGraceExpiredPending`, `COLLECTIONS_PENDING_TIMEOUT_MS` removed · paths: `MediaSourcesController.GetCollectionsScanStatus` · issues: #271
|
||
**Mechanics:** `GET /api/v1/media-sources/collections-scan-status`
|
||
|
||
**External Collections rows stay client-derived.** The SPA derives them from `GET
|
||
/api/v1/media-sources`: remote sources expose only sync-enabled libraries, so a non-empty `libraries` list is
|
||
equivalent to Blazor's `Libraries.Any(ShouldSyncItems)` filter. No second listing endpoint or fetch is needed.
|
||
|
||
The first scan-button implementation used a bounded optimistic timeout because collections locks had no HTTP
|
||
mirror. #271 replaces that temporary ceiling with the authoritative status contract below.
|
||
|
||
**Decision 1 — one family-global status endpoint, reading `IEntityLocker`.** New
|
||
`GET /api/v1/media-sources/collections-scan-status` (`MediaSourcesController` → `GetCollectionsScanStatus`
|
||
handler) returns one `CollectionsScanStatusResponseModel { family }` entry per media-source family
|
||
(`plex`/`jellyfin`/`emby`) whose collections lock is currently held, and only for active ones — the direct
|
||
counterpart to `GET /api/v1/libraries/scan-status`. It reads `IEntityLocker.Are{X}CollectionsLocked()` (the lock
|
||
*is* the running scan — the scan-collections controllers acquire it before enqueueing and the scanner releases
|
||
it on completion), analogous to how the library endpoint reads `IScannerProxyService.GetActiveScans()`. Two
|
||
deliberate shape differences from libraries: (a) **family-global, not per-source** — the collections lock takes
|
||
no source id (`LockPlexCollections()`), so an entry means *every* source of that family is scanning, matching
|
||
Blazor's all-rows-disabled behavior (per the PR #272 review note); (b) **no percent** — collections scans
|
||
expose only a boolean lock, not progress.
|
||
|
||
**Decision 2 — the SPA reconciles authoritatively; the fixed timeout is removed.** `useCollectionsScan` now
|
||
polls the new endpoint (seeding on mount, so a scan already running when the screen opens disables the buttons
|
||
immediately — the old timeout couldn't) and reconciles optimistic pending against the active-family set using
|
||
the **same `pruneGraceExpiredPending` grace-tick helper** the library hook uses (now generic over the pending
|
||
key type). A row shows "Scanning" when its family is in the active set **or** it has a still-in-grace optimistic
|
||
pending key. The grace window is kept (not the old wholesale timeout) to absorb the click→observed-active lag and
|
||
the fast-scan-between-polls race — the same bounded-pending discipline #232/#230 established for library scans.
|
||
`COLLECTIONS_PENDING_TIMEOUT_MS` is gone.
|
||
|
||
## 2026-07-11 — Blazor Server UI removed (#91 phase b)
|
||
`key: blazor.ui-removed` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The legacy Blazor Server UI (`Pages/`, `Shared/`, `ViewModels/`, `Validators/`, MudBlazor + 8 other packages, Blazor Startup wiring) is fully deleted now that the SPA has parity; the legacy `MapWhen` branch is kept only for controllers/docs/OpenAPI/`LegacyUiRedirects`, and the catch-all fallback 302s any unmatched non-api/artwork/docs/openapi path to `/app`.
|
||
**Signals:** `blazor-final` rollback tag, #206 auth-posture sign-off, forbidden-prefix guard lifted for `/media/sources` · paths: `ErsatzTV/Pages/**`, `ErsatzTV/Shared/**`, `ErsatzTV/ViewModels/**`, `ErsatzTV/Validators/**` · issues: #91, #145, #151, #152, #153, #155, #202, #207, #212, #213, #235, #204, #206, #25
|
||
**Mechanics:** `Startup.cs` MapFallback, `LegacyUiRedirects.cs`
|
||
|
||
The #91 phase (b) removal PR deletes the legacy Blazor Server UI now that the ChicoryTV SPA has parity
|
||
(all gates cleared: #145, #151/#152/#153/#155, #202, #207, #212/#213, #235 F9). The SPA is the only UI.
|
||
|
||
**Deleted.** `ErsatzTV/Pages/**` (all `.razor`, incl. `_Host.cshtml`, `FragmentNavigationBase.cs`,
|
||
`MultiSelectBase.cs`), `ErsatzTV/Shared/**` (all `.razor` + `_Favicons.cshtml`), `ErsatzTV/ViewModels/**`
|
||
(39 Blazor edit-form VMs), `ErsatzTV/Validators/**` (10 Blazor edit-VM FluentValidation validators),
|
||
`App.razor`, `_Imports.razor`, `ErsatzTV/Locals/Shared/**` + `ErsatzTV/Locals/Pages/**` (Blazor
|
||
localization resx — `ErsatzTV/Locals/Resources.*` is KEPT), `ErsatzTV/wwwroot/css/**` (site.css),
|
||
`ErsatzTV/wwwroot/lib/**` (jquery, jqueryui, sortablejs, hls, media-chrome, roboto), `libman.json`, and
|
||
`ErsatzTV.Tests/Pages/MultiSelectBaseTests.cs`.
|
||
|
||
**9 packages pruned** (from both `Directory.Packages.props` and `ErsatzTV/ErsatzTV.csproj`; each verified
|
||
to have zero remaining consumers after the Blazor deletion): **MudBlazor**, **Heron.MudCalendar**,
|
||
**Blazored.FluentValidation**, **BlazorSortable** — unambiguous Blazor UI; **MediatR.Courier.DependencyInjection**
|
||
— `ICourier` was consumed only by the deleted pages, and the app's `mediator.Publish` notification path is
|
||
plain MediatR (unaffected by the `AddCourier` removal); **Markdig**, **HtmlSanitizer**, **Chronic.Core**,
|
||
**NaturalSort.Extension** — verified zero non-Blazor consumers post-deletion.
|
||
|
||
**Startup surgical reduction.** Removed `AddRazorPages` (+`AuthorizeFolder("/")`), `AddServerSideBlazor`,
|
||
`AddMudServices`, `AddSortable`, `AddCourier`, the Blazor-attached `UseAuthentication`/`UseAuthorization`,
|
||
`MapBlazorHub`, and `MapFallbackToPage("/_Host")`. The former "blazor" `MapWhen` branch (lambda param
|
||
renamed `blazor`→`legacy`) is KEPT — it still co-hosts `MapControllers()`, `/docs` (Scalar), dev
|
||
`MapOpenApi()`, and the `LegacyUiRedirects` middleware. `MapFallbackToPage("/_Host")` is REPLACED by a
|
||
catch-all `endpoints.MapFallback(...)` that 302-redirects any unmatched path to `PathBase + "/app"`
|
||
EXCEPT paths under `/api`, `/artwork`, `/docs`, `/openapi` (those get a genuine 404, per #204's design).
|
||
**KEPT** (not removed): the OIDC/JWT/API-key SERVICE registrations (inert unless configured; real auth is
|
||
#197), `ConditionalIptvAuthorizeFilter` (`/iptv/*`), and `ApiKeyAuthorizationFilter` (mutating `/api/*`) —
|
||
per the #206 auth-posture sign-off (deleting the Blazor page challenged nothing beyond phase (a)).
|
||
|
||
**LegacyUiRedirects.** Added redirects for all 14 `/media/sources/*` routes → their `/app/libraries/*`
|
||
SPA screens (7 Tier-1 exact + 7 Tier-2 `{id}` patterns; the last Section-2 rows in
|
||
`blazor-route-parity.md`), and LIFTED the #204-era `/media/sources` forbidden-prefix guard (its Blazor
|
||
pages were replaced by #202's SPA screens). The forbidden-prefix guard now covers only `/api`, `/artwork`,
|
||
`/docs`, `/openapi`, `/iptv`, `/app`.
|
||
|
||
Also removed the now-dead ersatztv#25 razor-Sonar `<NoWarn>S6966;S3267;…</NoWarn>` line from
|
||
`ErsatzTV.csproj` — those Sonar rules only needed suppression in `.razor` `@code`; on `.cs` they run at
|
||
`suggestion` via `.editorconfig`, so removal is safe (closes part of #25's burn-down).
|
||
|
||
**Rollback.** The tag `blazor-final` was cut on the pre-removal `main` commit as the first step (see the
|
||
2026-07-11 "Pre-removal Blazor rollback tag `blazor-final` (#205)" entry above for the exact command +
|
||
restore path). Not a `v*` tag → no prod release build.
|
||
|
||
## 2026-07-12 — Live-E2E is a required step for API write-path handler changes (#303)
|
||
`key: release.live-e2e-required` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A PR that changes an API write-path handler must include a live-E2E pass (driving the real endpoint/screen and confirming the round-trip through a subsequent read), not only unit/characterization tests, and must state whether live-E2E ran or wasn't required.
|
||
**Signals:** lazy LanguageExt `Map` blind spot, #229 finding · paths: `docs/e2e-local.md`, `docs/api-conventions.md` §7 · issues: #303, #229
|
||
**Mechanics:** `docs/e2e-local.md`
|
||
|
||
**A PR that changes an API write-path handler (a `POST`/`PUT`/`DELETE` `/api/*` command that mutates
|
||
state and reloads it through the read path) MUST include a live-E2E pass** — driving the real endpoint
|
||
or its SPA screen and confirming the mutation round-trips through a subsequent read — not only unit /
|
||
characterization tests. Rationale: this class has a **correlated blind spot** unit and characterization
|
||
tests share. A green fixed-point test passed while the write-path returned a production 500 because the
|
||
handler returned a *lazy* LanguageExt `Map` the test never enumerated (#229; reload-through-read-path
|
||
mechanics in `api-conventions.md` §7); the failure surfaces only when the result is materialised, which
|
||
the SPA does and the test did not. Live driving is the only reliable net for it.
|
||
|
||
Non-write-path (pure-SPA/read-only) and docs PRs don't need it. The requirement is auditable, not
|
||
silent: the PR/close comment states that live-E2E ran, or — for a non-write-path change — that it
|
||
wasn't required (the same stated-exemption discipline as the review skip rubric). Recipe +
|
||
"When live-E2E is required": `docs/e2e-local.md`. This formalizes the #229 lore bullet ("live E2E
|
||
remains the only net for this class") into a standing convention.
|
||
|
||
## 2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)
|
||
`key: spa.topbar-primary-action` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button.
|
||
**Signals:** `usePrimaryAction` hook, `ctv:primary-action` event, dead-button class · paths: `web/src/primaryAction.ts`, `docs/spa-conventions.md` §10 · issues: #238, #229, #247, #295
|
||
**Mechanics:** `web/src/primaryAction.ts`, `docs/spa-conventions.md` §10
|
||
|
||
The shell TopBar rendered a prominent top-right primary-action button (Plus icon) for **every** screen, but
|
||
only `SchedulesScreen` had ever subscribed to its `ctv:primary-action` event — so on every other screen the
|
||
button was **dead** (either a labelled no-op like "Save Changes"/"Add Channel", or, for the ~10 routes whose
|
||
`primaryAction` was `''`, a bare labelless "+"; the button rendered unconditionally). Issue #238 (from a #229
|
||
live-E2E finding, pre-existing since the TopBar's introduction).
|
||
|
||
**Decision — the Plus-icon button is a "create new item" affordance; keep+wire it only where that fits:**
|
||
|
||
- **TopBar renders the button only when the active route declares a non-empty `primaryAction`** (was:
|
||
unconditional). This alone removes every empty-`''` dead "+".
|
||
- **WIRE** (8 list screens with a single unambiguous create flow) via a shared `usePrimaryAction(routeId,
|
||
handler)` hook (`web/src/primaryAction.ts`), each delegating to the same top-level create handler its in-body
|
||
control uses (`navigateToPath('/app/new-channel')`, `setEditing({kind:'new'})`, `navigateToPath('…/add')`,
|
||
etc.): channels, schedules (refactored onto the hook), multiCollections, rerunCollections, traktLists,
|
||
fillerPresets, ffmpegProfiles, watermarks.
|
||
- **DROP** (`primaryAction: ''`, no button) everywhere else, for one of four reasons: (1) the action isn't a
|
||
create so the "+" is wrong and a correct in-body control already exists — editChannel (savebar), settings
|
||
(savebar), guide (Jump to now), playouts (Reset all), logs/troubleshooting/blockPlayoutTroubleshooting
|
||
(Refresh), playbackTroubleshooting (Play), yamlValidator (Validate); (2) ambiguous — collections (two create
|
||
types behind tabs); (3) a silent no-op — builder ("Create Channel" is disabled until the form is valid),
|
||
playlists (needs a group first); (4) semantically misplaced — dashboard (status page), libraries ("Scan" is
|
||
per-library-row, no global target).
|
||
|
||
**Why not wire everything** (the SchedulesScreen precedent): the recon found every screen already carries a
|
||
correct, disabled-state-aware, context-aware in-body control, and several banner actions are unreachable
|
||
without refactoring handlers out from under early returns, or would render a silent no-op — reintroducing the
|
||
very dead-button class #238 fixes. Wiring only the single-create-flow screens gives one explainable rule
|
||
("primary button = create a new item on a list screen") and needs no risky refactors.
|
||
|
||
**Also fixed here:** the `apiKey` route's `primaryAction: 'Save key'` + its description/comment were stale
|
||
post-#295 (the screen displays the machine key + changes the local password; there is nothing to "save") —
|
||
relabelled to reflect the #295 reality. Convention + hook documented in `spa-conventions.md` §10. The
|
||
implicit route-label ⟺ screen-subscription coupling is the kind of thing #247 (shell extraction) will
|
||
formalize; #238 keeps it a documented convention guarded by a data-driven `App.test.tsx` test over the URL-navigating
|
||
create screens (a typo'd route id → the banner navigates nowhere → red). Refs #238 #247.
|
||
|
||
## 2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)
|
||
`key: api.versioning-v1` · `status: active` · `since: 2026-07-13` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The entire `/api` surface is versioned to `/api/v1` uniformly (no unversioned corner); legacy unversioned callers are rewritten in-pipeline (not redirected) with Deprecation/Link/Sunset headers, and post-freeze `/api/v1` is additive-only — a breaking change requires `/api/v2`.
|
||
**Signals:** `ApiVersionRewriteMiddleware`, `ApiRouteVersioningTests`, RFC 8594 Deprecation header · paths: `docs/api-conventions.md` §1/§9 · issues: #286, #197
|
||
**Mechanics:** `ApiRouteVersioningTests`, `docs/api-conventions.md` §1/§9
|
||
|
||
The #197 cold review's C1 **BLOCKER**: `/api/*` was entirely unversioned (`info.version` was cosmetic), so the
|
||
first breaking change would silently break the SPA and any external/MCP client with no negotiation path. This is
|
||
the Phase-2 contract-freeze gate — versioning can't be added compatibly *after* the contract ossifies, so it
|
||
lands before freeze.
|
||
|
||
**What changed.** Every route under `/api` was swept to `/api/v1` — all 251 controller route attributes, the ~24
|
||
`Location`-header literals, the scanner callback URL (`CallLibraryScannerHandler.GetBaseUrl`), and the
|
||
`Startup` request-log path literal. This is **uniform**: the machine JSON API, the browser-session auth surface
|
||
(`/api/v1/auth/*`, still `IgnoreApi`), the internal loopback callbacks (`/api/v1/scan/*`) and the scripted-build
|
||
surface (`/api/v1/scripted/*`) are all versioned, so there is no unversioned corner and the compat rewrite needs
|
||
no exclusion list. The OpenAPI `v1.json` (160 paths), `endpoint-index.md`, and the SPA (945 request literals +
|
||
its test mocks, incl. regex/positional URL parsers) were regenerated/swept in lockstep. **No wire-DTO or
|
||
status-code change** — only the path prefix moved.
|
||
|
||
**Legacy compat = an in-pipeline rewrite, NOT a redirect** (`ApiVersionRewriteMiddleware`, sequenced before
|
||
`UseRouting` in the API branch). A legacy caller hitting an unversioned `/api/foo` has its request *path*
|
||
rewritten to `/api/v1/foo` and continues in-pipeline — method, body, auth headers and query string all survive,
|
||
so curl / the future MCP server / bookmarked URLs keep working with no round-trip (a 307/308 redirect would have
|
||
been fragile for non-GET + custom-header clients). Rewritten (legacy) responses carry RFC 8594 `Deprecation: true`
|
||
+ `Link: </docs>; rel="deprecation"`, and a `Sunset` header when `Api:LegacyRoutesSunset` is configured. An
|
||
already-versioned path (`/api/v1/*`) passes through untouched; a future `/api/v2/*` is **not** forced back to v1
|
||
(the middleware only fills in a *missing* version).
|
||
|
||
**Freeze semantics (owner decisions):** once shipped, `/api/v1` is **additive-only** — new endpoints/optional
|
||
fields are fine; renaming/removing/retyping an existing one requires a new `/api/v2`, never an in-place break.
|
||
The legacy-rewrite compat shim has a **2-release sunset window** (owner-chosen) before removal; the actual removal
|
||
is a tracked Phase-3 follow-up, not this PR. Existing pre-freeze warts (e.g. channel `{id}` vs `{channelNumber}`,
|
||
the synthesized negative-id "(none)" group rows) are frozen as-is per their own prior decisions.
|
||
|
||
**Route-convention standardization (#286, owner-requested).** The leading-slash inconsistency (238 absolute
|
||
`"/api/…"` method routes vs 13 relative `"api/…"`) is resolved: the standard is a **leading-slash absolute route
|
||
on each method's `[Http*]` attribute, no class-level `[Route]`** — except the two controllers where many actions
|
||
share a parametrized prefix (`ScannerController` `{scanId}`, `ScriptedScheduleController` `{buildId}`, ~40
|
||
methods), which keep a leading-slash absolute **class** `[Route("/api/v1/…")]` with relative method segments (the
|
||
right tool for a shared prefix). Enforced by `ApiRouteVersioningTests`: it reflects over every `[ApiController]`
|
||
in `Controllers.Api`, computes each action's *effective* route (ASP.NET's class+method combination rule), and
|
||
asserts it matches `^/api/v\d+/` — so a new controller that drifts (relative or unversioned) fails CI, the
|
||
"fix-it-while-you're-in-the-file" gate the `dotnet format` rules use. Browser-nav endpoints deliberately outside
|
||
`/api` (e.g. `GET /auth/oidc/login`) are out of scope for the test. Docs: `api-conventions.md` §1/§9. Refs #286 #197.
|
||
|
||
## 2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)
|
||
`key: api.scheduling-hardening` · `status: active` · `since: 2026-07-13` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Create/Replace handlers guard against null/whitespace `name` (`IsNullOrWhiteSpace`, not just `Length`) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 `ProducesResponseType` attributes on create-only actions are trimmed.
|
||
**Signals:** `BlockTemplateItem` record value-equality bypass, `api-conventions.md` §3b handler-hardening checklist · paths: `ReplaceTemplateItemsHandler`, `api-conventions.md` §3b · issues: #172, #144
|
||
**Mechanics:** `docs/api-conventions.md` §3b
|
||
|
||
Cleared the still-live findings from issue #172 (consolidated non-blocking nits from the #144 S1/S2
|
||
reviews). Most of the 2026-07-07 list had already been ratified deliberate (§8 "(none)" synthesized
|
||
rows; §3b deep-FK non-existence-check) or fixed since (the unauthenticated `/api/logs` +
|
||
`/api/troubleshoot/info` GETs now carry `[RequiresAuthentication]` per §9; the Trakt matched-items link
|
||
points at the live `/app/search`; `GET /api/search` already fans out via `Task.WhenAll`). Three were
|
||
genuinely live:
|
||
|
||
- **Null/empty `name` → 500 (10 handlers).** Create + Replace/Update handlers for Block, Template,
|
||
DecoTemplate, Deco (8, all genuine 500s), plus `UpdateFFmpegProfile` (genuine 500; `CreateFFmpegProfile`
|
||
was already guarded) and `CreatePlaylist` (its DTO coalesces `null`→`""`, so an empty-name persist, not a
|
||
500) all did `if (request.Name.Length > 50)` on a client-nullable `string Name` → unhandled
|
||
`NullReferenceException`. Fixed to `if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length >
|
||
50)` — kills the NRE, and also rejects empty/whitespace names (matching the group-create handlers'
|
||
`NotEmpty` behavior, closing a latent "block/template named ''" gap). Chose the one-line guard over
|
||
refactoring each handler onto the `NotEmpty`/`NotLongerThan` combinator to keep the blast radius tiny and
|
||
preserve each handler's existing error message + 422 mapping. Convention captured in `api-conventions.md`
|
||
§3b handler-hardening checklist.
|
||
- **Exact-duplicate template items bypassed overlap validation.** `ReplaceTemplateItemsHandler`'s O(n²)
|
||
overlap loop skipped on `item == otherItem`, but `BlockTemplateItem` is a `record`, so two value-identical
|
||
items (same BlockId + StartTime → same computed EndTime) were value-equal and skipped — both persisted
|
||
unvalidated. Switched to index-based iteration (`i != j`) so identical items at distinct positions are
|
||
compared and register as a (self-)intersection → rejected 422. (The SPA's index-based check already caught
|
||
this client-side; it was an API-only gap.)
|
||
- **Unreachable 404 on create-group actions.** `POST /api/blocks/groups` and `POST /api/templates/groups`
|
||
declared `[ProducesResponseType(ProblemDetails, 404)]` copied from precedent, but a create has no parent
|
||
lookup that can 404 (only 201/422). Trimmed — OpenAPI spec regenerated.
|
||
|
||
Deliberately **not** fixed (documented as accepted): the §8 "(none)" synthetic rows, the §3b deep-FK
|
||
non-existence-check, and the missing `Name=` on `PlayoutController` Create/Delete/Update (moot — the
|
||
"v1"-doc `OperationIdOpenApiTransformer` (#197 Bundle C) already synthesizes stable operationIds for
|
||
`Name=`-less ops, and adding `Name=` would risk renaming generated SPA client methods). Refs #172 #197.
|
||
|
||
## 2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)
|
||
`key: ci.functional-e2e-harness` · `status: active` · `since: 2026-07-16` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs curl-only, deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check.
|
||
**Signals:** staged rollout precedent (`migrations` job), racy/interactive flows deferred · paths: `scripts/e2e-local.sh`, `scripts/e2e-functional.sh` · issues: #299
|
||
**Mechanics:** `scripts/e2e-functional.sh`
|
||
|
||
The manual live-E2E curl flows sessions had been re-running by hand (and leaving only as PR/issue
|
||
comments) are now a CI regression net. Two decisions shaped it:
|
||
|
||
**Boot from source + `dotnet run`, not the built image.** The only "E2E" in CI before this was the
|
||
smoke test in the `build` job, which runs against the *pushed* image — so it exists only on `main`/`v*`
|
||
(the image isn't built on PRs) and would test a stale image, not the PR's code. To gate PRs on the PR's
|
||
own code, the `functional-e2e` job builds the SPA + solution and launches `dotnet ErsatzTV.dll` via the
|
||
same `scripts/e2e-local.sh` used locally (parameterized with `ETV_BUILD_CONFIG=Release`). The assertions
|
||
live in `scripts/e2e-functional.sh`, so the identical harness runs by hand and in CI — which is the
|
||
point of the issue (stop re-deriving the flows each session).
|
||
|
||
**Advisory, not blocking — separate job, not a `build` dependency, not a required check.** Per the
|
||
issue's "a functional-E2E flake must not block the unit-test gate." A boot-the-app job has more moving
|
||
parts (background process, port, readiness wait) than a pure unit test, so it starts advisory and gets
|
||
promoted to a required check / `build` dependency once proven reliable — the same staged rollout the
|
||
`migrations` job used. SQLite is the default provider, so it needs no DB service container.
|
||
|
||
**Scope is curl-only and deterministic; the racy/interactive flows are explicitly deferred.** The first
|
||
cut asserts the legacy→SPA redirect sweep (+ `/api`/`/artwork` never-redirect exemption), the
|
||
auth/CSRF/security-stamp flow, the library-scan status contract (404/202/`scan-status`), and the
|
||
`If-Match`/412 round-trip — all exercisable without seeded media, ffmpeg-transcode, or a browser (an
|
||
empty local library still enqueues `202`; an empty collection drives the concurrency editor). The 409
|
||
"already-scanning" re-trigger (needs a long-running scan to be non-racy), the playout-build lock 409,
|
||
and the genuinely UI-interactive Playwright flows are deferred as #299 follow-ups rather than shipped
|
||
flaky. Assertions were written against a real running instance, not the source — which caught that
|
||
`/artwork/*` returns `400` (not the `404` a static read suggested); extend the harness the same way.
|
||
|
||
## 2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)
|
||
`key: iptv.base-url` · `status: active` · `since: 2026-07-16` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** An optional advertised base URL (`iptv.base_url`) is resolved centrally via a pure Core helper (`AdvertisedBaseUrl`) inside the two IPTV generation handlers (M3U + XMLTV); unset/malformed values fall back byte-identical to the request-derived host, and it's a new `iptv` settings group distinct from `ETV_BASE_URL` and out of scope for HDHomeRun.
|
||
**Signals:** `AdvertisedBaseUrl.TryParse`/`Resolve`, `ConfigElementKey.IptvBaseUrl`, request-derived-host symptom (Gitea #1) · paths: `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `docs/m3u-xmltv.md` · issues: #340
|
||
**Mechanics:** `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `GET`/`PUT /api/v1/settings/iptv`
|
||
|
||
ErsatzTV's absolute IPTV URLs (M3U stream/logo/guide URLs, XMLTV `<icon>`/artwork) were always
|
||
derived from the incoming request's `Scheme`/`Host`/`PathBase`, so any client that fetched with a
|
||
host downstream consumers can't resolve (the historical Gitea #1 `localhost:8409` symptom) baked
|
||
that host into the output. #340 adds an **optional** advertised base URL to pin those URLs to a
|
||
fixed public origin. Several deliberate decisions shaped it:
|
||
|
||
**(a) Resolve the override centrally in the two generation handlers, via a pure Core helper — not
|
||
in the controller.** A new `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs` exposes `TryParse(string) →
|
||
Option<(scheme, host, baseUrl)>` and `Resolve(configured, requestScheme, requestHost,
|
||
requestBaseUrl)`. `GetChannelPlaylistHandler` (M3U, via `ChannelPlaylist`) and
|
||
`GetChannelGuideHandler` (both XMLTV `{RequestBase}` substitution sites) call `Resolve` and use its
|
||
result instead of the raw request values. Keeping the logic in a pure, allocation-free Core helper
|
||
(not the thin controller) keeps controllers dumb, makes the parse/validate/resolve rules unit-testable
|
||
in isolation, and — because the fallback path returns the exact request-derived values — leaves the
|
||
M3U/XMLTV golden tests (`ChannelPlaylistGoldenTests`, `ChannelGuideGoldenTests`) untouched.
|
||
|
||
**(b) Validation rules, with blank/invalid → request-derived so "unset" is byte-identical.**
|
||
`TryParse` accepts only an **absolute http(s)** URL with **no credentials, query, or fragment**; it
|
||
**preserves the port and any path prefix** and **normalizes a trailing slash** off. Anything failing
|
||
these rules → `None`, and `Resolve` then falls back to the request-derived scheme/host/base. So an
|
||
unset or malformed value produces output byte-for-byte identical to today's request-derived behaviour
|
||
— the override is strictly opt-in and can never silently corrupt the default path.
|
||
|
||
**(c) A NEW `iptv` settings group, not folded under `xmltv`.** The base URL affects **both** the M3U
|
||
playlist and the XMLTV guide, so it does not belong under the existing XMLTV-only settings. `GET`/`PUT
|
||
/api/v1/settings/iptv` on `SettingsController` (tier `[RequiresAuthentication]`) with body `{ baseUrl }`,
|
||
backed by `GetIptvSettings`/`UpdateIptvSettings` handlers + `IptvSettingsViewModel` /
|
||
`IptvSettingsResponseModel` / `UpdateIptvSettingsRequest`, and a new "IPTV" section on the SPA Settings
|
||
screen. Follows the existing settings GET/PUT pattern — no new API convention.
|
||
|
||
**(d) Blank clears the key; non-blank malformed → 422.** A blank/whitespace `baseUrl` on PUT
|
||
**deletes** the `ConfigElement` (reverting to request-derived); a non-blank value that fails
|
||
`AdvertisedBaseUrl.TryParse` is rejected with **422** rather than being silently stored and ignored at
|
||
generation time — the error surfaces at the point of configuration.
|
||
|
||
**(e) Scoped to M3U + XMLTV, deliberately NOT HDHomeRun.** #340 covers only the M3U playlist and XMLTV
|
||
guide generators. The HDHomeRun lineup URLs still echo the request host; extending the override there
|
||
was explicitly out of scope.
|
||
|
||
**(f) Distinct from `ETV_BASE_URL`.** The `ETV_BASE_URL` environment variable only sets the ASP.NET
|
||
Core `PathBase` (request routing/prefix); it does not advertise a scheme+host. `iptv.base_url` is the
|
||
separate, DB-stored (`ConfigElementKey.IptvBaseUrl`, **no EF migration**) advertised origin for IPTV
|
||
output. See `docs/m3u-xmltv.md` → "IPTV base URL (#340)".
|
||
|
||
## 2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)
|
||
`key: sched.auto-tune-foundation` · `status: active` · `since: 2026-07-16` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Auto-tune preview enumeration uses EF distinct+count queries for exact counts, while each created channel is persisted as a live SmartCollection; coexistence with existing channels/numbers is additive-only, never mutating.
|
||
**Signals:** auto-tuning, SmartCollection, additive coexistence, channel numbering · paths: `CreateChannelFromLineup` · issues: #69, #63
|
||
**Mechanics:** `PreviewAutoTuneChannelsHandler`, `AutoTuneAxisMap`, `ISender`-based `CreateChannelFromLineup` primitive
|
||
|
||
Auto-tuning (#69) generates channels from library metadata (TV Show / TV Genre / Movie Genre).
|
||
Enumeration for the preview uses EF distinct+count queries (exact counts drive the min-items
|
||
threshold and preview display); each created channel is backed by a newly-created **SmartCollection**
|
||
(live Lucene query) so channels keep tracking the library as it grows. Query authorship is
|
||
server-side only — the client passes `{axis, value}`, never a Lucene string. Coexistence is additive:
|
||
the batch gets a reserved starting channel number (skipping taken numbers), a proposed channel whose
|
||
name already exists is flagged and de-selected by default, and existing channels are never mutated.
|
||
Bulk create loops the #63 `CreateChannelFromLineup` primitive via `ISender` and returns a per-channel
|
||
Created/Skipped/Failed outcome. Known MVP limitation: the generated SmartCollection is named after the
|
||
channel; a name collision with an existing SmartCollection surfaces as a per-channel Failed outcome.
|
||
|
||
## 2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)
|
||
`key: sched.reshuffle-scoped-reset` · `status: active` · `since: 2026-07-16` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `POST /api/v1/playouts/{id}/reshuffle` runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears anchors/rerun-history) then enqueues a scoped `Reset` build, so reshuffle always reseeds — even for the non-Classic kinds `Reset` alone wouldn't reseed; `Playout.Seed` is surfaced on list/detail DTOs as visible confirmation.
|
||
**Signals:** reshuffle, Playout.Seed, ErasePlayoutHistory, CollectionEnumeratorState · paths: `PlayoutBuilder` · issues: #71
|
||
**Mechanics:** `POST /api/v1/playouts/{id}/reshuffle`; `ErasePlayoutHistory`; `PlayoutBuildMode.Reset`
|
||
|
||
- The shuffle seed (`Playout.Seed`) + per-collection `CollectionEnumeratorState` already persist a stable
|
||
shuffled order across rebuilds. #71's real gap was a **user-triggered per-playout reshuffle** (a Classic
|
||
playout's seed is otherwise only reseeded by a full Reset, and `reset-all` uses `Refresh` for Classic, so
|
||
it never reseeds Classic) plus visibility.
|
||
- `POST /api/v1/playouts/{id}/reshuffle` first runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears
|
||
anchors/rerun-history) and then enqueues `BuildPlayout(id, PlayoutBuildMode.Reset)` to rebuild. `Reset`
|
||
alone only reseeds the seed for **Classic** playouts (`PlayoutBuilder`); Block/Sequential/Scripted rebuild
|
||
deterministically from the existing seed, so `ErasePlayoutHistory` is the one primitive that reseeds +
|
||
clears the derived per-collection enumerator state for all four resettable kinds — reshuffle runs it
|
||
first so the reshuffle is never a no-op for non-Classic playouts. Named `/reshuffle` (not `/reset`) to (a)
|
||
match user intent and (b) avoid the "reset-one reseeds Classic while reset-all refreshes Classic" naming
|
||
clash. It is deliberately more aggressive than `reset-all` for Classic: an explicit single-channel action
|
||
rolls a new order; the bulk action stays non-disruptive.
|
||
- `Playout.Seed` is surfaced on the playout list + detail DTOs so the SPA can show it — its purpose is
|
||
**visible confirmation** (the seed changes after a reshuffle).
|
||
|
||
## 2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred
|
||
`key: sched.clock-padding-existing` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Clock-boundary padding already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and `pad_to_next`/`pad_until` (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic.
|
||
**Signals:** clock padding, FillerMode.Pad, pad_to_next, EPG guide-stop coalescing · paths: `PlayoutModeSchedulerBase.AddFiller`, `ChannelGuideProjector.ProjectFlood`, `/app/filler-presets` · issues: #77, #388
|
||
**Mechanics:** `PlayoutBuildGoldenTests.Classic_clock_padded`, `ChannelGuideProjectorClockPadTests`
|
||
|
||
#77 asked to "pad/snap channel schedules to clock boundaries (:00/:15/:30) using filler" — a Tunarr
|
||
"Pad Times" analogue for broadcast-looking guides. Investigation found the **core is already
|
||
implemented** (upstream ErsatzTV machinery carried into the fork), so #77 was scoped to *verification +
|
||
documentation* rather than a new feature:
|
||
|
||
- **Classic playouts**: a `FillerPreset` with `FillerMode.Pad` + `PadToNearestMinute = N` snaps each
|
||
content item up to the next N-minute clock mark, filling the gap with the preset's filler collection
|
||
(falling back to the item/channel fallback filler). The boundary math is a ceiling-to-the-next-multiple
|
||
on the local minute with seconds zeroed (`PlayoutModeSchedulerBase.AddFiller`, the `FillerMode.Pad`
|
||
block). Live SPA editor at `/app/filler-presets` (increment options 5/10/15/30).
|
||
- **Sequential (YAML) playouts**: the `pad_to_next: N` and `pad_until: "HH:mm"` instructions do the same,
|
||
reusing the identical boundary formula.
|
||
- **Block playouts**: no per-item Pad, and none is needed — Block is inherently clock-anchored (each
|
||
`Block` starts at a fixed `TemplateItem.StartTime`), so it already yields clean guide times.
|
||
- **EPG reflects the padding with no extra code**: `ChannelGuideProjector.ProjectFlood` coalesces trailing
|
||
PostRoll/Tail/GuideMode/Fallback/DecoDefault filler into the programme window, so a programme's `Stop` is
|
||
the padded boundary (the filler run's finish), not the raw content finish.
|
||
|
||
**What this decision fixes in the record:** the issue's premise ("reverses an earlier not-adopting call")
|
||
predates the machinery; do not re-implement pad-to-boundary from scratch. Two deterministic tests lock the
|
||
behaviour end-to-end (chosen over a one-off live-E2E because #77 changed **no production code**, so the
|
||
live-E2E's write-path/500 justification does not apply): `PlayoutBuildGoldenTests.Classic_clock_padded`
|
||
(schedule → `PlayoutItem`s land on :15 through the real `PlayoutBuilder`) and
|
||
`ChannelGuideProjectorClockPadTests` (those items → guide programmes stop on :15).
|
||
|
||
**Deferred (UI-gated):** the only real residual is *convenience* — there is no one-click per-channel /
|
||
per-schedule "clock-align this whole channel" toggle; today you hand-build a Pad `FillerPreset` and wire it
|
||
into a schedule item's roll slot. That toggle is mostly SPA work and is deferred to a follow-up **blocked on
|
||
the #388 design-system sync epic** (all UI work is currently gated on #388), which also covers adding a
|
||
60-minute increment to the filler-preset editor's options (the backend already accepts any integer).
|
||
|
||
## 2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380)
|
||
`key: sched.shuffle-source-builder` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Shuffle-source construction moves to a static, DI-free `ShuffleSourceBuilder` (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into `PlayoutBuilder` statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381.
|
||
**Signals:** ShuffleSourceBuilder, PlaybackOrder, cross-engine reach-in, god-factory rejection · paths: `ErsatzTV.Core/Scheduling/ShuffleSourceBuilder.cs`, `PlayoutBuilder`, `PlaylistEnumerator` · issues: #380, #163, #176, #70, #381
|
||
**Mechanics:** `ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle`/`GetCollectionItemsForShuffleInOrder`; `classic-shuffle` golden in `PlayoutBuildGoldenTests`
|
||
|
||
`PlaybackOrder` is turned into an enumerator at five independent sites (one per schedule kind, dispatched
|
||
by `BuildPlayoutHandler` on `ScheduleKind`): Classic (`PlayoutBuilder`), Block (`BlockPlayoutBuilder`,
|
||
`PlayoutHistory` rotation), Scripted (`SchedulingEngine.EnumeratorForContent`), Sequential/YAML
|
||
(`EnumeratorCache`), and the Playlist leaf (`PlaylistEnumerator.Create`, consumed by the others). These
|
||
are **not** accidental duplication — they map the same enum to different enumerator *families* keyed on
|
||
different state models (Block's history cursor vs the stateless-index `CollectionEnumeratorState`
|
||
enumerators). The real smell was one **cross-engine reach-in**: `PlaylistEnumerator` called
|
||
`PlayoutBuilder.GetGroupedMediaItemsForShuffle` / `GetCollectionItemsForShuffleInOrder` as **statics** —
|
||
one engine reaching into another engine's class.
|
||
|
||
- **Fix (a) shipped, not (b).** The two shuffle-source helpers moved verbatim to a new
|
||
`public static class ShuffleSourceBuilder` in `ErsatzTV.Core/Scheduling` (sibling to the also-static
|
||
`MultiCollectionGrouper` / `MultiPartEpisodeGrouper`; dependencies passed as parameters, **not** a DI
|
||
service — `PlaylistEnumerator.Create` is itself a static factory). Both Classic and Playlist now consume
|
||
it, so the reach-in is gone and shuffle-source construction lives in one directly-unit-tested place.
|
||
- **One intentional signature change.** `GetGroupedMediaItemsForShuffle` now takes
|
||
`bool keepMultiPartEpisodesTogether, bool treatCollectionsAsShows` instead of a `ProgramSchedule`
|
||
(verified those are the only two properties it read). This deletes `PlaylistEnumerator`'s fake
|
||
`new ProgramSchedule { KeepMultiPartEpisodesTogether = false }` (its TODO becomes an honest
|
||
`false, false`) and gives future callers with no `ProgramSchedule` (#176 PseudoTV, #70 distribution) a
|
||
schedule-entity-free entry point. Behavior is unchanged.
|
||
- **Rejected: a unified Classic+Playlist enumerator factory (b).** That would be the god-factory the issue
|
||
forbids — three concrete hazards: Classic's `Marathon` arm *produces* a `PlaylistEnumerator` (a factory
|
||
consumed by `PlaylistEnumerator.Create` that also builds one is a dependency cycle); Playlist hardcodes
|
||
`KeepMultiPartEpisodesTogether=false`/`randomStartPoint=false` where Classic reads schedule flags (a
|
||
merge silently changes behavior); and Classic's `CustomOrder`/`Rerun`/`MultiEpisodeShuffle`-template arms
|
||
gate on instance deps Playlist doesn't have. Engine separation is deliberately preserved.
|
||
- **Block / Scripted / YAML left alone.** Block is a distinct family; the Scripted≡YAML construction
|
||
duplication (`EnumeratorForContent` ≡ `EnumeratorCache.GetEnumerator`, both using the *different*
|
||
`BlockPlayoutShuffledMediaCollectionEnumerator` for shuffle) is a real but separate cleanup — deferred to
|
||
a follow-up gated on #381 (those paths have no golden coverage yet).
|
||
- **Net first, because #163's was too narrow.** `PlayoutBuildGoldenTests` only pinned
|
||
`PlaybackOrder.Chronological`; the moved shuffle paths (incl. the entire Playlist reach-in) were
|
||
uncovered, so "goldens green" would have been a false signal. The PR adds a `classic-shuffle` golden
|
||
(pinned `Playout.Seed` + `Continue` build, since `Reset` randomizes the seed) and a
|
||
`PlaylistEnumerator.Create` reach-in characterization (Shuffle + ShuffleInOrder items) **before** the
|
||
move, plus direct `ShuffleSourceBuilder` unit tests for the branches goldens don't reach
|
||
(multi-collection vs fake-multi-collection lookup; multi-part grouping on/off).
|
||
|
||
## 2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented
|
||
`key: sched.seasonal-scheduling-existing` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code.
|
||
**Signals:** alternate schedules, playout templates, date-conditional, catch-all-last evaluation · paths: `AlternateScheduleSelector.cs`, `PlayoutBuilder`, `EffectiveBlock`, `DecoSelector`, `PlayoutScheduleEditors.tsx` · issues: #73, #70
|
||
**Mechanics:** `AlternateScheduleSelectorTests`, `playoutTemplateCalendar.test.ts`; `channels.md` "Recipe: seasonal / holiday programming"
|
||
|
||
#73 asked for "seasonal / date-conditional channels & schedule rules" — a PseudoTV holiday-channel analogue
|
||
— on the stated premise that *"ErsatzTV has no native date-conditional scheduling today; you'd hand-build it
|
||
seasonally."* **That premise is false.** The feature is first-class, shipped, SPA-reachable, unit-tested and
|
||
already documented (upstream machinery carried into the fork). #73 is closed as already-implemented; the only
|
||
deliverable was the docs recipe below.
|
||
|
||
- **The predicate**: `IAlternateScheduleItem` (`DaysOfWeek`, `DaysOfMonth`, `MonthsOfYear`,
|
||
`LimitToDateRange`, `StartMonth/StartDay/StartYear?`, `EndMonth/EndDay/EndYear?`), implemented by
|
||
`ProgramScheduleAlternate` (Classic — picks the `ProgramSchedule` for a date) and
|
||
`Scheduling/PlayoutTemplate` (Block — picks the `Template` + optional `DecoTemplate` for a date).
|
||
- **The evaluator**: `AlternateScheduleSelector.GetScheduleForDate` — first match in `Index` order,
|
||
broadest/unconditional row last = catch-all. Wired into `PlayoutBuilder` (:572, :956), `EffectiveBlock`,
|
||
`BlockPlayoutFillerBuilder`, and `DecoSelector`. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31
|
||
clamped) are already handled; `AlternateScheduleSelectorTests` + `playoutTemplateCalendar.test.ts` pin it.
|
||
- **SPA**: `PlayoutScheduleEditors.tsx` ships the "Limit to date range" control for both editors, with a
|
||
per-date calendar preview (`playoutTemplateCalendar.ts`).
|
||
- **Nullable years are the seasonal switch** (`AlternateScheduleSelector.cs:32-40`): years default to the
|
||
*queried date's* year, so blank years = repeats every year. The override branch requires **both**
|
||
`StartYear` and `EndYear` (only one → silently still yearly), and explicit years force `reverse = false`,
|
||
disabling wrap-around. This was the single most load-bearing undocumented behaviour and is now in the docs.
|
||
- **Correcting the issue's Deco hypothesis**: the issue proposed "a Deco-like modifier". Decos carry **no**
|
||
dates — `DecoTemplateItem.StartTime/EndTime` are time-of-day. The date dimension lives *exclusively* on the
|
||
`PlayoutTemplate` / `ProgramScheduleAlternate` join row. Composition is **PlayoutTemplate (date range) →
|
||
Template (time-of-day grid) → Block (content)**.
|
||
|
||
**Why closed rather than #77-style "re-scope to verify + document":** #77 had two real gaps (undocumented AND
|
||
untested). #73 has neither — tests and docs both already existed on main — so even the #77 re-scope target was
|
||
already met. Under the close-don't-park convention (same date), holding #73 open for a hypothetical future
|
||
build is precisely the parking that convention forbids.
|
||
|
||
**Rejected asks, and why:**
|
||
|
||
- **Per-schedule-item date predicate** (the issue's literal ask; shipped design swaps the *whole* schedule per
|
||
date instead). Rejected as-designed: functionally equivalent for every seasonal use case — clone a schedule,
|
||
date-gate it, order it above the catch-all — at a cost of "clone a schedule". Moving the predicate onto
|
||
`ProgramScheduleItem` rows would touch the classic playout builder's core loop for **zero new expressible
|
||
behaviour**, carrying regression risk in the exact engine #163/#380 just built golden nets around.
|
||
- **"Prioritize collection X during a date range"** — routed to **#70**, not built here. The ask decomposes as
|
||
*date scoping* (shipped) × *soft prioritization* (the weighting primitive #70 is actively building across the
|
||
`PlaybackOrder` switch sites). Once #70 lands, this is pure composition with no date-aware code in the
|
||
weighting path. Building a second weighting primitive under #73 while #70 was mid-flight would have been a
|
||
direct collision in the same milestone.
|
||
- **Date-gating a playout's default `Playout.DecoId`** — rejected: the gated path already exists
|
||
(`PlayoutTemplate.DecoTemplateId`), and a date-conditional *fallback* is a contradiction in terms.
|
||
- **UI rename** of "Alternate Schedules" / "Playout Templates" to something that reads as "seasonal" — rejected:
|
||
inherited upstream vocabulary baked into routes, API paths and parity docs; churn for marginal gain. The
|
||
discoverability gap is addressed with docs instead.
|
||
|
||
**Known non-goal:** the issue's "optionally, auto-surface a seasonal channel in the lineup during its window"
|
||
is genuinely absent — M3U/XMLTV lineup visibility is not date-conditional. It was speculative, has no concrete
|
||
use case (the practical equivalent: air a date-gated schedule year-round, or toggle `ShowInEpg`), and per
|
||
close-don't-park no placeholder issue was filed. Refile if a real need appears.
|
||
|
||
**The real gap was discoverability, and it was a docs gap.** The mechanism was documented as a *mechanism*
|
||
("Alternate Schedules… useful for seasonal programming") but never as a *task* — a user thinking "holiday
|
||
channel" has no reason to look under "Alternate Schedules", and on finding that line gets no worked steps.
|
||
Fixed with a task-shaped **"Recipe: seasonal / holiday programming"** section in `channels.md` (both engines,
|
||
plus the gotchas above) and a `domain-model.md` glossary row. No production code changed, so no live-E2E
|
||
(same reasoning as #77).
|
||
|
||
## 2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)
|
||
`key: sched.autotune-detailpanel-members` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The Auto-Tune DetailPanel's per-channel content-source list is a live `ISearchIndex.Search` roll-up through the server-owned `AutoTuneAxisMap.GenerateQuery`, not an EF distinct+count query, so the preview matches exactly what the built channel's SmartCollection will contain.
|
||
**Signals:** Auto-Tune DetailPanel, search-index roll-up, SmartCollection fidelity · paths: `GET /api/v1/channels/auto-tune/members`, `MediaCollectionRepository.GetSmartCollectionItems` · issues: #384, #383, #69, #385, #386
|
||
**Mechanics:** `AutoTuneAxisMap.GenerateQuery`; `LibraryBrowseItemMapper.GetShows/GetMovies`; `PagedLibraryBrowseItemsResponseModel`
|
||
|
||
The Auto-Tune DetailPanel (#383) shows, per proposed channel, the distinct **content sources** its
|
||
generated SmartCollection resolves to (a genre channel's shows/movies), each weightable in the #385
|
||
write path. `GET /api/v1/channels/auto-tune/members?axis=&value=` backs that list.
|
||
|
||
**Why the search index, not an EF distinct+count query** — even though #69's *preview* enumeration uses
|
||
EF (`PreviewAutoTuneChannelsHandler.EnumerateTvShows/…`). The created channel's playout is built from a
|
||
**SmartCollection** whose members come from `ISearchIndex.Search` (`MediaCollectionRepository.GetSmartCollectionItems`).
|
||
For the DetailPanel to faithfully preview *what the built channel will actually contain*, the member list
|
||
must run the **same** query through the **same** index — so the handler calls the server-owned
|
||
`AutoTuneAxisMap.GenerateQuery(axis, value)` (client never sends Lucene, per #69 PR1) and rolls the matching
|
||
leaf items up to their distinct parents. #69's preview is a different granularity (enumerating candidate
|
||
axis *values* with EF exact counts to drive the min-items threshold); this is enumerating the *members of one
|
||
value*, where index-fidelity matters more than count-exactness. The two coexist deliberately.
|
||
|
||
**Roll-up + shape.** Episode axes (TvShow/TvGenre) → distinct parent shows (Episode→Season→ShowId), with
|
||
`ItemCount` = the **query-matching** episode count, not the show's total (a show contributes only its matching
|
||
episodes to a genre channel). Movie-genre axis → the matching movies are themselves the sources. Both reuse
|
||
the existing `PagedLibraryBrowseItemsResponseModel` / `LibraryBrowseItemResponseModel` DTOs and
|
||
`LibraryBrowseItemMapper.GetShows/GetMovies` (no new schema), ordered by title then id, paged in memory
|
||
(the distinct-source set is bounded — dozens for a genre). Search pulls up to the 10k cap
|
||
`GetSmartCollectionItems` already uses; a value resolving to >10k leaf items could under-report sources past
|
||
the cap — the same staleness bound the smart-collection path accepts. Read-only, catalog-read tier (no
|
||
`[RequiresAuthentication]`), so a cold review sufficed. Sibling backend child #385 (write-path overrides +
|
||
weights) and SPA child #386 remain open under the #383 milestone.
|
||
|
||
---
|
||
|
||
## 2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)
|
||
`key: ci.runner-placement` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** No persistent Roslyn compiler server survives a CI build (`UseSharedCompilation=false` etc., runner env + Dockerfile `ENV`); every `services:` container gets its own explicit `--memory`/`--memory-swap`/`--cpus` cap (it does not inherit the job container's).
|
||
**Signals:** CI memory/swap thrash · paths: `.gitea/workflows/*.yml`, `docker/Dockerfile` · issues: #406, #390 (prose-reversed, no standalone record), server-management#604, server-management#570
|
||
**Mechanics:** `docs/ci-cd.md` → CI build memory; `scripts/ci-peak-anon.sh`
|
||
|
||
Three CI changes, all downstream of one incident: on 2026-07-17 bumblebee (the **prod media** Docker
|
||
host, 25 GiB / 12 cores) hit load **340** with **21 GiB swapped** and ~238 MiB free, taking prod
|
||
ersatztv/jellyfin down until reboot. Prod ersatztv itself was healthy at 168 MiB throughout — the
|
||
thrash was **CI-induced**, triggered by a burst of parallel merges to `main`. Infra sizing is
|
||
server-management#604's boundary; these three levers live in this repo and each does more than any
|
||
capacity knob.
|
||
|
||
**1. No persistent compiler servers.** `VBCSCompiler` is a *persistent* Roslyn server: it outlives
|
||
the `dotnet build` that started it and holds its heap for the next one. Measured at **7.8 GB RSS**
|
||
live on bumblebee — the single largest consumer on the box, and the actual reason each job needed a
|
||
10 GiB cap. In CI it buys **nothing**: each job container is torn down at the end of the run, so
|
||
there is never a "next build" to warm. The workflow's top-level `env:` now sets
|
||
`UseSharedCompilation=false`, `DOTNET_CLI_USE_MSBUILD_SERVER=0`, `MSBUILDDISABLENODEREUSE=1` —
|
||
MSBuild properties set as env vars so they apply to every `dotnet` call without touching each call
|
||
site (MSBuild surfaces env vars as properties; `UseSharedCompilation` only defaults to `true` when
|
||
empty, so the env var wins). Verified locally: a default build leaves 1 `VBCSCompiler` alive, the
|
||
same build under these vars leaves **0**, and `ErsatzTV.sln` still builds clean (0 errors).
|
||
|
||
**Why the Dockerfile also sets them** — and this is the part the issue's suggestion would have
|
||
missed: the workflow `env:` reaches the *runner-side* dotnet jobs only. The `build` job compiles
|
||
inside `docker build`, where it does not propagate, so the SDK stage of `docker/Dockerfile` sets the
|
||
same three as `ENV`. That is precisely the job server-management#570 measured pegging **5.999/6
|
||
GiB** — the one that most needs it. Build-stage only; the final image is `FROM runtime-base`, so
|
||
nothing lands in the shipped image or affects runtime.
|
||
|
||
**Trade-off accepted**: without the shared server each project's `csc` is a fresh process, which
|
||
costs some build time. Worth it — the memory spike is what takes prod down, and the cap sizing that
|
||
spike forces is what starves the lanes.
|
||
|
||
**2. `services:` containers do not inherit the runner's cap.** A runner's `container.options`
|
||
(`--cpus=4 --memory=10g`) applies to the **job container only**. Verified by inspecting a live
|
||
`migrations` job: the job container reported `HostConfig.Memory=10737418240`, its `mysql:8.4`
|
||
service reported `mem=0 nanocpus=0` — **unbounded**. Every migrations run was adding an uncapped
|
||
MySQL to an already-tight host. Now `--memory=2g --memory-swap=2g --cpus=2`.
|
||
|
||
**Why `--memory-swap` is not redundant** (cold-review catch, and the sharpest thing in this change):
|
||
Docker defaults an unset `--memory-swap` to **twice** `--memory`, so `--memory=2g` alone grants 2g
|
||
RAM **plus 2g of swap** — verified live: `--memory=2g` → `memory.max=2147483648` **and**
|
||
`memory.swap.max=2147483648`; `--memory=2g --memory-swap=2g` → `memory.swap.max=0`. Capping RAM
|
||
while silently permitting swap is close to the worst outcome **on the host whose swap thrash is the
|
||
entire reason for the cap**, and a swapping mysqld mid-DDL is exactly the pathology behind the known
|
||
`Command Timeout expired` migrations flake — i.e. the naive cap could have made that flake worse.
|
||
**Standing rule: prefer a loud OOM over silent swapping.** An OOM is an unambiguous "raise the cap"
|
||
signal; swapping just degrades everything and blames something else.
|
||
|
||
**The same 2× applies to the runners' 10g job slots** — each is really 10 GiB RAM *plus* 10 GiB
|
||
swap, so the "60 GiB promise on a 25 GiB host" understates by 2× and is a plausible direct mechanism
|
||
for the incident's 21 GiB swapped. That is server-management#604's boundary; reported there.
|
||
|
||
**On 2g, honestly**: 543 MiB is init+idle, **not** the 787-migration replay (which grows caches idle
|
||
never touches), so 2g is a measured *floor* plus headroom, not a measured ceiling — the `migrations`
|
||
job going green is what validates it. `--cpus=2` has no measurement behind it at all; 787 sequential
|
||
DDL statements on one connection are ~1-core-bound, so it is judgement. Recording that rather than
|
||
dressing a guess as measurement — the same failure this entry criticises below.
|
||
|
||
**Standing rule: any new `services:` container needs its own explicit cap** — it will not inherit
|
||
one, and `--memory` without `--memory-swap` silently grants 2× in swap.
|
||
|
||
**3. #390's `small`-lane move for `api-docs`/`format` reversed.** #390 moved them to dodge a ~29 min
|
||
`ubuntu-latest` queue. The queue was real, but the lane was the wrong fix, and **#390's own comment
|
||
flagged why**: *"on an API-touching PR this job does a full `dotnet build`, so it is not always a
|
||
'small' job; capacity 4 absorbs that."* "Capacity 4 absorbs that" held only because **nothing
|
||
enforces the sum** of the lanes' caps — 6 slots × 10 GiB on a 25 GiB host is a 60 GiB promise. These
|
||
are not small jobs: a live `docker stats` caught the `format` job container at **3.95 GiB**, which
|
||
#604's re-sized 2 GiB `small` lane would OOM-kill outright. #604 grows `ubuntu-latest` to 5 slots
|
||
(48 GiB ci-runner at capacity 4 + a bumblebee overflow slot) and fixes the queue at the source. This
|
||
one is **order-coupled with #604**: the small lane's caps can't tighten until it lands.
|
||
|
||
**Measurement is now continuous, not a one-off.** The `test` job's **last** step reports the
|
||
cgroup's `memory.peak` plus an `anon`/`file` breakdown (`continue-on-error`, tolerates absence).
|
||
#604 sizes both runners' caps on that number, and until now it was *inherited* rather than measured
|
||
— the 10g cap traces back to #570 observing a different job entirely. Two things that look like
|
||
details but are the whole point: it must run **last** (`memory.peak` read at step N reports the peak
|
||
only up to N, so an earlier placement silently excludes the job's later workload), and
|
||
`continue-on-error` — not `if: always()` — is what makes it advisory (`always()` controls whether a
|
||
step *runs*, not whether its failure fails the job, and `defaults.run.shell: bash` means `-e` is on).
|
||
|
||
**And then the instrument taught us the lesson twice, both times at our own expense.** The first
|
||
reading came back `peak 8305 MiB`. `memory.peak` is the high-water mark of `memory.current`, which
|
||
charges **page cache** as well as anonymous memory — proven: a container with `anon=0` that merely
|
||
reads an 800 MB file reports `memory.peak=826 MiB`, `file=800 MiB`. Page cache is *reclaimed* under
|
||
a tighter cap, not OOM-killed, so a large peak that is mostly `file` is **not** evidence a cap must
|
||
stay high. `anon` is what forces an OOM; size caps on it. The step now prints the split (end-of-job,
|
||
so indicative rather than peak-instant); a true peak-anon sample is **#412**.
|
||
|
||
**Then we made the same mistake in the opposite direction.** Having established that peak
|
||
*overstates*, the docs (and a report to #604) leaned to "so this number is probably mostly cache."
|
||
That was a guess about *magnitude* dressed in a verified fact about *mechanism* — and an independent
|
||
probe killed it: a full solution build in the CI image with shared compilation off measured `peak
|
||
9457 MiB` / **`anon 7134 MiB`** / `file 421 MiB`. **Anon dominated.** So a 6g cap looks *unsafe*,
|
||
#570's "6g proved too tight" is the rule rather than an outlier, and **#406's premise ("if this
|
||
brings peak RSS well under 6 GiB, the whole budget loosens") is looking dead** — the 7134 MiB was
|
||
measured *with* shared compilation already off. The switches are still right (no persistent 7.8 GB
|
||
server between builds); the looser budget they were supposed to buy is not.
|
||
|
||
**What is NOT established:** there is still no pre-change baseline from this instrument — the 7.8 GB
|
||
`VBCSCompiler` figure was measured host-wide across concurrent jobs, not inside one job container —
|
||
and the anon figure above is one probe, not the `test` job. #412 covers the real A/B. What *is*
|
||
established: no persistent compiler server survives a build, and `migrations` is green with mysql
|
||
capped at 2g with swap disabled.
|
||
|
||
The lesson generalizes, and note it bit *this* change twice — once in the issue's premise and once
|
||
in our own instrument: this repo's CI perf work keeps stating numbers from plausibility rather than
|
||
measurement (see #390's "2–4min" apt-ffmpeg estimate; real 110s, and not load-bearing). Measuring
|
||
the wrong quantity precisely is the same failure wearing a lab coat.
|
||
|
||
**What this does NOT shrink**: `format`. `dotnet format` loads Roslyn in-process via
|
||
MSBuildWorkspace and never spawns `csc`, so its measured 3.95 GiB is untouched by any of this. Do
|
||
not size the `small` lane expecting otherwise.
|
||
|
||
**Not addressed here**: the 12–35 min queue waits (server-management#604) and the redundant
|
||
triple-build (#398).
|
||
|
||
## 2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)
|
||
`key: api.channel-health-signal` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands).
|
||
**Signals:** channel health, PlayoutCount, config-derived vs runtime-liveness cadence, frozen /api/v1 · paths: `ChannelRepository.GetChannel`, `Mapper.GetPlayoutsCount`, `api-conventions.md` §3a · issues: #72, #383, #384, #401
|
||
**Mechanics:** `api-conventions.md` §3a (precedent: `IsLocked` on `PlayoutListItemResponseModel`); `ChannelResponseModel.PlayoutCount`
|
||
|
||
#72 asks for per-channel status in the Channels list, "especially channels that will fail to play".
|
||
|
||
**Where it lives: `ChannelResponseModel` (the lean list DTO), not a new endpoint and not
|
||
`/channels/state`.** Channel health is *config-derived* — it changes when someone edits a playout, not
|
||
tick to tick — whereas `/channels/state` is the fast-poll runtime-liveness feed (`OnAir` = someone is
|
||
streaming *right now*). Folding health into the polled feed would recompute rarely-changing data every
|
||
tick and mix two cadences in one DTO; a third endpoint is over-engineering for one derived integer on a
|
||
list whose consumer already reads it. Precedent: `api-conventions.md` §3a stamps the server-derived
|
||
`IsLocked` onto `PlayoutListItemResponseModel` for exactly this reason. **It is also free**: `GetAll`
|
||
already `Include`s `Playouts` and `MirrorSourceChannel.Playouts` and was discarding them, so no extra
|
||
query and no N+1 on a large lineup.
|
||
|
||
**A raw fact (`int PlayoutCount`), not a `ChannelHealth` enum.** v1 has exactly one trustworthy negative
|
||
signal, and the auto-tune arc (#383/#384) is about to churn the status taxonomy (origin, auto-tune
|
||
outcomes) — freezing a server-side enum now guarantees a breaking rev of a frozen-additive `/api/v1`
|
||
surface. `PlayoutCount` mirrors the long-standing `ChannelViewModel.PlayoutCount` 1:1, is a fact rather
|
||
than a policy, and leaves the SPA to derive `0 ⇒ "No playout"` in one predicate.
|
||
|
||
**What v1 deliberately does NOT compute** — each was considered and ruled out, so don't "finish" them
|
||
without reading this:
|
||
- **Empty schedule behind an existing playout.** `EmptyScheduleHealthCheck` only understands **Classic**
|
||
`ProgramSchedule` playouts. Block, Sequential, Scripted and ExternalJson channels have no
|
||
`ProgramSchedule` at all, so a badge driven off that query would be silently absent or wrong for four of
|
||
the five schedule kinds — the #71 "verify a shared primitive covers ALL variants" trap. Needs a
|
||
per-kind emptiness notion first.
|
||
- **Broken / missing source.** `FileNotFound`/`Unavailable` are server-wide media-item counts with no
|
||
channel attribution; mapping media → collection → schedule → channel is a project, not a field.
|
||
- **User-defined vs auto-generated origin** (#72 scope item a). No honest signal exists:
|
||
`Channel` has no origin column, and `ChannelPlayoutSource.Generated` is a *playout-strategy* value that
|
||
SPA-created blank channels also carry, so it would mislabel them. Requires a new column + a dual-provider
|
||
migration, and provenance belongs to the auto-tune arc that stamps it at creation. A join through the
|
||
`"Channel Lineups"` system playlist group was **rejected**: it is a heuristic that breaks the moment a
|
||
user edits the channel. Deferred to a follow-up blocked on the auto-tune backend.
|
||
|
||
Keeping all three out held #72 to a **read-path-only** change: no migration, no write-handler live-E2E.
|
||
|
||
**Corrected in passing:** `ChannelRepository.GetChannel` never included `Playouts`, so
|
||
`GET /api/v1/channels/{id}` reported `playoutCount: 0` for every channel, which silently disabled the
|
||
channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware).
|
||
The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent,
|
||
against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here.
|
||
|
||
## 2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)
|
||
`key: sched.weighted-shuffle` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Fair-share/weighted airtime distribution ships as one new `PlaybackOrder.WeightedShuffle = 9` order (equal weights = fair-share), not a retrofit of `ShuffleInOrder` (which only anti-clumps, since its padding spacers emit nothing) and not a separate orthogonal "distribution" setting; weights live on `MultiCollectionItem`/`MultiCollectionSmartItem` (DB default 1, dual-provider migration), bounded at write (1..1000) and clamped again in the enumerator, and the write path rejects `WeightedShuffle` at every dispatch site that doesn't handle it rather than let it silently degrade to unweighted random.
|
||
**Signals:** WeightedShuffle, fair-share, ShuffleInOrder anti-clumping, MultiCollectionItem.Weight, write-path validation · paths: `WeightedShuffleCollectionEnumerator`, `MultiCollectionGrouper`, `ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder`, `AddMultiCollectionItemWeight` migration · issues: #70, #380, #403, #385, #386
|
||
**Mechanics:** `CollectionWithItems.Weight`; `ReplacePlaylistItems`/`CreateChannelFromLineup` write-path gates; `EffectiveWeight` clamp
|
||
|
||
`PlaybackOrder.WeightedShuffle = 9` ships the two behaviors #70 asked for — "air Show A 70% / Show B 30%"
|
||
and "no single show dominates" — as **one** order, because fair-share is the equal-weights case of weighted.
|
||
|
||
- **`ShuffleInOrder` does NOT already do fair-share, despite looking like it.** Its balanced shuffle
|
||
(keyj) pads every source to the longest with `Option<MediaItem>.None` spacers — but **spacers emit
|
||
nothing** (`ShuffleInOrderCollectionEnumerator.cs`, `result.AddRange(maybeItem)` over an `Option`). One
|
||
cycle therefore plays **every item exactly once**, so a 200-episode show still takes 10× the airtime of a
|
||
20-episode one. What it buys is **anti-clumping**: the small show is spread evenly instead of arriving in
|
||
bursts, and each show plays chronologically. That is a different product from "airs as often". Recorded
|
||
because the padding reads as equalization and this was misread once during #70's own design pass — the
|
||
distinction is the entire justification for the issue.
|
||
- **Plain `Shuffle` is already implicitly weighted by collection size** (Fisher–Yates over flattened items),
|
||
which is why "just use Shuffle" isn't fair-share either.
|
||
- **One new enum value, not two, and not a separate setting.** Fair-share = `WeightedShuffle` with weights
|
||
left at their default of 1, so the UI's "fair-share toggle" needs no second mechanism. A separate
|
||
non-enum "distribution" setting was rejected: it would add an orthogonal axis every `PlaybackOrder`
|
||
dispatch site must *also* consult, multiplying the blast radius below. Retrofitting weights onto
|
||
`ShuffleInOrder` was rejected: it would silently change shipped users' output.
|
||
- **Weights live on `MultiCollectionItem` AND `MultiCollectionSmartItem`** (`int Weight`, DB default 1,
|
||
dual-provider migration `AddMultiCollectionItemWeight`). The mirror is mandatory — omitting the smart
|
||
item un-weights smart-collection members silently. The DB default must be **1, not 0**: a 0 backfill would
|
||
hand every pre-existing member to the rotation carrying a weight that means nothing on a share-of-airtime
|
||
scale. (The enumerator clamps such rows to the floor so they rotate fair-share rather than vanish — but the
|
||
backfill should be right at the source.)
|
||
Rejected carriers: `PlaylistItem` (sequential engine; its `Count`/`PlayAll` already mean "how much of this
|
||
source", and drain-N-consecutively `A A A B` is a different product from smooth interleave `A A B A`);
|
||
`CollectionItem` (wrong grain — per media item, and the largest table).
|
||
- **Applied on the `ShuffleInOrder`-shaped path only, because that is where source identity survives.**
|
||
`MultiCollectionGrouper` collapses sources into `GroupedMediaItem` + `.Distinct()`, so by the time the
|
||
`Shuffle` path has its list, *which source an item came from* is gone. `CollectionWithItems` gains
|
||
`int Weight = 1`; `WeightedShuffleCollectionEnumerator` consumes
|
||
`ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder` **unchanged** — the schedule-entity-free entry
|
||
point #380 reserved for this issue (no signature change, no god-factory, engines stay separate).
|
||
- **Stateless.** Smooth weighted round-robin (`acc += weight`; richest wins; pays the total) is a pure
|
||
function of (`Seed`, `Index`), so it restores by replay like its siblings and needs no per-source counters
|
||
(`CollectionEnumeratorState` has nowhere to put them). A rotation is sized so the source needing the most
|
||
picks works through all its items at its share; smaller sources loop inside it — that looping is what makes
|
||
equal weights mean equal airtime. **Weight ratios are data, not state**: a `Reset` build re-randomizes
|
||
`Playout.Seed` (fresh within-source shuffle and start point) but the ratios hold identically.
|
||
- **Ties break to the earliest source in list order** (strict `>`), keeping the sequence deterministic.
|
||
- **`ScheduleAsGroup` is not read by this order**, unlike `ShuffleInOrder`, which merges every
|
||
non-`ScheduleAsGroup` collection into ONE source. Here each `CollectionWithItems` is its own weighted
|
||
source, which is the entire point — per-source weights are meaningless if sources are merged. Recorded
|
||
because it means a persisted per-item flag quietly has no effect under this order.
|
||
- **Within-source order is shuffled, not chronological** (custom-ordered collections are still honored).
|
||
Chronological inner order would make every rotation byte-identical, since source selection is
|
||
deterministic — the reseed on wrap would change nothing and the avoid-an-immediate-repeat retry could
|
||
never succeed. "Weighted **in order**" would be a separate order if wanted.
|
||
- **Cross-engine exposure is closed by write-path validation, not by touching the silent fallbacks.** An
|
||
unhandled `PlaybackOrder` fails **silently at five of six dispatch sites**: Classic substitutes
|
||
`RandomizedMediaCollectionEnumerator` (`PlayoutBuilder`'s `default:`, `// TODO: handle this error case
|
||
differently?`); `PlaylistEnumerator` has **no `default:` arm**, so the enumerator stays null and the item is
|
||
dropped from the playlist (and null is *legitimate* there for `SeasonEpisode` with `Count == 0`, so nothing
|
||
flags it); `BlockPlayoutBuilder` filters block items against an allow-list and `continue`s past the rest;
|
||
YAML/Scripted return `Option.None`, which their callers read as "no content". Only `MultiCollectionGroup`
|
||
throws. **A weighted order degrading to unweighted random is the worst case, because the output is supposed
|
||
to look arbitrary.** So the write path **rejects** `WeightedShuffle` everywhere it isn't handled — if it
|
||
can't be persisted where it isn't handled, the silent sites never see it — and YAML/Scripted (which address
|
||
orders by *name*, so `Enum.Parse` accepts it regardless) log a warning when an order falls through
|
||
unhandled. Making those pre-existing fallbacks loud is a real but separate defect class: **#403**, an
|
||
explicit non-goal here.
|
||
- **Exactly two writers persist a CALLER-SUPPLIED `PlaylistItem.PlaybackOrder`, and the second is not
|
||
obvious.** `ReplacePlaylistItems` is the expected one; **`CreateChannelFromLineup`** is the other — a
|
||
lineup of 2+ entries is stored as a `Playlist`, and its own guard only covered *MultiCollection* entries,
|
||
so a multi-entry lineup of plain collections slipped `WeightedShuffle` straight through to
|
||
`PlaylistEnumerator`'s null-drop. Both are gated. The full writer set, since "persisting writer" alone is
|
||
the wrong axis: `Add*ToPlaylist` and `TraktCommandBase` **do** persist the field but hardcode it
|
||
(`Shuffle`/`Chronological`), so no caller value reaches them; `ReplaceBlockItems` writes the *different*
|
||
field `BlockItem.PlaybackOrder` (also gated); and `PreviewPlaylistPlayout`, `PreviewBlockPlayout` and
|
||
`Engine/PlaylistHelper` build in memory without persisting.
|
||
**This bullet has now been wrong three times, each time in the same shape**: it recorded the perimeter as
|
||
complete when the gate covered only the writers already in hand (missing `CreateChannelFromLineup`); the
|
||
first correction miscounted by conflating `PlaylistItem` with `BlockItem`; the second said "two persisting
|
||
writers" when the true predicate is *two writers that persist a caller-supplied order*. The failure is
|
||
always **enumerating from a list instead of re-deriving from a grep** — which is also how the
|
||
"0-weight is filtered out" claims survived their own correction. Before trusting any "this order can't
|
||
reach that engine" claim, grep every writer of **each field separately** and classify each as
|
||
persists-caller-value / persists-hardcoded / in-memory. The non-obvious composite handler is the one that
|
||
gets missed.
|
||
- **Weight is bounded at the write path** (`MultiCollectionItemWeight`, 1..1000) and clamped again in the
|
||
enumerator (`EffectiveWeight`). They do **different** jobs, which is why both stay: **EF's
|
||
`HasDefaultValue(1)` substitutes 1 for a `0` on INSERT** (0 reads as "not set") **but an UPDATE writes the
|
||
0 through**, so create and update disagreed on the same input — the gate makes them agree and refuses
|
||
values that mean nothing on a share-of-airtime scale (0, negative, or absurdly large). The **clamp** is
|
||
what makes the rotation arithmetic safe for rows that predate the gate: it bounds every weight before the
|
||
sum, so `Sum(weights)` cannot overflow regardless of what is stored. Historical note: an earlier revision
|
||
of this PR *filtered* `Weight > 0` instead of clamping, which silently deleted a 0-weight source from the
|
||
channel; the clamp replaced it, and any comment still describing that filter is stale.
|
||
- **Weight is returned on the multi-collection GET, not just accepted on write.** The update replaces the
|
||
item list, so a client that reads, edits a name, and writes back would silently reset every weight to the
|
||
default if the read didn't carry it. Edits ride the existing `Version` token (If-Match/412, §7a).
|
||
- **Scope:** Classic engine only; no SPA (the schedule-editor weight UI is gated on #388). Plain
|
||
single/smart collections (the `GroupIntoFakeCollections` path) carry no persisted weights — fair-share
|
||
applies there by construction, and per-source weighting requires a multi collection. Auto-tune's
|
||
per-member weights (#385/#386) need their own carrier: an auto-tuned channel is backed by one live
|
||
SmartCollection, so its "sources" are members of a single collection, and a `PlaylistItem` cannot even
|
||
reference a search query. Nothing here forecloses it — the enumerator reads weights off
|
||
`CollectionWithItems` source-agnostically.
|
||
|
||
## 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416)
|
||
`key: ci.docs-only-skip-steps` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt.
|
||
**Signals:** docs-only CI skip, required-context branch protection, step-level gating vs job-level `if:` · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-detect-docs-only.sh` · issues: #416, #418, #420, #398
|
||
**Mechanics:** `docs/ci-cd.md` → "Docs-only skip"
|
||
|
||
A change touching only `docs/**` or `*.md` ran the entire `docker-build.yml` matrix (`test`,
|
||
`migrations` incl. its `mysql:8.4` service, `functional-e2e`, `format`, `api-docs`) — ~9 min of warm
|
||
CI to validate Markdown. `docker-build.yml` had no path filtering.
|
||
|
||
**Why not `paths-ignore` / an `if:`-skipped job — the trap.** `main`'s branch protection requires
|
||
two contexts *by name* (`Build & test (.NET)`, `EF migration integrity (SQLite + MySql)`). If a
|
||
docs-only PR produced **no run** for them, those contexts never report and the PR can **never merge**
|
||
— the naive fix bricks docs PRs instead of speeding them. A probe (throwaway PR #418) confirmed that
|
||
on Gitea **1.25.4** an `if:`-skipped job reports commit-status state **`skipped`** (a distinct state,
|
||
not `success`); how branch protection treats a `skipped` *required* context is not something we rely
|
||
on.
|
||
|
||
**The decision.** Each heavy job (`test`, `migrations`, `functional-e2e`, `build`) runs
|
||
`scripts/ci-detect-docs-only.sh` as its first post-checkout step (`id: detect`) and gates every real
|
||
step on `if: steps.detect.outputs.docs_only != 'true'`. The job **always runs** and reports
|
||
`success` in seconds on docs-only — so the two required contexts report unconditionally (safe by
|
||
construction). Non-required jobs may skip freely (production proves a `skipped` non-required context
|
||
doesn't block merge — `build` is `skipped` on every PR), so `build` skips its image steps on a
|
||
**docs-only push to `main`** (docs aren't in the image); tag builds force `docs_only=false` so a
|
||
release is never skipped. `api-docs`/`format` already self-short-circuit; `docs-reminder`/
|
||
`decisions-guard`/`ci-image-pin` keep running.
|
||
|
||
**Detection biases toward running MORE.** `docs_only=true` only when *every* changed path is docs;
|
||
any code path, a tag build, a non-merge push, or an undeterminable diff → `false` (run everything). A
|
||
false `true` would skip real tests on a code change (a correctness bug); a false `false` merely wastes
|
||
CI. Trade-off accepted: `migrations`' `mysql` service still starts on a docs-only run (a `services:`
|
||
container starts with the job regardless of step `if:`), but the 787-migration replay — the expensive
|
||
part — is skipped.
|
||
|
||
Two adjacent redundancies are deliberately **out of scope**: the whole matrix re-running on a PR and
|
||
again on the merge-to-`main` over identical code (#420), and the within-run triple `dotnet build`
|
||
(#398). Full mechanism in `ci-cd.md` → "Docs-only skip".
|
||
|
||
## 2026-07-17 — Docs-only detect must be shallow-checkout safe: FETCH_HEAD + two-dot, not origin/main + three-dot (#416 follow-up)
|
||
`key: ci.docs-only-detect-shallow-safe` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check.
|
||
**Signals:** shallow clone, FETCH_HEAD, two-dot vs three-dot diff, fetch-depth 1 · paths: `scripts/ci-detect-docs-only.sh` · issues: #416, #422
|
||
**Mechanics:** `docs/ci-cd.md` → "Docs-only skip"; verified via a real shallow `file://` clone reproduction
|
||
|
||
The #416 docs-only skip shipped (#422) safe but **ineffective**: every docs-only PR still ran the full
|
||
matrix. Root cause — `test`/`migrations` check out `fetch-depth: 1`, and in a shallow clone
|
||
`origin/<base>` has no remote-tracking ref and there is no merge-base, so the detect script's
|
||
three-dot `git diff origin/main...HEAD` errored → `|| true` → empty diff → the fail-safe returned
|
||
`docs_only=false` → full matrix. Confirmed in a real shallow `file://` clone (`origin/main` did not
|
||
resolve; three-dot errored; `git diff FETCH_HEAD HEAD` returned the changed files correctly).
|
||
|
||
Decision: the detect diffs against **`FETCH_HEAD`** (always written by `git fetch`, resolves in a
|
||
shallow clone) with a **two-dot** tree diff (no merge-base). `api-docs`/`format` were unaffected only
|
||
because they use `fetch-depth: 0` — a difference the first cut missed. Meta-lesson reinforced: a CI
|
||
gating change can pass every local test and merge green while being a complete no-op in CI; only
|
||
real-PR verification that **measures the effect** (job durations, not just a green check) catches it —
|
||
which is exactly what #416's Done-when demanded. Fixed in the #416 follow-up PR.
|
||
|
||
## 2026-07-17 — Pre-push guard: don't push a file whose working-tree copy is uncommitted (H13, #416 session)
|
||
`key: release.prepush-clean-worktree-guard` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`.
|
||
**Signals:** working-tree vs committed-tree mismatch, stale index, pre-push hook, H13 · paths: `.claude/hooks/prepush-clean-worktree-check.sh`, `.husky/pre-push` · issues: #416
|
||
**Mechanics:** wired after the H11 rebase check in `.husky/pre-push`; same hook family as H11/H12 (#303)
|
||
|
||
A review fix (`--no-renames`) was edited into the working file and empirically verified there, but a
|
||
`git reset --soft` + `git add` then committed the *stale index*, leaving the fix as an uncommitted
|
||
working-tree diff. The push, the CI run, and a cold reviewer each saw a **different tree**: CI/push
|
||
had the OLD code; the reviewer read the working file and "confirmed" a fix that never shipped. A PR
|
||
went out still carrying the bug the review had cleared. Root cause: local build/test/review all
|
||
operate on the working tree, but what ships is the *committed* tree — nothing enforced that they
|
||
match.
|
||
|
||
Decision: a fail-open **pre-push hook** (`.claude/hooks/prepush-clean-worktree-check.sh`, wired into
|
||
`.husky/pre-push` after the H11 rebase check) blocks a push when a file that is part of the branch's
|
||
diff vs `origin/main` **also** has uncommitted working-tree or index changes. Scope is deliberately
|
||
precise — only files in the pushed diff, so unrelated uncommitted scratch (or untracked files) never
|
||
false-block. Fail-open on anything undecidable (not a repo, offline, no `origin/main`); deliberate
|
||
escape `ETV_ALLOW_DIRTY_PUSH=1`. This is the mechanized half of the working-tree-vs-committed lesson;
|
||
the review-process half (point reviewers at `git show <sha>:<file>`, never the bare working file) stays
|
||
guidance. Same hook family as H11 (rebase-not-merge) / H12 (issue-qualification), same "#303 make the
|
||
rule a hook, not prose to remember" throughline. Verified: dirty PR-file → block; clean tree → allow;
|
||
dirty non-PR file → allow; escape hatch → allow.
|
||
|
||
## 2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)
|
||
`key: sched.autotune-per-channel-overrides` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425.
|
||
**Signals:** Auto-Tune, per-channel overrides, advanced-options DTO reuse · paths: `CreateAutoTunedChannelsHandler`, `POST /api/v1/channels/auto-tune` · issues: #385, #383, #425, #283
|
||
**Mechanics:** `CreateAutoTunedChannelsHandler.CreateOne`; `CreateChannelFromLineupAdvancedOptionsRequest`
|
||
|
||
The Auto-Tune DetailPanel (#383) makes each proposed channel individually editable before bulk-create.
|
||
The backend for that (#385) split cleanly along a "structural cost" line, and only the additive half
|
||
shipped here; the rest is deliberately deferred rather than forced.
|
||
|
||
- **Shipped now (additive, over the frozen `/api/v1`):** `POST /api/v1/channels/auto-tune`'s
|
||
`AutoTunedChannelRequest` gained three optional per-channel fields — `templateId`, `advanced`, and an
|
||
uploaded `logo` image. Omitting each keeps PR1 behavior exactly (batch template, axis-derived playback
|
||
order, on-the-fly fallback logo), so the change is backward-compatible and the older positional
|
||
`{axis, value, name, number}` form still binds.
|
||
- **`advanced` reuses the manual Channel Builder's `CreateChannelFromLineupAdvancedOptionsRequest`
|
||
verbatim** — the same 24-field override set, the same `ToCommand()`, and the same
|
||
`advanced.X ?? template.X` stamp-at-create contract that `POST /api/v1/channels/from-lineup` already
|
||
honors. Auto-tune is now just a second caller of that wire contract; we did **not** mint a parallel
|
||
24-field DTO. The one auto-tune-specific rule: the axis default (`SeasonEpisode` for a single show,
|
||
`Shuffle` for a genre) fills `PlaybackOrder` only when the caller left it null, so an explicit
|
||
per-channel order wins but an unset one is never dropped.
|
||
- **`logo` is the uploaded channel image**, forwarded to `CreateChannelFromLineup.Logo` (persisted as an
|
||
`ArtworkKind.Logo` artwork) and run through `ArtworkContentTypeModel.Sanitized()` at the request
|
||
boundary — the same stored-XSS defense (#283) the Channel Builder uses; a hostile content type never
|
||
reaches the command.
|
||
- Per-channel independence is preserved: `templateId`/`advanced`/`logo` are resolved per channel inside
|
||
`CreateAutoTunedChannelsHandler.CreateOne`, so one channel's bad override still yields a per-channel
|
||
`Failed`/`Skipped` outcome without aborting the batch.
|
||
|
||
- **Deferred to #425 — per-source rotation weights + query corrections (exclude / add-untagged).** This is
|
||
a structural redesign, not effort: #70's `WeightedShuffle` reads weights only off
|
||
`MultiCollectionItem`/`MultiCollectionSmartItem` rows, but an auto-tuned channel is backed by a **single
|
||
SmartCollection** (→ `GetFakeMultiCollectionCollections`, every fake group forced to `Weight = 1`), which
|
||
gives fair-share but **cannot express differing per-source weights**. Honoring them requires auto-tune to
|
||
build a **MultiCollection of per-source SmartCollections** (one query per weighted source), which
|
||
contradicts the documented "one live query per channel" design and lands new structure in the scheduling
|
||
subsystem. That is the `[PLAN-MODE]` design call the #385 body flagged; it belongs in its own issue with
|
||
a recorded design decision, so it moved to **#425** (consumed by the SPA #386).
|
||
|
||
- **Deferred — bug initials + fallback colour generated logo.** The generated logo (`/iptv/logos/gen`) is a
|
||
stateless on-the-fly render used by both the XMLTV `<icon>` and the FFmpeg `WatermarkSelector` bug, with
|
||
**no persisted bug-initials/colour state on `Channel`**. Making it configurable needs either new `Channel`
|
||
columns (a dual-provider migration) wired through the watermark pipeline, or create-time artwork-file
|
||
materialization — neither is the additive plumbing this slice was scoped to, and it is a channel-wide
|
||
capability rather than an auto-tune concern. Left for its own issue / the SPA branding work; the uploaded
|
||
`logo` above already covers the on-screen bug for channels that supply an image.
|
||
|
||
## 2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)
|
||
`key: api.healthcheck-remediation-dto` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Health-check remediation is server-declared `{Kind, Target}` metadata on an additive DTO field; the SPA renders/acts on it, it doesn't derive labels itself.
|
||
**Signals:** health checks, remediation, AppRoute/ExternalDoc · paths: `HealthCheckResponseModel`, `HealthCheckLink` · issues: #164, #286, #108
|
||
**Mechanics:** `HealthCheckResponseModel.Remediation`; Application `Mapper.GetStatus`
|
||
|
||
#164 asked to make the ~14 health checks *actionable* — the Dashboard health panel showed problems
|
||
with no way to investigate or fix them. Two structural decisions came out of it.
|
||
|
||
**Remediation is server-declared metadata, not SPA-derived.** Each check that has a fix knows where the
|
||
fix lives, so the *check* declares it. The domain `HealthCheckLink` grew from `(string Link)` to
|
||
`(string Target, HealthCheckLinkKind Kind)` with `Kind ∈ {ExternalDoc, AppRoute}` and two factories
|
||
(`HealthCheckLink.ExternalDoc(url)` / `HealthCheckLink.AppRoute("/app/...")`). Only the 4 checks that
|
||
built links and the API mapper touched `.Link`, so the widening was local. The SPA then *acts* on the
|
||
kind: `AppRoute` → client-side `navigateToPath(target)` button; `ExternalDoc` → new-tab anchor. The
|
||
human label is derived SPA-side from the route (a small lookup + prettified fallback) rather than sent
|
||
over the wire — keeping the DTO minimal.
|
||
|
||
**The DTO evolved additively (`/api/v1` is frozen-additive, #286).** `HealthCheckResponseModel` kept
|
||
its existing `Detail` and gained `Brief` (← the domain `BriefMessage` the old mapper silently dropped)
|
||
and `Remediation { Kind, Target }` (a nested `HealthCheckRemediationResponseModel`). The old flat
|
||
`string? Link` is **kept and still populated** (mirrors `Remediation.Target`) but documented deprecated —
|
||
we don't remove a frozen field, and existing consumers keep working. `Remediation.Kind` is a plain
|
||
string ("ExternalDoc"/"AppRoute") mapped in the Application `Mapper` exactly like `Status`
|
||
("pass"/"fail"/…), not a wire enum — matching the established pattern for that DTO.
|
||
|
||
**Three defects the audit surfaced, fixed here.** (1) The Application `Mapper.GetStatus` threw
|
||
`ArgumentOutOfRangeException` on `NotApplicable`; the handler filters `NotApplicable` before mapping so
|
||
it was latent, but the mapper is now **total** (defense-in-depth — a future caller that skips the filter
|
||
can't 500 the endpoint). `InternalsVisibleTo("ErsatzTV.Tests")` was added to the Application assembly
|
||
(mirroring Core's precedent) to unit-test that totality directly. (2) Two checks linked to **stale
|
||
Blazor routes** (`media/trash`, `search?query=…`) — repointed to the SPA `/app/trash` and
|
||
`/app/search?query=…` as `AppRoute`s. (3) A dead `Open Classic UI` → `/system/health` link lingered in
|
||
`SettingsScreen` (a #91b leftover that just 302'd to `/app`); removed (see `blazor-route-parity.md`
|
||
Section 4 correction).
|
||
|
||
**Actionable checks that had no link gained an `AppRoute`** (metadata → `/app/libraries`, empty
|
||
schedules → `/app/schedules`, HW-accel / VAAPI → `/app/ffmpeg-profiles`, FFmpeg reports → `/app/settings`).
|
||
Pure-noise / no-clean-action checks (UnifiedDocker, MacOsConfigFolder, FFmpegCapabilities, the Info-tier
|
||
nags) were left untouched — semantic-tier changes (e.g. adding a Pass path, demoting a nag) were
|
||
deliberately **not** bundled into a remediation-UX PR.
|
||
|
||
**Deferred (own issue): a TTL cache for `PerformHealthChecks`** (#108 — every `GET /api/v1/health`
|
||
re-runs all 14 checks, 4 shelling out to ffmpeg, and the existing summary cache is write-only dead
|
||
code). Orthogonal to the UX; filed separately so a SPA-polled health panel gets a cache before it
|
||
polls.
|
||
|
||
## 2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)
|
||
`key: spa.autotune-detailpanel-slideover` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The Auto-Tune DetailPanel SPA is a reusable `SlideOver` primitive sharing `useOverlayBehavior` with `Dialog`, plus a shared advanced-options model extracted from ChannelBuilder; decorative panes without backend support are dropped.
|
||
**Signals:** Auto-Tune DetailPanel, SlideOver, shared advanced-options hook · paths: `web/src/components/overlay.tsx`, `web/src/builder/advancedOptions.tsx` · issues: #386, #384, #385, #425, #135
|
||
**Mechanics:** spa-conventions.md §11; `useAdvancedOverrides` hook
|
||
|
||
The SPA half of the Auto-Tune per-channel editor. It builds only what the shipped `/api/v1` surface
|
||
(#384 members read, #385 per-channel `templateId`/`logo`/`advanced`) can actually carry, so the panel
|
||
never presents a control with nowhere to send its value.
|
||
|
||
- **New reusable primitive `SlideOver`** (`web/src/components/overlay.tsx`), sharing one
|
||
`useOverlayBehavior` hook with `Dialog` (focus/scroll-lock/Escape/scrim). Right-edge panels are a
|
||
recurring need; this is the seam, not a screen-local drawer. See spa-conventions §11.
|
||
- **Advanced-options logic is shared, not re-implemented** — the `AdvancedPanel` internals in the 84 KB
|
||
`ChannelBuilder.tsx` were extracted to `web/src/builder/advancedOptions.tsx` (enum catalogs,
|
||
`ADVANCED_KEYS`, `effectiveValue`, and the INHERIT/omit `useAdvancedOverrides` hook). ChannelBuilder now
|
||
imports them with zero behavioral change (its test suite passes byte-for-byte); the DetailPanel writes
|
||
its own field JSX over the same hook. The #135 "inherit = omit, no None option" contract therefore has
|
||
a single implementation. Field *layout* stays per-screen (presentation, not logic) — the deliberate,
|
||
stated deviation from full component reuse.
|
||
- **Shipped panes:** identity (name/number + `uploadArtwork(_, 'logo')` dropzone), Playback (Shuffle →
|
||
`advanced.playbackOrder`, Always-playing → `advanced.playoutMode`, merged into `advanced` only when
|
||
diverged from the template), per-channel template picker, the Advanced disclosure, a lean read-only
|
||
Query&size (order-from-axis + est items + effective streaming mode), and a read-only Content-sources
|
||
member list via `GET /members`.
|
||
- **Dropped as backend-less decoration** (the prototype had them; the wire contract does not): the MiniEpg
|
||
"example schedule", the bug-initials/colour generator, and the "generated smart-collection query" text —
|
||
the proposal DTO carries no query string, so rendering one would be fabricated. **Deferred to #425** (its
|
||
own end-to-end slice): the per-source rotation-weight steppers and exclude/add-untagged corrections; the
|
||
Content-sources pane shows a "#425" hint so its read-only state reads as intentional.
|
||
- **Unsaved-changes guard is screen-scoped** (spa-conventions §8/§11): per-channel edits live in
|
||
`AutoTuneScreen` draft state until bulk-create, so closing the panel keeps them (an "Edited" row badge
|
||
makes that visible) and only screen navigation / full-page unload with uncommitted edits confirms.
|
||
|
||
## 2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)
|
||
`key: spa.smartcollection-rule-builder` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The SmartCollection visual rule builder compiles to/from a closed subset of the Lucene grammar over the existing stored query string — no new AST, one level of group nesting.
|
||
**Signals:** SmartCollection, rule builder, Lucene compile/parse · paths: `web/src/builder/rules/`, `compile.ts`, `parse.ts`, `roundtrip.test.ts` · issues: #176, #69
|
||
**Mechanics:** `GET /api/v1/search/fields`; api-conventions.md; spa-conventions.md §12
|
||
|
||
The SmartCollection create/edit dialog gained a visual rule builder (`web/src/builder/rules/`)
|
||
alongside the existing raw-Lucene textarea. **The SmartCollection still stores a plain Lucene query
|
||
string — no new stored rule AST, no schema change.** The builder compiles its in-memory rule tree
|
||
into a **closed subset** of the Lucene grammar (`compile.ts`) and parses exactly that subset back out
|
||
(`parse.ts`, the exact inverse — returns `null`, not a best-effort guess, for anything outside the
|
||
subset); escaping is total, so any builder-authored query round-trips losslessly, proven by a 500-tree
|
||
property test (`roundtrip.test.ts`, including Lucene special characters). Opening an existing
|
||
SmartCollection tries the parse first and falls back to raw-text mode on `null` (fuzzy queries,
|
||
boosts, mixed AND/OR at one nesting level, or nesting deeper than one level).
|
||
|
||
**Why compile-only over persisting an authoritative rule AST**: an AST would still need a
|
||
Lucene→rules parser to open every *pre-existing* free-text query — including every query the
|
||
Auto-Tune feature (#69) generates — so the AST would buy almost nothing (it still can't represent
|
||
arbitrary hand-written Lucene) while costing a dual-provider EF migration and a second source of
|
||
truth to keep in sync with the Lucene grammar. Compile-only keeps the query string as the single
|
||
source of truth and treats the builder as a structured *editor* over it, not a new storage model.
|
||
|
||
**One-level-nesting "Kodi" model.** `types.ts` defines a top `Group` (`match: all|any`) over `Rule`s
|
||
and/or **one level** of sub-`Group`s — enough to express `type:movie AND (genre:Horror OR
|
||
genre:Thriller)`, which covers the smart-playlist patterns Kodi-style rule builders are known for.
|
||
Arbitrary/recursive nesting was scoped out as YAGNI; revisit only if a real query needs it.
|
||
|
||
**Field vocabulary comes from a new catalog endpoint, not a hardcoded list.** `GET
|
||
/api/v1/search/fields` (read-only, MCP-introspectable; see `api-conventions.md`) returns the curated,
|
||
typed, labeled field set derived from `LuceneSearchIndex` — name/label/type/group/values — and is the
|
||
single source of truth the builder's field pickers (`fieldCatalog.ts`'s `useSearchFields` hook) and
|
||
operator/value-input choices are driven from, so the builder's vocabulary can't drift from what the
|
||
index actually supports.
|
||
|
||
**Deferred as separate follow-up issues** (explicitly out of scope for #176): facet-value typeahead
|
||
for value inputs, relative-date operators, nesting deeper than one level, and inline adoption of
|
||
`RuleBuilder` by ChannelBuilder / Auto-Tune (it was built reusable for exactly that reuse — see
|
||
`spa-conventions.md` §12).
|
||
|
||
## 2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)
|
||
`key: sched.autotune-per-source-weights` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT.
|
||
**Signals:** Auto-Tune, per-source weights, MultiCollection, WeightedShuffle · paths: `AutoTunedChannelRequest`, `OwnedByChannelId` migration · issues: #425, #70, #383, #386, #385
|
||
**Mechanics:** `AddCollectionOwnedByChannelId` migration; `WeightedShuffleCollectionEnumerator`
|
||
|
||
Per-source rotation weights (`3× Show A, 1× Show B`) and query corrections (exclude / add-untagged) for
|
||
an auto-tune channel are supplied **at bulk-create time** — an optional `sources: [{sourceId, weight,
|
||
excluded}]` list on each `AutoTunedChannelRequest` (the same DetailPanel-wizard surface #385 added its
|
||
per-channel `advanced`/`templateId`/`logo` to). There is **no** post-hoc `PUT .../weights`, no lazy
|
||
upgrade/downgrade, and no idempotent desired-state handler: the auto-tune DetailPanel (#383/#386) is a
|
||
create-wizard, and #425's Done-when is "create → built playout". (An earlier plan draft assumed a PUT on an
|
||
existing channel; the shipped flow is create-time, matching #385.)
|
||
|
||
**Structure — Option A (reuse #70), not a new fake-collection weight path.** When any source is customized
|
||
(a non-default weight, an exclusion, or an added out-of-axis id), the channel is backed by a system-owned
|
||
`MultiCollection` of per-source `SmartCollection`s, weights on `MultiCollectionSmartItem.Weight`, and its
|
||
single-item lineup points at the `MultiCollectionId` with `PlaybackOrder.WeightedShuffle` — the exact path
|
||
`WeightedShuffleCollectionEnumerator` already consumes (`GetMultiCollectionCollections` forwards the per-row
|
||
weight). Rejected: threading a weight map through `GroupIntoFakeCollections` (the fake-collection path a lone
|
||
SmartCollection takes, which hardcodes weight 1) — it derives its group keys at runtime, is shared with the
|
||
unrelated `FillWithGroupMode`, and would need a parallel weight-persistence home. When **no** source is
|
||
customized (all weights 1, nothing excluded/added) the channel keeps the #69 single-SmartCollection
|
||
fair-share shape — cheaper, and identical output for TV since the fake path already groups per show.
|
||
|
||
**Discriminators.** A source's member query is discriminator-only (membership is fixed at tune time): TV uses
|
||
`type:episode AND show_title:"X"` (episode docs carry no parent-show id in the index — `show_title` is the
|
||
only per-show field, the same one #69's TvShow axis already uses; a post-create rename empties the member
|
||
until re-tuned), and movies use the stable, rename-proof `type:movie AND id:{mediaItemId}` (a movie is the
|
||
played item). The remainder is `({base}) AND NOT ({d1} OR {d2} …)` over every materialized ∪ excluded
|
||
discriminator — a partition of the base set, so no item is counted twice or dropped. Exclude = omit the
|
||
member **and** keep it in the NOT-list (else its items leak back through the remainder); add-untagged = an
|
||
ordinary member whose id isn't in the base set (no genre clause, so it just airs).
|
||
|
||
**Materialization bound is axis-dependent — "weight N" means N× *each* other source.** TV materializes
|
||
**every** base show as its own member (un-weighted shows keep per-show fair-share; a single merged remainder
|
||
would regress them to item-proportional, so a 200-episode show would swamp a 20-episode one) plus one live
|
||
remainder at weight 1 for shows/episodes added after tune-in (empty at create → harmlessly skipped by
|
||
`WeightedShuffleCollectionEnumerator`'s `ActiveSources` filter). MovieGenre materializes **only** the touched
|
||
movies (weighted or added) and leaves every un-touched base movie in ONE remainder whose weight = its member
|
||
count — exactly equivalent to materializing each movie individually, because the fake path already pools
|
||
movies uniformly, without hundreds of rows. Cost note: a large TV genre materializes one SmartCollection per
|
||
show (dozens–hundreds), each a cheap stored term query; a future optimization could cap/warn.
|
||
|
||
**Ownership + lifecycle.** The MultiCollection and its member/remainder SmartCollections carry a nullable
|
||
`OwnedByChannelId` (dual-provider migration `AddCollectionOwnedByChannelId`, indexed). Owned rows are hidden
|
||
from the user-facing collection lists and cascade-cleaned when the channel is deleted (the
|
||
`ProgramScheduleItem → MultiCollection` cascade removes the dangling flood item). Names embed a per-create
|
||
token (`at-mc:{token}`, `at:{token}:{n}`) because both names are unique `varchar(50)` and the channel id
|
||
isn't known until `CreateChannelFromLineup` runs; ownership is stamped immediately after. Non-weighted (#69)
|
||
channels set no ownership, so their pre-existing orphan-on-delete behavior is unchanged. The create is
|
||
non-atomic across the two handlers (mirrors #69) with best-effort rollback of the artifacts on channel-create
|
||
failure.
|
||
|
||
## 2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)
|
||
`key: api.search-allitems-paging` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response.
|
||
**Signals:** search all-items, pagination, DoS hardening · paths: `SearchController.SearchAllItems`, `LuceneSearchIndex`, `web/src/api/search.ts` · issues: #293, #285, #308, #384
|
||
**Mechanics:** `MaxAllItemsPageSize`/`DefaultAllItemsPageSize` clamps; `getAllSearchItemIds`
|
||
|
||
`GET /api/v1/search/all-items` (`SearchController.SearchAllItems` → `QuerySearchIndexAllItemsHandler`) fired
|
||
ten index searches with **`limit: 0`** (= "return every hit", `LuceneSearchIndex` line ~244), so a single
|
||
broad query (e.g. one matching the whole library) materialized *every* matching doc across all ten media
|
||
kinds into ten `List<int>` buckets and serialized them in one response — unbounded work per request. #285
|
||
closed the original *unauthenticated* exposure (the endpoint is now behind `Api:RequireKeyForReads`, default
|
||
true); the residual was DoS-hardening against an **authenticated** caller with a very broad query. Deferred
|
||
from #285 because the SPA "add all to collection/playlist" flow materializes the full id set before the add
|
||
POST, so a naive hard cap would silently truncate "add all".
|
||
|
||
**Decision (issue option (a), operator-confirmed): paginate the endpoint and teach the SPA add-all flow to
|
||
page to completeness** — rather than option (b) (a generous cap + truncation signal). Chosen because
|
||
"add all" must stay complete for real use, and it matches the sibling `GET /api/v1/search` /
|
||
`GET /api/v1/channels/auto-tune/members` (#384) paging convention already in the codebase.
|
||
|
||
- **Endpoint (additive).** `SearchAllItems` gains optional `pageNum` (0-based) + `pageSize`, clamped exactly
|
||
like the §1 Logs / sibling `Search` precedent: `pageSize = Math.Clamp(pageSize, 1, MaxAllItemsPageSize)`
|
||
with `MaxAllItemsPageSize = 1000`, `DefaultAllItemsPageSize = 500`. `pageNum` is clamped
|
||
`Math.Clamp(pageNum, 0, MaxAllItemsPageNum)` with `MaxAllItemsPageNum = 2_000_000` — the upper bound keeps
|
||
`pageNum * pageSize` (the search skip) inside `int` range so an absurd page number can't overflow to a 500
|
||
(the sibling `Search` only floors at 0; the all-items endpoint hardens the upper bound too since this is a
|
||
DoS-hardening change). The clamp is applied per media kind (a page returns ≤ `pageSize` ids of
|
||
*each* of the ten kinds), so one response is bounded to ≤ 10 × `pageSize` ids. `QuerySearchIndexAllItems`
|
||
carries `PageNum`/`PageSize`; the handler passes `skip = PageNum × PageSize`, `limit = PageSize` into
|
||
`ISearchIndex.Search` (native skip/limit) and reads `SearchResult.TotalCount` (the true total, free) per
|
||
kind.
|
||
- **Response (additive, frozen-v1-safe).** The ten `…Ids` buckets are unchanged; a new non-null nested
|
||
`Totals` (`SearchResultAllItemsTotalsResponseModel`, ten `…Count` ints) is added so a client knows how many
|
||
ids exist per kind and can page to completeness. Nothing is removed or retyped (#286 additive-only holds).
|
||
- **Deliberate default-behavior change.** A caller that sends no `pageSize` now gets one page (default 500 /
|
||
kind) plus `Totals`, not the entire id set. This is the security change the issue asks for; it is safe here
|
||
because the only in-repo consumer is the SPA (updated in the same PR) and any external/MCP caller can read
|
||
`Totals` and page. Recorded as intentional, not a regression.
|
||
- **SPA pages to completeness.** `web/src/api/search.ts` `getSearchAllItems(query, pageNum, pageSize)` gains
|
||
the params; a new `getAllSearchItemIds(query)` loops pages (requesting `pageSize = 1000`, the server max),
|
||
accumulating every bucket until each kind has collected its `Totals` count (with an empty-page safety break
|
||
against total-count drift), and returns the merged `SearchAllItemIds`. `SearchScreen.addAll` calls it
|
||
instead of the single-shot fetch; the #221 stale-query guard and the single add POST are unchanged.
|
||
- **Out of scope (unchanged):** the add POST itself still accepts the full merged id set in one request body
|
||
— bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
|
||
|
||
## 2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)
|
||
`key: spa.sidebar-collapsible-accordions` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The shell sidebar's collapse + nav-group-accordion state persists under two hyphenated `ctv-sidebar-*` localStorage keys (matching the repo's `ctv-` convention, not the prototype's dotted names); labeled groups default-collapsed.
|
||
**Signals:** sidebar, collapsible nav groups, localStorage keys · paths: `web/src/app/AppShell.tsx`, `web/src/app/sidebarState.ts` · issues: #396
|
||
**Mechanics:** spa-conventions.md §5d; `ctv-sidebar-collapsed` / `ctv-sidebar-groups` keys
|
||
|
||
The shell sidebar (`web/src/app/AppShell.tsx`) gained (a) a header toggle that collapses it to a 60px
|
||
icon rail and (b) collapsible accordions per **labeled** nav group (Media, System); the unlabeled
|
||
**Primary** group is always open. Mirrors the Claude Design prototype's updated `Sidebar`.
|
||
|
||
- **State lives in a small hook, not App.** `web/src/app/sidebarState.ts` `useSidebarState()` owns both
|
||
pieces of state + persistence; `AppShell` consumes it (nothing else needs it) and stamps
|
||
`ctv-app-shell-collapsed` on the shell root so the collapse is CSS-driven from one class.
|
||
- **Persistence keys use the established `ctv-` hyphen convention, NOT the prototype's dotted names.**
|
||
The issue quoted `ctv.sidebar.collapsed` / `ctv.sidebar.groups`, but every existing client-local pref
|
||
is hyphenated (`ctv-theme`, `ctv-logs-page-size` — spa-conventions §5d), so we use
|
||
**`ctv-sidebar-collapsed`** (`"1"`/`"0"`) and **`ctv-sidebar-groups`** (JSON `{groupKey: boolean}`,
|
||
boolean = *collapsed*). Deliberate deviation from the issue's literal key text in favour of the repo
|
||
convention the issue itself points to; helpers validate/parse defensively (bad JSON / non-boolean
|
||
values → default).
|
||
- **Labeled groups default to COLLAPSED** (absent `ctv-sidebar-groups` entry ⇒ collapsed), so a fresh
|
||
load shows only Primary — matching the prototype ("default-collapsed, leaving only Primary visible").
|
||
A behavior change for existing users; `App.test.tsx`'s shell/nav suite seeds the two groups open
|
||
because it clicks Media/System nav links directly (the accordion behavior is covered in its own
|
||
describe).
|
||
- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored — every
|
||
item renders as an icon (label kept in the a11y tree via an sr-only span so the accessible name/tests
|
||
survive; surfaced as a native `title` tooltip), groups separated by a hairline divider, numeric
|
||
badges shown as a corner dot. The active-route indicator (left rail bar + active background) works in
|
||
both states.
|
||
- **Group keys are explicit + stable** (`SidebarNavGroupDefinition.key`: `'media'`, `'system'`) rather
|
||
than derived from the label, so renaming a label doesn't silently orphan persisted state.
|
||
- No route/screen was added or redirected (shell-chrome only), so no `blazor-route-parity.md` change.
|
||
|
||
## 2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)
|
||
`key: sched.playbackorder-support-matrix` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction.
|
||
**Signals:** PlaybackOrder, support matrix, build-time dispatch · paths: `ErsatzTV.Core/Scheduling/PlaybackOrderSupport`, `PlaylistEnumerator.Create`, `BlockPlayoutBuilder` · issues: #403, #70
|
||
**Mechanics:** `PlaybackOrderSupportTests` (partition assertion)
|
||
|
||
`#70` closed the *persistence* hole for `WeightedShuffle` (the write path rejects it on the engines that
|
||
can't handle it) and made **YAML + Scripted** log a warning; `MultiCollectionGroup` already threw. It left
|
||
the three still-**silent** build-time dispatch sites — the ones this issue names — as an explicit non-goal.
|
||
`#403` makes those loud and adds the by-construction net.
|
||
|
||
- **Loud, but NON-FATAL, at every build site.** On an order it doesn't handle, each site now logs a
|
||
`Warning` (naming the order + engine + the fallback taken) instead of silently substituting/dropping/
|
||
skipping: Classic's `PlayoutBuilder` `default:` arm (was `// TODO`, silently returned Random), the
|
||
`PlaylistEnumerator.Create` switch (had **no** `default:` arm → null enumerator → item dropped), and
|
||
`BlockPlayoutBuilder`'s allow-list `continue` (silent skip). The actual fallback is **preserved** — a
|
||
live channel airing wrong-but-something beats going dark on one misconfigured item — so scheduler goldens
|
||
do not move. This matches the log-and-continue posture `#70` already shipped for YAML/Scripted.
|
||
- `PlaylistEnumerator.Create` was `static` with **no logger**, which is *why* the playlist drop was
|
||
unreportable. It gained an optional `Option<ILogger> logger = default` (mirrors the existing
|
||
`GetStartTimeAfter(..., Option<ILogger>.None)` pattern); the three callers that have a logger pass it,
|
||
the rest default to `None`.
|
||
- `BlockPlayoutBuilder`'s `GetEnumerator` switch gained an explicit `PlaybackOrder.Random` arm. Random is
|
||
in Block's allow-list but previously reached an enumerator only via the switch's coincidental `_ =>`
|
||
Random fallback; the fallback is now a loud helper (defensive — the allow-list should make it
|
||
unreachable).
|
||
|
||
- **Safe-by-construction net: a declared support matrix + a tripwire test.** `PlaybackOrderSupport`
|
||
(`ErsatzTV.Core/Scheduling/`) declares, per `SchedulingEngineKind` (Classic / Playlist / Block / Yaml /
|
||
Scripted), BOTH a `Supported` and an `Unsupported` set. `PlaybackOrderSupportTests` asserts the two sets
|
||
**partition** every `PlaybackOrder` value (union total, intersection empty) for every engine — so adding a
|
||
new enum value lands in neither set and **fails the test until it is consciously classified** and wired
|
||
into the matching dispatch switch. Both sets are hand-maintained on purpose: deriving `Unsupported` as
|
||
"everything not supported" would let a new order fall through silently, which is the exact defect being
|
||
killed. The matrix is not pure test scaffolding — `BlockPlayoutBuilder` consumes it at runtime for its
|
||
allow-list (replacing the previously duplicated inline list).
|
||
|
||
- **Write-path rejection is deliberately UNCHANGED.** `#70`'s per-engine guards already close the real
|
||
persistence exposure, and the decisions log records that the "which order reaches which engine" perimeter
|
||
has been wrong three times — always by enumerating from a list instead of grepping every writer.
|
||
Broadening write-path rejection here would risk newly-`400`ing configs that currently save-and-fall-back,
|
||
for no safety gain, so it was left alone. This PR hardens the *build-time* sites and adds the tripwire.
|
||
|
||
- **Reverse-mappings deferred (a different axis).** The three `_ => PlaybackOrder.None` arms that map an
|
||
enumerator *class* back to a `PlaybackOrder` for `PlayoutHistory` were left alone: that path is reached in
|
||
normal operation by legitimately-supported enumerator types (`ShuffleInOrderCollectionEnumerator`,
|
||
`SeasonEpisodeMediaCollectionEnumerator`) that simply aren't reverse-mapped, so making it "loud" would
|
||
emit false-positive warnings. Completing that reverse map is a separate concern from "an unsupported
|
||
*order* degrades silently" and is not part of #403's scope.
|
||
|
||
## 2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip
|
||
`key: ci.build-once-rejected` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** CI build-once (a shared compile artifact across jobs) was implemented, measured, and rejected for a 40-85% wall-clock regression; keep the #420 cross-run tree-identity skip instead.
|
||
**Signals:** CI build-once, artifact tar/transport cost, tree-identity skip · paths: `docs/ci-cd.md` → Cross-run tree-identity skip · issues: #420, #398, #455
|
||
**Mechanics:** docs/ci-cd.md → Cross-run tree-identity skip; PR #455 measurement
|
||
|
||
Build-once (a `compile` job producing a single artifact, consumed by `test`/`migrations`/
|
||
`functional-e2e` via `--no-build`) was fully implemented and went **green on CI** (PR #455, run
|
||
830), then **rejected on measurement**: it traded a ~12% slot-occupancy saving for a ~40–85%
|
||
**per-run wall-clock regression**.
|
||
|
||
- **Why it regressed.** The `bin`+`obj` artifact is 2.5 GB raw / 972 MB gz; tar alone costs ~82s CPU
|
||
plus ~180s transport, consuming most of the ~465s the shared compile was meant to save. Worse,
|
||
`compile` serializes **before** `migrations`' long ef-replay, which is pure DB work a shared build
|
||
cannot shorten — the bottleneck was never the redundant compiles.
|
||
- **Incidental finding worth recording.** `actions/upload-artifact@v4` does not work on this Gitea
|
||
instance — it throws `GHESNotSupportedError`, because the `@actions/artifact` v2 client library
|
||
rejects any non-`github.com` host. `@v3` is required for any future artifact use here.
|
||
- **Kept: the #420 cross-run tree-identity skip** (`docs/ci-cd.md` → Cross-run tree-identity skip).
|
||
It is independent of build-once — it only *skips* redundant work on identical-tree main pushes, at
|
||
zero wall-clock cost, rather than trying to share a build across jobs. Don't re-attempt build-once
|
||
unless the runner's artifact storage or network changes materially.
|
||
|
||
Refs: #398 (closed), #420, PR #455.
|
||
|
||
## 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)
|
||
`key: media.lastscan-null-boundary` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A never-scanned `LastScan` surfaces as `null` at the API/MCP boundary, not the `0001-01-01` MinValue sentinel — enforced by an ongoing read-boundary coercion plus a one-time data migration cleanup.
|
||
**Signals:** LastScan, MinValue sentinel, API-boundary honesty · paths: `GetAllMediaSourcesForApiHandler.NormalizeLastScan`, `MediaSourceRepository` · issues: #409
|
||
**Mechanics:** `NullOutNeverScannedLastScan` migration; `LibrariesScreen.tsx` `hasScanned` removal
|
||
|
||
`Library.LastScan` / `LibraryPath.LastScan` are `DateTime?`; a never-scanned library is `null` at
|
||
runtime for a freshly-created row. But the `0001-01-01 00:00:00` MinValue sentinel still appears in the
|
||
DB from **two** sources — and the second is ongoing, not historical:
|
||
1. Old `Reset_*` migrations wrote it via raw SQL (`UPDATE Library SET LastScan = '0001-01-01 00:00:00'`).
|
||
2. **Live code still writes it today**: `MediaSourceRepository` sets `library.LastScan =
|
||
SystemTime.MinValueUtc` on the Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows
|
||
(`MediaSourceRepository.cs:480/611/976`). So the sentinel keeps being written during normal use.
|
||
|
||
`GetAllMediaSourcesForApiHandler` projected `l.LastScan` straight onto its `DateTime?` DTO, so that
|
||
sentinel leaked to API/MCP clients as a fake midnight timestamp — the SPA papered over it with a
|
||
client-side year<1900 heuristic (#409 first pass). Decision: the API is the right place to be honest, so
|
||
**never-scanned reports as null** for API/MCP parity with the UI, via two layers:
|
||
|
||
- **Read-boundary coercion — the load-bearing, ongoing guard** (provider/history-independent):
|
||
`GetAllMediaSourcesForApiHandler.NormalizeLastScan` maps any `< 2000-01-01` value to null. Because
|
||
source #2 above keeps writing the sentinel, this coercion is *permanent*, not a stopgap — a
|
||
migration-only fix would regress the next time a user toggles a library's sync off.
|
||
- **Data migration** (`NullOutNeverScannedLastScan`, dual-provider): a one-time cleanup of the historical
|
||
residue — `UPDATE Library/LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan <
|
||
'2000-01-01'`. Data-only (empty `Up`/`Down` otherwise, both `TvContextModelSnapshot.cs` byte-identical).
|
||
The `< '2000-01-01'` predicate matches the `0001-01-01` sentinel robustly on both providers (ISO-text
|
||
compare on SQLite, whatever the out-of-range zero date stored on MySQL — where no `Reset_*LastScan`
|
||
migration ever ran, so it's a safe no-op there) without depending on the exact stored bytes; no real
|
||
scan predates ErsatzTV. `Down` is a no-op — the original sentinel is unrecoverable and worthless.
|
||
|
||
`GetAllMediaSourcesForApiHandler` is the **only** API/MCP-facing consumer of `LastScan` (grepped
|
||
`LastScan` under `ErsatzTV.Application/**/Queries` and `ErsatzTV/Controllers`); the other reads are
|
||
internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non-nullable
|
||
`DateTimeOffset` scan-comparison needs and never serializes it to a client. The DTO field was already
|
||
`DateTime?`, so the OpenAPI schema is unchanged (no regen). The SPA's `hasScanned` heuristic in
|
||
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
|
||
is honest. (Follow-up option, not done here: have `MediaSourceRepository` write `null` instead of
|
||
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
|
||
|
||
## 2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)
|
||
`key: sched.weightedshuffle-editor` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode.
|
||
**Signals:** WeightedShuffle SPA, multi-collection weights, fair-share reset · paths: `web/src/.../itemRules.ts` `MULTI_COLLECTION_ORDERS`, `itemsFromMultiCollection`/`toItemRequest` · issues: #404, #70, #402
|
||
**Mechanics:** spa-conventions.md §4 (replace-all-DTO trap); `fillWithGroupModeEligible` exclusion
|
||
|
||
The UI half of #70 (backend + API shipped in PR #402). No new endpoint or DTO — `weight` was already on
|
||
`MultiCollectionItemRequest`/`…ResponseModel` and `WeightedShuffle` already in the `PlaybackOrder` enum; this
|
||
is purely SPA (+ docs).
|
||
|
||
- **Two surfaces, split by where each datum lives.** The per-source *weights* are a property of the
|
||
`MultiCollection`, so they are edited in the multi-collection editor (`/app/multi-collections`), not on the
|
||
schedule item. The *order* (`WeightedShuffle`) is a property of the schedule item, so it is added to the
|
||
classic schedule item's Playback Order options. Putting weights on the schedule item was rejected: the same
|
||
multi-collection can be referenced by many schedule items, and weights are shared, not per-reference.
|
||
- **`WeightedShuffle` is offered ONLY for `collectionType === 'MultiCollection'`** (`itemRules.ts`
|
||
`MULTI_COLLECTION_ORDERS`) — a *meaningfulness* scope, not a rejection mirror. Weights only exist on
|
||
multi-collection members, so the order only does something with 2+ weighted sources; on a single collection
|
||
the classic engine still **accepts** it (verified by live-E2E: `PUT /schedules/{id}/items` with a plain
|
||
Collection + `WeightedShuffle` → 200) but it degrades to fair-share (≈ `Shuffle`), a confusing no-op — so
|
||
the SPA doesn't offer it there. The *rejection* the #70 entry describes is on the separate **playlist and
|
||
block** write paths (different engines); the playlist/block editors keep their own order lists (not
|
||
`MULTI_COLLECTION_ORDERS`) and already omit `WeightedShuffle`, so nothing extra was needed there.
|
||
- **`WeightedShuffle` joins `ShuffleInOrder` in the `fillWithGroup` exclusion.** `fillWithGroupModeEligible`
|
||
already excluded `ShuffleInOrder`; WeightedShuffle is excluded for the same structural reason —
|
||
`PlayoutBuilder` splits a fill-with-group item into per-group enumerators scheduled one group at a time,
|
||
which is incompatible with WeightedShuffle's *whole-multi-collection* per-source share of airtime. Offering
|
||
the combination would silently not do what the name says.
|
||
- **Fair-share is a "Reset to fair share" button, not a stateful toggle.** decisions.md 2026-07-17 established
|
||
that fair-share is not a separate mode — it is `WeightedShuffle` with all weights left at the default 1. So
|
||
there is nothing to toggle *on*: the button just sets every source's weight to 1 (disabled when already
|
||
there). A persistent toggle was rejected because "fair share" has no stored representation distinct from
|
||
"the weights all happen to be 1".
|
||
- **Weights round-trip through the editor draft or they silently reset.** The multi-collection PUT replaces
|
||
the whole item list, so `weight` is read in `itemsFromMultiCollection` and written in `toItemRequest`;
|
||
dropping it would reset every source to 1 on the next unrelated save (rename, add a source). Covered by a
|
||
round-trip test. This is the general replace-all-DTO trap now recorded in `spa-conventions.md` §4.
|
||
- **Percentages are display-only; the wire format is the integer weight.** A 3:1 weight shows as 75% / 25%,
|
||
computed from the clamped weights; the request always carries the integer relative share (1..1000). The
|
||
weight field is held as a string in the editor draft so it edits smoothly, clamped to the API validator's
|
||
1..1000 on blur and again at save — an out-of-range value never reaches the server as a raw 400. `Input`
|
||
gained `min`/`max`/`inputMode`/`onBlur` passthroughs for this (reusable by #425's weight UI).
|
||
|
||
## 2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)
|
||
`key: api.healthcheck-ttl-cache` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Health-check results are held in a 30s TTL cache inside `HealthCheckService`; a non-forced `GET /api/v1/health` returns the cached list, and `?refresh=true` (or a forced internal caller) bypasses it to run fresh.
|
||
**Signals:** health check caching, TTL, refresh query param · paths: `HealthCheckService._memoryCache`, api-conventions.md §1/§3b · issues: #431, #164
|
||
**Mechanics:** `PerformHealthChecks(forceRefresh, ...)`; `GET /api/v1/health?refresh=true`
|
||
|
||
`HealthCheckService.PerformHealthChecks` re-ran all 14 checks on **every** call, four of which shell out to
|
||
`ffmpeg`/`ffprobe` via CliWrap — so a bare `GET /api/v1/health` spawned ~4 subprocesses per request. The
|
||
existing `HealthCheckSummary` cache was **write-only** (populated + published, never read back to short-circuit
|
||
a re-run). Harmless while the SPA Dashboard health panel refreshes on-demand only, but a real cost the moment
|
||
anything *polls* health (a status widget, an MCP client, monitoring). Split out of #164 as the orthogonal
|
||
performance half.
|
||
|
||
- **A short TTL cache of the full result list lives inside `HealthCheckService`.** A `_memoryCache` entry
|
||
(`"healthcheck.results"`, `TimeSpan.FromSeconds(30)`) holds the last `List<HealthCheckResult>`; a non-forced
|
||
call returns it directly on a hit, skipping both the 14 checks and the summary `Publish`. Chosen over
|
||
"make the existing summary cache read-through" because the API returns the full per-check list, not the
|
||
2-int summary — the summary entry (`"healthcheck.summary"`, read by `GetHealthCheckSummary`) is kept as-is
|
||
(un-expiring) so its fallback behavior is unchanged.
|
||
- **`PerformHealthChecks` gained a `bool forceRefresh` first parameter** (interface signature change; one
|
||
implementer, 3 live callers). `forceRefresh: true` bypasses the cache and repopulates it.
|
||
- **The refresh surface is an optional `?refresh=` query param on the existing GET**, following the
|
||
`?deep=` bool-query-param exemplar (`api-conventions.md` §1/§3b) — additive, backward-compatible, no new
|
||
endpoint. `[FromQuery] bool refresh` → `GetAllHealthCheckResultsForApi(Refresh)` → `PerformHealthChecks(request.Refresh, …)`.
|
||
The SPA "Refresh health" button calls `/api/v1/health?refresh=true`; the initial/poll load calls the bare
|
||
path (cached). A separate `POST …/refresh` endpoint was rejected as unnecessary surface for a read.
|
||
- **Who forces vs. who reads the cache:** the API GET poll path reads the cache; the **startup**
|
||
`RunHealthChecksService` and the **troubleshooting** support bundle force a fresh run (both want current
|
||
state — startup is a cold cache anyway, and a diagnostic bundle should reflect *now*, not a ≤30s-old poll).
|
||
The legacy `GetAllHealthCheckResults` handler is dead (no senders) and reads the cache.
|
||
- **Thundering-herd on a cold cache was left out of scope** (no request-coalescing lock): polling is sequential
|
||
per client and the TTL collapses steady-state load, so at most a handful of exactly-simultaneous cold callers
|
||
re-run — a once-per-30s edge, not the repeated per-request cost the issue targets. Recorded here so a later
|
||
reviewer doesn't read the absence of a `SemaphoreSlim` as an oversight.
|
||
|
||
## 2026-07-19 — The `format` gate runs `dotnet format whitespace . --folder`, not the full solution format (#469)
|
||
`key: ci.format-gate-folder-mode` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage.
|
||
**Signals:** dotnet format, folder mode, CI format gate · paths: `.gitea/workflows` format job, `.editorconfig` · issues: #469, #406, #311
|
||
**Mechanics:** `dotnet format whitespace . --folder --verify-no-changes --include <files>`
|
||
|
||
The blocking `format` CI job (and the matching Husky pre-commit hook) verify changed `.cs` files with
|
||
`dotnet format whitespace . --folder --verify-no-changes --include <files>` instead of the previous
|
||
`dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include <files>`.
|
||
|
||
- **Why.** `--include` narrows *which* files are checked, never what gets loaded. The full recipe loaded
|
||
the whole ~10-project MSBuild workspace and built a Roslyn compilation per project before checking a
|
||
single line — a fixed cost independent of how few files changed. Measured **~480s** for a whole-solution
|
||
`dotnet format` locally (matching the issue's "7+ min"). `--folder` treats the tree as a plain folder of
|
||
files and skips MSBuild/Roslyn entirely: **~0.5s**, and it needs no `dotnet restore`, so the job's
|
||
NuGet-cache + Restore steps were deleted. It also drops the job's ~3.95 GiB Roslyn heap (the #406
|
||
memory note about `format` not shrinking is now moot).
|
||
- **Coverage is unchanged, not merely "good enough".** Folder mode reads `.editorconfig` and enforces
|
||
exactly the two things this gate exists for — **whitespace** (indent/EOL/trailing/final-newline) and
|
||
**charset** (no UTF-8 BOM). Proven non-vacuous: exits non-zero with `error WHITESPACE` on an injected
|
||
trailing-whitespace line and `error CHARSET` on a prepended BOM; exits 0 on a clean file. What it drops
|
||
is the style/analyzer pass — but the *full* gate never enforced that either: a probe injecting a
|
||
`warning`-severity naming violation (`local_constants` not `ALL_UPPER`) **passed** the full solution
|
||
format (exit 0): the only `.editorconfig` rule above `:suggestion`/`:none` severity is that one naming
|
||
rule, and naming violations have no `dotnet format` batch code-fixer, so `--verify-no-changes` reports
|
||
no change regardless of severity. The analyzers that must block (`NU1904`,
|
||
`S3981`) are enforced at compile time via `WarningsAsErrors` in `Directory.Build.props`, never by this
|
||
job.
|
||
- **Fix command for a violation:** `dotnet format whitespace . --folder --include <files>`. The full
|
||
`dotnet format ErsatzTV.sln --include <files>` is a superset (also applies style) and still works, so
|
||
existing muscle memory and the #311 lore's `dotnet format --include` guidance are not broken.
|
||
- **Lane left on `ubuntu-latest`.** The job is now seconds-long and low-memory, so it could move to a
|
||
lighter lane, but that re-touches the per-lane memory-cap accounting (#406/#604) and is a
|
||
server-management capacity call — deliberately out of scope here.
|
||
|
||
## 2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)
|
||
`key: ci.peak-anon-measurement` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The `test` job's headline memory figure is a sampled high-water mark of cgroup `anon`, produced by `scripts/ci-peak-anon.sh`; `memory.peak` and the end-of-job `anon`/`file` split are kept only as a cache-inflated reference.
|
||
**Signals:** CI memory measurement · paths: `scripts/ci-peak-anon.sh`, `.gitea/workflows/*.yml` · issues: #412, #411 (prose-only predecessor, no standalone record — its `memory.peak`-headline approach is superseded by this record)
|
||
**Mechanics:** `scripts/ci-peak-anon.sh` header; `docs/ci-cd.md` → CI build memory
|
||
|
||
**Decision.** The `test` job's memory instrument (added in #411) now reports a **sampled high-water
|
||
mark of the cgroup's `anon` memory** as the headline figure, produced by `scripts/ci-peak-anon.sh`
|
||
(a `start` step before the dotnet Build/Test/Coverage, a `report` step last). `memory.peak` and the
|
||
end-of-job `anon`/`file` split stay in the output as a cache-inflated ceiling and a reference.
|
||
|
||
**Why not just `memory.peak`.** `memory.peak` is the high-water mark of `memory.current`, which
|
||
charges reclaimable **page cache** to the cgroup alongside anon. A build does heavy
|
||
NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak — and page cache is *reclaimed* under
|
||
a tighter cap, not OOM-killed. Sizing a per-job cap (server-management#604) off `memory.peak`
|
||
therefore **inverts the decision**: a big, mostly-`file` peak reads like "the cap must stay high"
|
||
when it isn't. The OOM-forcing quantity is peak **anon**. The kernel exposes `memory.peak` but has
|
||
**no peak-anon counter**, and the end-of-job `anon` is the composition *then*, not at the peak
|
||
instant (a job that peaks mid-`dotnet test` then frees reports a misleadingly low `anon`) — so it
|
||
must be **sampled**. Details + the sampler's robustness rationale: `scripts/ci-peak-anon.sh` header
|
||
and `docs/ci-cd.md` → "CI build memory".
|
||
|
||
**Implementation note (do not "simplify" back to `memory.peak`).** The sampler is a detached
|
||
`nohup` poller that survives step-boundary re-execs (reparents to the container's PID 1) and is
|
||
reaped at container teardown; a TERM trap + `sleep & wait` stops it at once on `report`. Both steps
|
||
are `continue-on-error` with a fail-open script, so the instrument can never redden a green build.
|
||
Validated on bumblebee: it catches a transient 2.5 GiB anon spike that the end-of-job snapshot
|
||
reports as 0.
|
||
|
||
**Compiler-server A/B verdict (refines the #406 entry's "premise looking dead" read).** Measured
|
||
in the CI image, swap-off, sampled peak-anon, n=2 interleaved: **OFF (the CI config) ≈ 5.84 GiB,
|
||
consistent; ON (defaults) 6.3–7.6 GiB, always higher, + a ~3 GiB resident `VBCSCompiler`.**
|
||
Disabling the servers is worth it (consistent reduction, no resident server), but OFF sits *right at
|
||
6 GiB for the build phase alone* and the `test` job adds test + coverage on top — so #406's premise
|
||
("disabling brings peak *well under* 6 GiB → the budget loosens") is **not supported**. Size the cap
|
||
off the live test-job peak-anon this instrument now reports, not off the build-only A/B. The older
|
||
#411 probe (`anon 7134 MiB`) read higher than these swap-off sampled numbers and is superseded
|
||
(swap/read-method move the figure >1 GiB).
|
||
|
||
## 2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473)
|
||
`key: media.remote-stream-probe` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `ValidatePlayoutItemPath` probes the Plex/Jellyfin/Emby remote-stream URL via `IRemoteStreamProber` before returning it; only a redirected 404 fails closed (`PlayoutItemNotAvailableFromMediaServer`), everything else fails open, and there is no toggle.
|
||
**Signals:** remote-stream probing, fail-open/fail-closed, exit-8 ffmpeg loop · paths: `GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, `IRemoteStreamProber` · issues: #473, #350, #480
|
||
**Mechanics:** `HttpRemoteStreamProberTests` (`Should_Fail_Open_*` / `Should_Fail_Open_On_404_That_Was_Not_Redirected`)
|
||
|
||
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath` now probes the Plex/Jellyfin/Emby
|
||
remote-stream URL via the new `IRemoteStreamProber` seam before returning it, and on a 404 **from the media
|
||
server** returns the new `PlayoutItemNotAvailableFromMediaServer` error instead of a playable path.
|
||
|
||
- **The bug this closes.** The method's local branch checked `_fileSystem.File.Exists(path)`, but the
|
||
three remote-stream branches returned `http://localhost:{port}/media/{plex,jellyfin,emby}/{id}`
|
||
*unconditionally*. When the media was gone from the media server too, validation "succeeded", ffmpeg was
|
||
launched against a URL that 404s and died with **exit 8** — landing in `HlsSessionWorker`'s generic
|
||
ffmpeg-failure path, which sizes its error card to the failed **44s work-ahead chunk** and then
|
||
re-selects the *same* broken item, so a dead item produces a repeating error card for its whole slot
|
||
(~22 min for an episode). The fix restores the method's own invariant — every `PlayoutItemWithPath` it
|
||
returns has been checked for existence — so the failure now returns an **error** rather than a playable
|
||
URL, and the handler's error path sizes the card to run until the **next** playout item.
|
||
- **What the new `case` label does and does not do** (a review corrected an earlier draft of this entry):
|
||
the skip-to-next-item sizing comes from `maybeDuration`/`finish`, computed *before* the switch — the
|
||
`default:` arm already had it. Adding `case PlayoutItemNotAvailableFromMediaServer:` alongside
|
||
`PlayoutItemDoesNotExistOnDisk` changes only the **caption** on the card (the real error text instead of
|
||
"Channel is Offline"). Worth having, but not where the fix lives. A handler test asserts that message
|
||
specifically, so the label cannot silently decay into dead weight.
|
||
- **Scope: the generated-playout path only.** All three remote branches of `ValidatePlayoutItemPath` are
|
||
covered. `ExternalJsonPlayoutItemProvider` builds its own `/media/plex/...` URL and its result is
|
||
assigned *without* passing through `ValidatePlayoutItemPath`, so external-JSON channels keep the old
|
||
behaviour — tracked as #480 rather than silently claimed as fixed. The troubleshooting and
|
||
subtitle-extraction paths build the same URLs and are deliberately **left unprobed**: troubleshooting
|
||
should surface the raw ffmpeg failure, and a subtitle-extraction miss is not a channel outage.
|
||
- **Fail-open on everything except a *redirected* 404.** A timeout, a 5xx, an auth error or a transport
|
||
failure returns *available* — and so does a **404 that arrived without a redirect**. ErsatzTV's own
|
||
`InternalController` returns `NotFound` when the media source is unconfigured or momentarily missing, so
|
||
honouring that would fail *closed* for every item on that source; the first draft probed for "any 404",
|
||
which made this contract untrue. Only a 404 reached *after* the redirect to the media server is evidence
|
||
the item is gone. Pinned by tests (`Should_Fail_Open_*`,
|
||
`Should_Fail_Open_On_404_That_Was_Not_Redirected`) so a later refactor can't quietly invert it. Caller
|
||
cancellation is **not** swallowed — it propagates, because a shutdown is a genuine signal, not a probe
|
||
failure.
|
||
- **No `ConfigElement` toggle.** The behaviour strictly dominates the status quo, and the probe is bounded
|
||
by a 2s linked-CTS timeout (not `HttpClient.Timeout`, which defaults to 100s). A toggle would be surface
|
||
for a switch nobody has a reason to flip. **Not yet measured:** on an install without path replacement
|
||
every item selection pays this probe, and a slow media server could cost up to the full 2s. That is a
|
||
worst case rather than a typical one, but it has not been measured against #350's cold-start budget —
|
||
measure before assuming it is noise.
|
||
- **Rejected — resizing the `HlsSessionWorker` retry loop to skip the item on any ffmpeg failure.** It buys
|
||
the viewer nothing the above doesn't (the slot is dead either way) and cannot distinguish "this item is
|
||
dead" from "this transcoder hiccupped". Prod shows real *transient* channel-wide failures (VAAPI
|
||
`hwupload -22` / exit 234), and retrying those is correct; converting them into whole-item blackouts is a
|
||
regression. The retry loop is deliberately left intact — it also remains the backstop for the probe's
|
||
TOCTOU window (media can vanish between a 200 probe and ffmpeg's own request).
|
||
- **Rejected — writing `MediaItemState` from the streaming path.** Only the scanner writes `State`, and
|
||
breaking that ownership wouldn't even have fixed this: the failing item is `RemoteOnly`, which
|
||
`PlayoutBuilder`'s `PlayoutSkipMissingItems` skip does not exclude. On Jellyfin (`ServerSupportsRemoteStreaming`)
|
||
`RemoteOnly` is the *normal* state for every item when path replacement isn't configured, so it carries no
|
||
signal about playability — never key availability decisions off it.
|
||
- **Deferred — HEAD instead of GET.** The probe sends `GET` with `Range: bytes=0-0`, which on
|
||
`/Videos/{id}/stream?static=true` is a real (if minimal) playback request and may register a session or
|
||
touch play-state on some media-server versions. `GET` was chosen because HEAD support varies across
|
||
media servers — that is a reasonable prior but an *unverified* one. A HEAD-with-GET-fallback would avoid
|
||
the side effect; deferred rather than guessed at, since it trades a known-working request for an
|
||
untested one.
|
||
|
||
## 2026-07-19 — A media-server library sweep refuses to flag when a successful fetch returns zero items, rather than nuking the whole library (#477)
|
||
`key: scan.zero-item-fetch-guard` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A media-server library sweep refuses to flag missing items when a successful fetch returns zero incoming items against a non-empty existing set (`MediaServerReconciliationGuard.ShouldFlagMissing`), rather than treating an ambiguous empty result as a full-library deletion.
|
||
**Signals:** library sweep, zero-item guard, anti-nuke · paths: `MediaServerReconciliationGuard`, `MediaServerTelevisionLibraryScanner`/`MovieLibraryScanner`/`OtherVideoLibraryScanner` · issues: #477, #476
|
||
**Mechanics:** `MediaServerReconciliationGuardTests` policy table
|
||
|
||
Each media-server scanner reconciles "gone upstream" as `existing.Except(incoming)` and flags the result
|
||
`FileNotFound`. If a *successful* fetch returns **zero** items — the server is up but mid-restore /
|
||
mid-rebuild, or the library was genuinely emptied upstream — then `existing.Except([])` is **every** item,
|
||
so one scan flags the entire library. That is data-loss-adjacent: `EmptyTrashHandler` permanently deletes
|
||
`state:FileNotFound` rows (a user clicking Empty Trash after a bad scan), and `PlayoutSkipMissingItems`
|
||
empties every affected collection (dead channels). There was no zero-count / ratio / server-total guard;
|
||
the only thing that stopped a mid-*pagination* failure was an exception unwinding past the flag step —
|
||
protection by accident of control flow, not by design.
|
||
|
||
- **The guard is a single shared policy.** `MediaServerReconciliationGuard.ShouldFlagMissing(logger,
|
||
libraryName, incomingCount, existingCount)` returns false (and logs a Warning) **only** when
|
||
`incomingCount == 0 && existingCount > 0`; every other combination sweeps normally. Wired into the three
|
||
**library-level** sweeps — `MediaServerTelevisionLibraryScanner` (shows), `MediaServerMovieLibraryScanner`,
|
||
`MediaServerOtherVideoLibraryScanner`. One place owns the invariant so the policy can't drift between
|
||
scanners.
|
||
- **An empty incoming set is genuinely ambiguous, so we choose the non-destructive branch.** "User removed
|
||
every item" and "server returned empty erroneously" are **indistinguishable** at scan time — both report
|
||
a total of zero (the paginator computes `pages` from `TotalRecordCount`, so a 0 total is a clean empty
|
||
enumeration, not an error). Given the blast radius, skipping wins: the cost of *not* flagging a
|
||
legitimately-emptied library (stale rows persist until an item returns or the library is removed by hand)
|
||
is far smaller than a one-scan permanent wipe of a live library.
|
||
- **This partially overrides #476 for the degenerate case, on purpose.** #476 cascades a removed show's
|
||
flag to its seasons/episodes. Its common path — some items removed while **survivors are present**
|
||
(incoming non-empty) — still flags and cascades exactly as before. Only the degenerate "the last item was
|
||
removed, so incoming is empty" case now skips instead of flagging. The #476 characterization test was
|
||
rewritten from an empty incoming to a survivor-plus-removed partial deletion so it exercises the cascade
|
||
without tripping the guard.
|
||
- **Scope: library-level sweeps only; the nested TV season/episode sweeps are deliberately left unguarded.**
|
||
Their blast radius is one show's seasons / one season's episodes (not the whole library), a per-parent
|
||
empty is a more plausible legitimate state there, and the #476 descendant cascade already handles a fully
|
||
removed parent. Guarding them would alter #476's per-parent behaviour for little safety gain.
|
||
- **Deferred — ratio threshold and projection-failure detection.** The issue also floated "skip if the
|
||
missing fraction exceeds a threshold" and "distinguish a silently-dropped projection failure from a real
|
||
deletion." A ratio threshold risks suppressing a legitimate bulk deletion and needs a tunable, telemetry-
|
||
backed policy; projection-failure detection needs a dropped-count threaded out of `JellyfinApiClient`
|
||
through to the scanner (a cross-layer change). The deterministic zero-count guard has **no** false
|
||
positives and covers the reported catastrophic case, so both are deferred to a follow-up rather than
|
||
guessed at here.
|
||
- **Tests.** `MediaServerReconciliationGuardTests` pins the policy table (only `(0, N>0)` skips-and-warns;
|
||
`(0,0)`, `(3,5)`, `(3,0)` all sweep). Per-scanner integration tests
|
||
(`MediaServer{Television,Movie,OtherVideo}LibraryScannerTests`) prove the wiring: empty incoming +
|
||
non-empty existing flags nothing and reindexes nothing. Proven non-vacuous by neutralizing the guard and
|
||
watching all four anti-nuke assertions fail while the `(0,0)` no-op case stays green.
|
||
|
||
## 2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480)
|
||
`key: media.remote-stream-probe-externaljson` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** External-JSON playout channels' `StreamRemotely` now probes the remote-stream URL through the same `IRemoteStreamProber` seam as the generated-playout path, closing the #473 scope gap for a channel kind with no DB `PlayoutItem` rows.
|
||
**Signals:** external-JSON channels, remote-stream probe, scope-gap follow-up · paths: `ExternalJsonPlayoutItemProvider.StreamRemotely` · issues: #480, #473
|
||
**Mechanics:** `ExternalJsonPlayoutItemProviderTests`
|
||
|
||
The #473 fix (PR #479, the entry above dated 2026-07-19) probed Plex/Jellyfin/Emby remote-stream URLs in
|
||
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, but explicitly scoped itself to the
|
||
**generated-playout** path and flagged `ExternalJsonPlayoutItemProvider` as the surviving hole (tracked as
|
||
#480). That provider builds its own `/media/plex/{server}/{plexFile}` URL in `StreamRemotely` and its result
|
||
is assigned in the handler *without* passing through `ValidatePlayoutItemPath`, so for external-JSON channels
|
||
a media item gone from the server still 404'd under ffmpeg (exit 8) and the same dead item was re-selected
|
||
for its whole slot. This closes that gap.
|
||
|
||
- **Fix location: the provider, not a new handler choke point.** `StreamRemotely` now probes the URL via the
|
||
same `IRemoteStreamProber` seam before returning it, mirroring the three generated-playout branches. The
|
||
handler-side single-choke-point option (route *every* `PlayoutItemWithPath` through one validator) was
|
||
rejected: the external-JSON provider constructs a **synthetic** `MediaItem`/`PlayoutItem` (from the JSON +
|
||
Plex API) whose path is already a `/media/plex/...` URL, so it does not fit `ValidatePlayoutItemPath`'s
|
||
local-path-then-per-provider-switch derivation without contortion. Per-provider probing keeps the blast
|
||
radius to one method; the fail-open **policy** (only a *redirected* 404 fails closed) lives entirely inside
|
||
`IRemoteStreamProber`, so the second call site duplicates only the *decision to probe*, not the policy.
|
||
- **Probe before the Plex metadata round-trip.** The URL depends only on `server.Id` + `program.PlexFile`,
|
||
not on the constructed `MediaItem`, so the probe runs *before* `GetPlexEpisode`/`GetPlexMovie`. A gone item
|
||
therefore also skips the Plex API metadata call (which would otherwise `throw NotSupportedException` on a
|
||
`Left`).
|
||
- **Error type + card sizing.** An unavailable stream returns `PlayoutItemNotAvailableFromMediaServer`, which
|
||
the handler already maps (case added in #473) to a real-error card rather than "Channel is Offline". One
|
||
behavioural difference from the generated path is accepted: external-JSON channels have **no DB
|
||
`PlayoutItem` rows** (their schedule is a JSON file), so the handler's `maybeNextStart` query returns none
|
||
and the error card falls to the existing 1-minute work-ahead clamp instead of spanning to the next item.
|
||
The worker then re-tunes and advances by time. That is repeated 1-minute error cards within a long dead
|
||
slot rather than one long card — strictly better than the exit-8 loop, and sizing to the next JSON program
|
||
would require parsing the schedule here (deferred, out of scope).
|
||
- **Tests.** `ExternalJsonPlayoutItemProviderTests` pins both directions (unavailable → `Left`
|
||
`PlayoutItemNotAvailableFromMediaServer` and no metadata call; available → `Right` with the `/media/plex`
|
||
URL). The fail-open contract itself stays pinned by `HttpRemoteStreamProberTests`. Proven non-vacuous by
|
||
neutralizing the probe guard and watching the unavailable assertion flip to `Right`.
|
||
|
||
## 2026-07-20 — `ILibraryRepository.GetOrAddFolder` resolves the folder from the DB, not the caller's `LibraryPath.LibraryFolders` navigation (#488)
|
||
`key: scan.getoraddfolder-db-lookup` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers.
|
||
**Signals:** GetOrAddFolder, LibraryFolders navigation, ArgumentNullException, folder lookup contract, remote scanner crash, eager-load assumption · paths: `ILibraryRepository.GetOrAddFolder`, `LibraryRepositoryTests`, `JellyfinMusicVideoLibraryScanner` · issues: #488
|
||
**Mechanics:** `LibraryRepositoryTests` (`LibraryPath.LibraryFolders == null` case); test coverage in `LibraryRepositoryTests`
|
||
|
||
`GetOrAddFolder` looked the existing folder up by reading `libraryPath.LibraryFolders` in memory. That
|
||
navigation collection is only eager-loaded on the **local** scan path — `LibraryRepository.GetLibrary`
|
||
`.Include(l => l.Paths).ThenInclude(p => p.LibraryFolders)` — which every `*FolderScanner` goes through. The
|
||
**remote** (Jellyfin) sync path takes its `LibraryPath` straight off the `JellyfinLibrary` entity, whose
|
||
`Paths[].LibraryFolders` is **null**, so `.Filter(...)` on it hit `Enumerable.Where(null, …)` and threw
|
||
`ArgumentNullException('source')` on the very first item of every Jellyfin music-video scan.
|
||
`JellyfinMusicVideoLibraryScanner` is the only *remote* caller of `GetOrAddFolder` (it does not derive from the
|
||
`MediaServer*LibraryScanner` base that the movie/TV/other-video remote scanners share), which is why no other
|
||
remote scanner tripped it, and the feature had never run in prod, CI, or locally.
|
||
|
||
- **Fix the repository, not the caller.** The lookup now queries `dbContext.LibraryFolders` by
|
||
`(LibraryPathId, Path)` (the method already opened a `dbContext` for the insert). This removes the *implicit,
|
||
undocumented loading contract* entirely — correct for all nine callers — rather than the caller-side option
|
||
(eager-load `LibraryFolders` on the Jellyfin path), which would have left the contract intact for the next
|
||
remote scanner to trip. The contract change is now **stated** on `ILibraryRepository.GetOrAddFolder`.
|
||
- **`null` ≠ empty — do not coerce.** A `null` navigation meant *unknown*, not *known-absent*; treating it as an
|
||
empty collection would fall through to `CreateNewFolder` and insert a duplicate `LibraryFolder` for a path
|
||
that already exists (there is no unique constraint behind it). Querying the DB distinguishes the two.
|
||
- **No new hot-path cost.** Every local scanner already calls `GetParentFolderId` (a DB query with the same
|
||
`(LibraryPathId, …)` shape) once per folder immediately before `GetOrAddFolder`, so the folder granularity was
|
||
never served purely from memory; this adds one indexed lookup per folder, not a per-file query. As a bonus the
|
||
DB lookup also sees folders created earlier in the *same* scan, which the load-time in-memory snapshot could
|
||
not.
|
||
- **Tests.** `LibraryRepositoryTests` (real in-memory-SQLite `TvContext`) drives `GetOrAddFolder` with
|
||
`LibraryPath.LibraryFolders == null` — the exact remote-path shape — and asserts it creates the folder and is
|
||
idempotent on re-scan (no duplicate row). Proven non-vacuous by restoring the navigation-collection lookup and
|
||
watching both tests fail with the issue's `ArgumentNullException('source')`. Note: `CleanEtagsForLibraryPath`
|
||
still reads `libraryPath.LibraryFolders` directly, but it is only reached on the local (eager-loaded) path, so
|
||
it is not affected; left as-is (out of #488 scope).
|
||
|
||
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
|
||
`key: scan.musicvideo-reconciliation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `JellyfinMusicVideoLibraryScanner` reconciles removed music videos by a library-scoped local-path diff plus hard delete (`TrashMissingMusicVideos`), not the server-itemId soft-trash pattern the other media-server scanners use, because music videos carry no server identity.
|
||
**Signals:** music-video trash sweep, path-based identity, cross-kind safety, path-keyed identity, empty-fetch guard reuse · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496
|
||
**Mechanics:** `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`; `ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items`; integration tests extending the #488 harness
|
||
|
||
The Jellyfin music-video scanner did add/update only — a music video removed on the Jellyfin side lingered in
|
||
ErsatzTV forever and could still be scheduled. It now runs a trash sweep at the end of `ScanLibrary`
|
||
(`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone
|
||
upstream ⇒ remove" pattern but with a deliberately different identity function, because music videos lack the
|
||
media-server identity those base scanners rely on.
|
||
|
||
- **Identity is (LibraryPathId, path), not server itemId.** The base scanners diff `GetExisting*` (keyed by
|
||
`MediaServerItemId`) against the incoming server item ids, then soft-trash via `FlagFileNotFound`. Music videos
|
||
have **no `JellyfinMusicVideo` entity and no `ItemId`/`Etag`** — the scanner is a standalone
|
||
`IJellyfinMusicVideoLibraryScanner` that injects the *local* `IMusicVideoRepository`, which offers no
|
||
itemId-keyed existing-set or flag seam. So the sweep diffs the **local path** set instead: existing =
|
||
`FindMusicVideoPaths(libraryPath)` `.Except` the incoming items' replaced local paths, then hard-deletes the
|
||
remainder with `DeleteByPath` + `IScannerProxy.RemoveMediaItems`, and cleans now-empty artists with
|
||
`IArtistRepository.DeleteEmptyArtists`. Hard delete (not soft `FileNotFound` trash) because there is no
|
||
per-item FileNotFound seam on this path and the issue's Done-when is "removed".
|
||
- **Cross-kind safety is a property of the queries, not the media kind.** `MediaItem` is TPT with `LibraryPathId`
|
||
on the abstract base, so a Movie, Show and MusicVideo can share one `LibraryPath` (a mixed Jellyfin library).
|
||
Both `FindMusicVideoPaths` and `DeleteByPath` filter `LibraryPathId` **and** join the concrete `MusicVideo`
|
||
table, so the sweep can only ever see/delete music videos — a Movie/Show under the same `LibraryPath` is
|
||
invisible to it. Pinned by `ScanLibrary_Should_Not_CrossDelete_Movie_Or_Show_Sharing_The_LibraryPath`.
|
||
- **Reuses the #477 empty-fetch guard.** The sweep is gated by `MediaServerReconciliationGuard.ShouldFlagMissing`
|
||
— a successful fetch that returns zero items (server mid-restore / transient) is indistinguishable from a real
|
||
emptying, so the whole-library wipe is refused and logged. Pinned as a negative control by
|
||
`ScanLibrary_Should_Not_Sweep_When_Jellyfin_Returns_Zero_Items` (removing the guard flips it red).
|
||
- **Known limitation (deferred to per-library identity).** `MusicVideoRepository.GetOrAdd` dedups a path
|
||
**globally** (no `LibraryPathId` predicate), so a file served by two libraries with overlapping local paths is
|
||
a single row owned by whichever library scanned it first. If that owner later stops reporting the file while
|
||
another library still serves it, this sweep removes the shared row. A proper fix needs per-library music-video
|
||
identity (a `JellyfinMusicVideo` etag entity + migration) — the issue's "option 2 / fold into the base
|
||
scanner" refactor — tracked as #496.
|
||
- **Tests.** Integration tests (real `ArtistRepository`/`MusicVideoRepository`/`LibraryRepository` over in-memory
|
||
SQLite, extending the #488 harness) pin removal, empty-artist cleanup, cross-kind safety, and the empty-fetch
|
||
guard. Proven non-vacuous: all four fail against the pre-fix scanner except the guard control, which only
|
||
earns its keep once the sweep exists.
|
||
|
||
## 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds
|
||
`key: scan.jellyfin-mixed-content-library` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind.
|
||
**Signals:** mixed-content library, `LibraryMediaKind.Mixed`, per-kind sequential scan, per-kind scan dispatch, silent-success bug · paths: `JellyfinApiClient.Project()`, `ScanMixedLibrary`, `SynchronizeJellyfinLibraryByIdHandler`, `ScanLocalLibraryHandler` · issues: #489, #474, #488
|
||
**Mechanics:** local mixed libraries deliberately unsupported (`LocalFolderScanner.VideoFileExtensions` hazard); `LibraryMediaKind.Mixed` dispatch in the Jellyfin sync handlers; per-kind scanners queried by `parentId` + `includeItemTypes`
|
||
|
||
A Jellyfin library whose collection type is `mixed` — or absent — now maps to `LibraryMediaKind.Mixed`
|
||
instead of being dropped by `JellyfinApiClient.Project()`'s `_ => None`. Scanning it runs the movie,
|
||
television and music-video scanners in sequence against that one library.
|
||
|
||
- **A library is a place, not a media kind.** One physical path ↔ one Jellyfin library ↔ one ErsatzTV
|
||
library, whose contents are heterogeneous. This is what keeps music and standup content segregated from
|
||
the main `Movies` and `TV Shows` libraries, which was the actual goal (#474). The previous workaround was
|
||
a *local* library pointed at the same tree, which bypassed Jellyfin, scanned the content twice and
|
||
mis-modelled shows as movies.
|
||
- **The classification is Jellyfin's, not ours.** Each scanner queries `parentId` + `includeItemTypes`
|
||
(`"Movie"` / `"Series"` / `"MusicVideo"`), so the three passes receive disjoint, authoritative sets.
|
||
Nothing is inferred from folder shape or NFO contents. This is why mixed support is tractable at all — an
|
||
earlier reading that it would require guessing per item was wrong.
|
||
- **No migration.** `MediaItem` is table-per-type with no discriminator and `LibraryPathId` on the abstract
|
||
base, so heterogeneous items under one `LibraryPath` were always legal;
|
||
`MediaItemRepository.GetAllTrashedItems` already `COALESCE`s across every subclass id for a single path.
|
||
- **No cross-deletion.** Reconciliation is type-scoped (`GetExistingMovies(library)` and friends), so a pass
|
||
over one kind cannot trash another kind's items in the same library.
|
||
- **`MediaKind` is dispatch + presentation only.** Scheduling, playout, collections, smart collections and
|
||
search hold zero references to it; search keys off the item's own subclass and the SPA's browse screens
|
||
use a per-item `LibraryBrowseMediaType`. Adding a kind is therefore cheap.
|
||
|
||
**Deliberately scoped to Jellyfin. Local mixed libraries are NOT supported** and `Mixed` is absent from the
|
||
SPA's local-library media-kind options. Locally the same approach is unsafe: every local scanner shares
|
||
`LocalFolderScanner.VideoFileExtensions`, so the movie scanner would claim episode files, and `LibraryFolder`
|
||
rows are keyed by `LibraryPathId` with no notion of kind, so two scanners over one path would thrash each
|
||
other's etags. Neither hazard exists remotely — among the remote scanners only
|
||
`JellyfinMusicVideoLibraryScanner` touches `LibraryFolder` at all. (The existing `Images` + `OtherVideos`
|
||
shared-path exemption in `LocalLibraryHandlerBase.AreSubPaths` is the closest local analogue, and it already
|
||
carries that etag contention.)
|
||
|
||
**Failure semantics of the `Mixed` arm**: one kind failing does *not* skip the others — a broken music-video
|
||
scan must not prevent the movies in the same library from being ingested — but the library as a whole then
|
||
reports failure and `LastScan` is not stamped. `ScanCanceled` aborts the sequence immediately, since a user
|
||
cancellation is not one kind failing.
|
||
|
||
**Also fixed here**: `ScanLocalLibraryHandler` and `SynchronizeJellyfinLibraryByIdHandler` both ended their
|
||
dispatch switch with `_ => Unit.Default`, returning **success** for an unhandled kind and stamping `LastScan`
|
||
as though a scan had run. Both now return a `BaseError`. That silent success is precisely how a missing
|
||
`Mixed` arm would have hidden, so removing it is part of the feature, not a drive-by.
|
||
|
||
**Known limitations of the `Mixed` arm**, surfaced by the adversarial review and accepted rather than
|
||
fixed here:
|
||
|
||
- **Scan progress resets twice.** Each per-kind scanner independently drives `_scannerProxy.UpdateProgress`
|
||
from 0 to 1 over its own item set, so a mixed library's progress bar fills and resets three times. The
|
||
local scanner solves this by threading `progressMin`/`progressMax` per path (`ScanLocalLibraryHandler`),
|
||
but the Jellyfin `ScanLibrary` signature has no such parameter, so fixing it means changing three scanner
|
||
interfaces. Cosmetic, and deliberately out of scope.
|
||
- **One permanently-failing kind forces the healthy kinds to rescan forever.** `ScanMixedLibrary` returns
|
||
`Left` if any arm failed, so `LastScan` is never stamped and the whole library re-scans every interval.
|
||
Single-kind libraries already behave this way; `Mixed` widens the blast radius to the other two kinds.
|
||
Accepted: the alternative — stamping `LastScan` on partial success — would silently mask a broken kind,
|
||
which is worse.
|
||
- **`JellyfinMusicVideoLibraryScanner` performs no reconciliation at all** — no `GetExisting*`, no trash
|
||
sweep. This is *why* it cannot cross-delete in a mixed library, but it also means a music video removed
|
||
from Jellyfin is never removed from ErsatzTV. Pre-existing and orthogonal to this change; it belongs with
|
||
that scanner's other gaps (no `ItemId`/`Etag`, path-keyed identity — see #488).
|
||
|
||
## 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)
|
||
`key: iptv.logo-drives-bug-preset` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** One uploaded channel logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset (`Channel Bug`), not new per-channel schema.
|
||
**Signals:** channel logo, watermark bug preset, ChannelWatermark seeding, ChannelLogo imageSource, seed/adopt semantics, quick-add · paths: `ChannelWatermarkImageSource.ChannelLogo`, `DbInitializer.Initialize`, `watermark.channel_bug_seeded`, `WatermarkResponseModel` · issues: #67, #286, #502
|
||
**Mechanics:** `DbInitializerChannelBugWatermarkTests`; `WatermarkResponseModel.imageSource`; `DbInitializer` seeded `Channel Bug` watermark + `watermark.channel_bug_seeded` ConfigElement marker
|
||
|
||
#67 asked that one uploaded image drive both the listing logo and the on-screen bug, separably
|
||
overridable, with preview. Most of it already existed: `ChannelWatermarkImageSource.ChannelLogo`
|
||
resolves the channel's own logo artwork at render time at all three watermark precedence levels, and
|
||
`Custom` already provides the independent override. Production had already been running exactly this
|
||
pattern by hand — 43 channels pointing at one hand-made `Channel Bug` preset.
|
||
|
||
**Decision: seed that preset rather than add per-channel bug columns.** A watermark is a shared named
|
||
entity, so per-channel geometry would need either a dual-provider migration or one watermark row per
|
||
channel (under a unique-name index). Since `ChannelLogo` resolves per channel at render time, a single
|
||
shared row already delivers the user-visible behavior with no schema change.
|
||
|
||
- The seed **adopts** an existing `Channel Bug` row untouched, so an operator's tuned geometry is never
|
||
overwritten, and a `ConfigElement` marker (`watermark.channel_bug_seeded`) makes it run once per
|
||
database rather than once per name-absence — `ChannelWatermark` has no `IsSystem` flag and
|
||
`DbInitializer.Initialize` runs at every startup, so a name-only guard would resurrect a deliberately
|
||
deleted preset forever. Covered by `DbInitializerChannelBugWatermarkTests`.
|
||
- `WatermarkResponseModel` gained `imageSource` (additive under the frozen `/api/v1`, #286) so clients
|
||
identify logo-driven presets generically instead of matching a user-editable name. Note the limit:
|
||
once a *second* logo-driven preset exists, `imageSource` identifies the class but not *the* default,
|
||
so `findLogoBugWatermark` prefers the seeded name as a deterministic tiebreak.
|
||
- The default is applied by the SPA's quick-add **creation** path, not by inferring "newness" in the
|
||
editor — quick-add creates through the API and then navigates to the editor, so the editor only ever
|
||
loads an existing row. The editor toggle reflects the **referenced** watermark's `imageSource`; an
|
||
earlier draft searched the list instead, which (because `getWatermarks()` sorts by name) would have
|
||
silently repointed channels bound to a non-first logo-driven preset. Caught in independent review
|
||
and pinned by a regression test.
|
||
- The builder flow is covered on **fresh installs only**, by stamping the preset onto the system
|
||
channel templates the seed itself creates; existing installs' templates are never mutated.
|
||
- Server-side create defaulting was rejected: making an omitted `watermarkId` mean "give me a
|
||
watermark" would surprise machine clients of the frozen API. `POST /api/v1/channels/auto-tune` is
|
||
therefore unchanged.
|
||
- **Not fixed here:** external-URL logos never render a bug (`WatermarkSelector` `File.Exists`-checks a
|
||
URL). Pre-existing, lands in the FFmpeg render path, tracked as **#502**. This change only stops the
|
||
preview from promising it.
|
||
|
||
**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean
|
||
creating a second preset on the Watermarks screen.
|
||
## 2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool
|
||
`key: ffmpeg.qsv-decode-encode-split` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** QSV decode is decoupled from QSV encode via a single `FFmpegProfile.QsvPreferNativeDecoder` bool (default ON, Linux-only), so a QSV encode profile can decode with the more tolerant native VA-API decoder instead of the QSV decoder, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum.
|
||
**Signals:** QSV, native VA-API decode, Dolby Vision passthrough, hybrid decode/encode, HDR tonemap fallback · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder.SetTonemap`, `QsvPipelineBuilder` · issues: #498, #505
|
||
**Mechanics:** `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`; migration `HasDefaultValue(true)` on the nullable `bool?` column; `QsvPipelineBuilder` decoder-mode branch
|
||
|
||
`FFmpegProfile.HardwareAcceleration` picked one pipeline builder for **both** decode and encode, so an
|
||
Intel QSV profile decoded with the QSV decoder — which is materially less tolerant of imperfect H.264 than
|
||
FFmpeg's native VA-API decoder and cannot carry Dolby Vision metadata. Jellyfin, on identical hardware,
|
||
avoids this by decoding with VA-API and encoding with QSV; that combination was not expressible in
|
||
ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`.
|
||
|
||
- **Chosen: a single, QSV-scoped `FFmpegProfile.QsvPreferNativeDecoder` boolean**, mirroring Jellyfin's
|
||
"Prefer OS native DXVA or VA-API hardware decoders" checkbox exactly — Jellyfin itself deliberately
|
||
collapses this to one default-on toggle rather than a decode-family picker, and copying the reference
|
||
tool's granularity avoids over-building past a problem it has already solved.
|
||
- **Default ON.** Native VA-API decode is strictly more tolerant of imperfect streams than the QSV decoder
|
||
wrapper, and Dolby Vision passthrough requires it — so the working configuration *is* the default for a
|
||
reliability fix. Existing QSV profiles adopt the hybrid on upgrade via the migration's
|
||
`HasDefaultValue(true)` (SQLite `INTEGER`/MySQL `tinyint`, **nullable with `DEFAULT 1`** — the column
|
||
is `bool?` so an explicit `false` persists, while `NULL`/absent reads as ON via `!= false`; a
|
||
non-nullable `bool` + store default would make EF silently drop a create-with-`false`), no opt-in required.
|
||
- **Rejected: a general `DecodeHardwareAcceleration` enum column** decoupling decode and encode hardware
|
||
families entirely. More configurable than Jellyfin's own control, but nothing in the codebase or the
|
||
reported failure needs that generality yet — `FFmpegState` already separates
|
||
`DecoderHardwareAccelerationMode` / `EncoderHardwareAccelerationMode` internally (used today for the
|
||
software-decode-plus-hardware-encode error-loop fallback), so the seam to grow into a real enum exists
|
||
if a second asymmetric-decode case ever shows up on another hardware family. Deferred rather than
|
||
built now, on YAGNI grounds.
|
||
- **Contained to the QSV builder.** The flag does not touch `PipelineBuilderFactory`'s single-builder
|
||
dispatch per profile, and does not repurpose the existing decode/encode-mode fields into a general
|
||
cross-family selector — that repurposing is exactly the rejected enum option, just introduced through
|
||
the back door.
|
||
- **Accepted trade-off — HDR tonemapping runs in software on the native path.** With native decode ON the
|
||
decoder mode is `Vaapi`, so `QsvPipelineBuilder.SetTonemap` (which only selects `TonemapQsvFilter` for
|
||
`DecoderHardwareAccelerationMode == Qsv`) falls to the software `zscale`/`tonemap` chain for HDR content.
|
||
Output is correct but costs CPU on the realtime path. Accepted for now because software tonemap is
|
||
correct-but-slower while an unvalidated GPU-tonemap graph could be worse (needs the Intel host to
|
||
verify), and the escape hatch covers it: HDR-on-QSV users who don't need the tolerant decoder set the
|
||
flag OFF to keep GPU tonemap. Optimizing the native path to `tonemap_qsv` is tracked in **#505**.
|
||
- **Native decode is Linux-only.** Guarded with `!OperatingSystem.IsWindows()` in the QSV builder —
|
||
FFmpeg has no `vaapi` hwaccel on Windows (and Windows QSV capabilities are over-reported), so on Windows
|
||
a QSV profile keeps QSV decode regardless of the flag.
|
||
## 2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639)
|
||
`key: ci.small-lane-git-only` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `runs-on: small` is defined by what a job does (git-only), not its usual runtime; the two `docker build` jobs (docker-build.yml, ci-image.yml) move to `ubuntu-latest` because their worst-case memory, not median runtime, was pinning the small lane's per-slot cap.
|
||
**Signals:** CI lane definition, per-job memory cap, small lane widening, memory cap vs capacity, act setup-phase hang, docker build placement · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml`, runner config · issues: server-management#639, #406, #604, #574
|
||
**Mechanics:** sum-of-caps rule (#406/#604); second jazz runner at `--cpu-shares=128`; `docs/ci-cd.md`
|
||
|
||
- **The `small` lane is defined by what a job *does*, not by how long it usually takes.** Both jobs
|
||
removed from it here were justified as small on a runtime argument that only held in the common case:
|
||
`docker-build.yml`'s `build` is a 1-second skip on PR runs (but a real image build on main/tags), and
|
||
`ci-image.yml`'s `build` was reasoned about as "docker-only, no toolchain needed — it *builds* the
|
||
toolchain", which is true and yet describes the single heaviest job in the lane. The lane's per-job
|
||
memory cap is set by its worst member, not its median, so both of these forced `--memory=10g`.
|
||
- **That cap, not a capacity decision, is what pinned the lane at one slot.** 10 GiB per slot on a
|
||
25 GiB host that also runs prod media permits exactly one — the sum-of-caps rule from #406/#604
|
||
(6 slots × 10 GiB on a 25 GiB host produced load 340 and 21 GiB of swap). So "widen the lane" and
|
||
"keep the heavy jobs" were never simultaneously available; the earlier note in the runner config had
|
||
parked the widening indefinitely behind moving the lane to a different host.
|
||
- **Fixing the cap dominates fixing the capacity.** With both builds on `ubuntu-latest`, `small` is a
|
||
checkout plus a `git diff`, cappable at 1 GiB, so it widened from 1 slot to **4 across two hosts
|
||
while committing less RAM to CI than the single slot did**. A second runner was added on jazz at
|
||
`--cpu-shares=128` — CI on a prod media host is only acceptable while it loses every scheduling
|
||
contest to the transcoders.
|
||
- **The symptom this fixes is not queue wait.** A saturated lane also wedges *dispatched* jobs in act's
|
||
setup phase: >10 min `in_progress`, **no log file written at all**, then failure, before Checkout
|
||
runs. That produced the standing "`decisions.md` is a known flake, just rerun it" belief — the rerun
|
||
works only because it lands after load clears, so a capacity problem read as a bug in the guard. A
|
||
job that fails with zero log output is evidence about the runner, not about the job.
|
||
- **#574's skip-task queueing does not return** by moving `build` back to `ubuntu-latest`:
|
||
`needs: [test, migrations]` means it cannot be dispatched until the jobs it would have queued behind
|
||
have already finished.
|
||
|
||
## 2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)
|
||
`key: ffmpeg.external-logo-graphics-engine` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut.
|
||
**Signals:** external-URL logo, WatermarkSelector, graphics engine · paths: `WatermarkSelector`, `FFmpegLibraryProcessService`, `ImageElementBase.LoadImage` · issues: #502, #67, #1, #510, #511
|
||
**Mechanics:** `WatermarkSelectorChannelLogoTests`; `ChannelLogoWatermarkOptions` helper
|
||
|
||
A channel whose logo is an **external URL** never rendered an on-screen bug, even with a
|
||
`ImageSource = ChannelLogo` watermark attached. `WatermarkSelector` resolved the URL correctly and then
|
||
existence-checked it on the filesystem — `File.Exists("https://…")` is always false — so all three
|
||
precedence levels (playout item, channel, global) logged *"Channel logo no longer exists"* and returned
|
||
`None`. The channel editor advertises the URL as winning over an uploaded logo, which is true for the
|
||
guide listing and was silently false for the bug.
|
||
|
||
**External artwork passes through; it is not downloaded into the image cache.** That is already the
|
||
codebase-wide convention — `ChannelPlaylist` (M3U), the XMLTV template data and `Channels/Mapper`
|
||
(SPA JSON) all emit the raw URL and let the client fetch it. No fetch→`SaveArtworkToCache` glue exists
|
||
anywhere, and adding it here would have invented a second convention for one consumer. The render path
|
||
needs no such glue: `ImageElementBase.LoadImage` already detects an `http(s)` path, fetches it with
|
||
`HttpClient`, and decodes it with ImageSharp for real pixel dimensions.
|
||
|
||
**A remote-URL watermark is therefore forced onto the graphics engine, not the ffmpeg-native path.**
|
||
`FFmpegLibraryProcessService` normally shortcuts a single permanent watermark into a `WatermarkInputFile`
|
||
handed to ffmpeg as a bare `-i` argument (and to `ffprobe` for animation detection). ffmpeg would likely
|
||
open an http URL itself, but that puts an unbounded network fetch *inside stream startup*, with no
|
||
timeout, redirect or auth handling under our control, and with dimensions left as a `FrameSize(1, 1)`
|
||
placeholder. The graphics engine is the path actually built for remote images, so the shortcut now
|
||
additionally requires a non-URL path.
|
||
|
||
**Scope held deliberately narrow — two adjacent defects were left alone:**
|
||
|
||
- The **generated-initials fallback** (no logo artwork ⇒ `ChannelLogoGenerator.GenerateChannelLogoUrl`,
|
||
which hardcodes `localhost`, issue #1) is *also* killed by the same `File.Exists`. Reviving it is
|
||
deferred by an earlier entry in this file, so it stays ignored — now behind an explicit comment and a
|
||
scope-guard test rather than as an accident of the existence check.
|
||
- The **deco path** (`OptionsForWatermarks` → the private `GetWatermarkOptions`) has always returned its
|
||
resolved path unchecked, so #502's `File.Exists` defect never reached it and its *resolution* is unchanged.
|
||
Aligning its missing-file and no-artwork behavior with the three precedence levels is a behavior change in
|
||
its own right, tracked as **#510**.
|
||
|
||
**The routing change is NOT scoped that way, deliberately.** `CanUseFFmpegNativeWatermark` keys off the
|
||
resolved `WatermarkOptions.ImagePath` alone, and `SelectWatermarks` puts deco-derived options into the same
|
||
list — so a *deco* watermark resolving to a URL is rerouted to the graphics engine too, including the
|
||
generated-initials `http://localhost:…/iptv/logos/gen` URL that the deco path does still pass through.
|
||
Routing by provenance instead of by what the path actually is would mean deciding the same thing twice and
|
||
letting the two drift; the URL-aware path is the right one for any URL. Worth knowing when picking up #510:
|
||
that fallback plausibly rendered through ffmpeg before and now composites through ImageSharp, which this
|
||
change's live-E2E did not cover.
|
||
|
||
**Accepted cost asymmetry.** Graphics-engine compositing is per-frame ImageSharp work rather than ffmpeg's
|
||
`overlay` filter, so two channels with visually identical bugs now transcode at different cost based only on
|
||
whether the logo is a URL. The rejected fetch-once-into-the-image-cache alternative would have avoided that;
|
||
if **#511** (remote-fetch hardening: timeout, size cap, pooling, caching) adds such a cache, this goes with it.
|
||
|
||
The three gated levels now share one `ChannelLogoWatermarkOptions` helper so they cannot drift apart
|
||
again — the duplication is what let the defect exist in triplicate. Covered by
|
||
`WatermarkSelectorChannelLogoTests`, which pins the external-URL fix, both preserved regressions
|
||
(cached local path, missing local file ignored) and the generated-fallback scope guard.
|
||
|
||
## 2026-07-20 — HLS cold start is fixed with `-readrate_initial_burst`, not by raising the work-ahead limit (#350)
|
||
|
||
`key: ffmpeg.hls-cold-start-burst` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** HLS cold-start latency is fixed with a bounded `-readrate_initial_burst` (gated on FFmpeg ≥6.1 capability detection), not by raising `work_ahead_limit`, which would remove the concurrency guarantee it exists for.
|
||
**Signals:** HLS cold start, readrate, work_ahead_limit, HlsSessionWorker, FFmpegKnownOption capability gate · paths: `HlsSessionWorker`, `SetRealtimeInput`, `FFmpegPlaybackSettingsCalculator`, `FFmpegKnownOption`/`HasOption` · issues: #350
|
||
**Mechanics:** `SetRealtimeInput` readrate-burst option; `FFmpegKnownOption.HasOption` version-capability gate
|
||
|
||
> **Correction (2026-07-21, #529):** the claim below that the burst is "bounded" is true only in
|
||
> *seconds of input* — it is **not** a bound on memory or hardware surfaces. `-readrate` was also
|
||
> incidentally bounding how fast decoded frames enter the filter graph, and removing that on a QSV
|
||
> pipeline whose profile stores `qsvExtraHardwareFrames: 0` exhausts the upload pool: the graph fails
|
||
> with `-12 (Cannot allocate memory)`, `h264_qsv` never opens, and zero segments are written. The
|
||
> burst is **not** the root cause (a work-ahead start takes no `-readrate` at all and was already
|
||
> failing the same way in production), but it removed the throttle on every realtime session and so
|
||
> made the failure near-deterministic. See `ffmpeg.qsv-extra-hw-frames-floor`; the decision recorded
|
||
> here still stands, with that floor in place.
|
||
|
||
> **Correction (2026-07-21, #536):** the bullet below stating that "every concurrent tune-in falls
|
||
> back to the throttled path" described the *intent*, not the behaviour. The slot check and its
|
||
> increment straddled an `await`, so N simultaneous tune-ins all read `0 < limit` and all started
|
||
> unthrottled; the "one winner, two throttled" observation held only because those tunes were
|
||
> effectively staggered. The measurement and the conclusions drawn from it stand — slot availability
|
||
> *was* the variable behind the bimodality — but the guarantee itself was not enforced until
|
||
> `ffmpeg.work-ahead-slot-atomic`.
|
||
|
||
- **`-readrate` throttles from the first read, so it sets a floor on time-to-first-segment.** The
|
||
realtime playback path pins input reading to 1.05× wall clock so a channel behaves like live TV.
|
||
Since `OutputFormatHls.SegmentSeconds` is 4 and the segmenter serves the playlist only once the
|
||
first segment exists, the playlist cannot appear sooner than ~4/1.05 ≈ 3.8 s. Measured on real
|
||
media: time-to-first-playlist 5369/5344 ms with `-readrate 1.05`, 648/649 ms with an initial burst.
|
||
- **The cold-start bimodality was never about the media.** `HlsSessionWorker` grants an unthrottled
|
||
`SeekAndWorkAhead` start only while `_workAheadCount < ffmpeg.segmenter.work_ahead_limit` (prod: 1);
|
||
every concurrent tune-in falls back to the throttled path. Three concurrent tunes on prod: the one
|
||
that won the slot reached `firstGop` in 866 ms, the other two in 3845 ms and 6357 ms. This is why
|
||
the earlier rounds found no correlation with subtitle burn-in, GOP length, or source file — the
|
||
variable was slot availability, and the same channel could differ 7.2× between tunes.
|
||
- **Two earlier hypotheses are falsified, not deferred.** Accurate-seek decode-discard (the issue's
|
||
ranked #1 driver) costs 30–100 ms on real prod media, and capping `-probesize`/`-analyzeduration`
|
||
buys 20–50 ms. Neither can account for seconds. Recorded here so they are not re-proposed.
|
||
- **Burst rather than a bigger work-ahead budget.** Raising `work_ahead_limit` would fix latency by
|
||
deleting the guarantee that limit exists for — it caps how many unthrottled transcodes N viewers can
|
||
start at once. The burst is bounded (`SegmentSeconds * 2` = 8 s of input, enough for the first
|
||
segments at the default `InitialSegmentCount` of 1), after which live pacing resumes. An operator
|
||
who raises `InitialSegmentCount` above 2 gets less of the benefit; that is a deliberate trade.
|
||
- **The burst is per ffmpeg process — i.e. per playout item — not per session.** `SetRealtimeInput`
|
||
runs on every pipeline build and `HlsSessionWorker` spawns a process per item, so each item boundary
|
||
bursts too; this is *not* only the session's cold start. Two consequences, both accepted: item
|
||
transitions get the same head start (a benefit), and on a channel whose items are shorter than the
|
||
burst every item transcodes unthrottled, so the instantaneous-concurrency guarantee that
|
||
`work_ahead_limit` provides is weaker than before — weaker, not absent, because `HlsSessionWorker`'s
|
||
`transcodedBuffer <= 1min` gate still stops the loop at a 60 s buffer, leaving average CPU
|
||
unchanged. Making the burst strictly cold-start-only would mean plumbing a "first process of this
|
||
session" flag through `FFmpegState`; that complexity was not judged worth a bounded peak.
|
||
- **Still images are excluded.** Their video input is paced by the realtime *filter* and takes no
|
||
readrate at all, so a burst would only run the audio input ahead of the video for songs and offline
|
||
filler, with no cold-start gain to show for it.
|
||
- **Non-HLS realtime outputs (`TransportStream`, HLS-Direct) burst too**, since
|
||
`FFmpegPlaybackSettingsCalculator` makes them unconditionally realtime. That is untested by the
|
||
benchmark, which was segmenter-only; it is kept because the same first-read throttle delays those
|
||
clients identically, and the outer `WrapSegmenter`/`Concat` processes still pace at `readrate 1.0`
|
||
with no burst.
|
||
- **Gated on runtime capability, not on a parsed version.** `-readrate_initial_burst` needs FFmpeg
|
||
≥ 6.1, and `FFmpegKnownOption`/`HasOption` already existed for exactly this (its `AllOptions` list
|
||
had simply been empty). Detection parses `ffmpeg -h long`, so an older binary silently keeps
|
||
today's behavior instead of failing to start — the same fail-safe posture as the other capability
|
||
gates, and cheaper to reason about than the version-string parsing in `NvidiaHardwareCapabilities`.
|
||
|
||
## 2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58)
|
||
|
||
`key: mcp.server-foundation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `ErsatzTV.Mcp` is a fresh stdio JSON-RPC server wrapping frozen `/api/v1` with explicit narrow per-endpoint tools, read-only-by-default enforced at runtime (`ERSATZTV_ALLOW_WRITES`), machine-key auth, and opt-in `If-Match`.
|
||
**Signals:** MCP server, tool catalog, read-only default, ERSATZTV_ALLOW_WRITES, X-Api-Key, If-Match round-trip · paths: `ErsatzTV.Mcp`, `docs/mcp.md` · issues: #58, #286, #197, #76, #289, #63, #64, #65, #66, #67, #68
|
||
**Mechanics:** `docs/mcp.md`; `ErsatzTV.Mcp` tool executor and catalog
|
||
|
||
The MCP server (issue #58, gated on #286 route-freeze + #197 security review, both closed) is built
|
||
**fresh** as `ErsatzTV.Mcp` — a stdio JSON-RPC server wrapping `/api/v1` — superseding the closed
|
||
read-only PR #76 rather than rebasing it. Full doc: `docs/mcp.md`. Decisions frozen:
|
||
|
||
- **Explicit, narrow tools over a generic HTTP passthrough.** Each tool maps to one OpenAPI-backed
|
||
endpoint; there is no "call any URL" tool. Each declared argument routes to exactly one place —
|
||
path `{param}`, an explicit query parameter, the reserved `ifMatch` header, or (write verbs only)
|
||
the JSON body — and `additionalProperties:false` rejects anything undeclared before a request is built.
|
||
- **Read-only is the default, enforced at runtime, not just by catalog shape.** The executor refuses
|
||
any non-GET tool unless `ERSATZTV_ALLOW_WRITES=true`, so one wrong catalog entry cannot mutate. This
|
||
is the PR #76/#289 posture carried forward verbatim, alongside the JSON-RPC DoS guards
|
||
(`-32700/-32600/-32602/-32603`, bounded stdin line reader), the per-request CTS covering headers
|
||
**and** the streamed body (the `ResponseHeadersRead` gotcha, #289), the response-size cap, and the
|
||
reverse-proxy prefix preservation.
|
||
- **Machine-key auth, CSRF-exempt.** The server sends `X-Api-Key` on every request and no `X-CSRF`
|
||
header (§9: key-authed requests are CSRF-exempt). `ERSATZTV_API_KEY` is effectively required.
|
||
- **`If-Match` is opt-in per call, and ETags are surfaced, not managed.** Only the replace-all PUTs
|
||
honor `If-Match` (here `update_collection_custom_order`); a response `ETag` header is appended to the
|
||
tool result as `[etag: "N"]` so an agent can round-trip it. All other writes force-write (§7a), so
|
||
no ETag handshake is needed — notably `add_collection_items`, whose re-add is an idempotent no-op.
|
||
- **Channel create/update is exposed** (28-field DTO): only `name`/`number`/`ffmpegProfileId` are
|
||
required, enums take the enum name and are API-validated, and `get_channel` makes the shape/current
|
||
values discoverable. **Deferred as too-large for a cautious v0.1** (not a contract gap): the ~40-field
|
||
schedule-item / playout replace-list writes. Redesign workflow tools (#63–#68) stay deferred until
|
||
their backend endpoints exist. No `/api` endpoint was added, so no OpenAPI regen.
|
||
## 2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)
|
||
|
||
`key: ffmpeg.remote-image-fetcher-bounded` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** remote graphics-engine images are fetched through `IRemoteImageFetcher` with a pooled `HttpClientFactory` client, a body-covering deadline, a wire-transfer size cap, and a decoder-enforced `DecoderOptions.MaxFrames` bound re-verified post-decode — never cached, re-fetched per element init.
|
||
**Signals:** remote image fetch, decompression bomb, MaxFrames, decode budget vs retention budget, SSRF accepted risk · paths: `IRemoteImageFetcher`, `HttpRemoteImageFetcher`, `ImageElementBase.LoadImage`, `DecoderOptions.MaxFrames`, `WatermarkElementRemoteImageTests` · issues: #511, #502, #289
|
||
**Mechanics:** `HttpRemoteImageFetcher` (Infrastructure) over `IHttpClientFactory`; `EnsureDecodeAffordable`/`EnsureScaledFramesAffordable` pure-function budget checks
|
||
|
||
`ImageElementBase.LoadImage` fetched `http(s)` images with a throwaway `new HttpClient()` and
|
||
`GetStreamAsync`. That is unbounded in three directions at once — no timeout override (the 100s
|
||
`HttpClient` default), no response size cap, and a new connection pool per element — and it runs
|
||
*inside stream startup*, while ffmpeg waits on the pipe. #502 routed ordinary channel-logo
|
||
watermarks onto that path, which is what made a pre-existing weakness worth hardening.
|
||
|
||
The fetch now lives behind **`IRemoteImageFetcher`** (`HttpRemoteImageFetcher`), deliberately
|
||
modelled on the neighbouring `IRemoteStreamProber`: a Core interface, an Infrastructure
|
||
implementation over `IHttpClientFactory`, and a `CancellationTokenSource.CreateLinkedTokenSource` +
|
||
`CancelAfter` deadline.
|
||
|
||
**The deadline covers the body, not just the headers.** The named client is registered with
|
||
`Timeout = InfiniteTimeSpan` and the linked token is threaded into `GetAsync` *and* every stream
|
||
read, because under `HttpCompletionOption.ResponseHeadersRead` the body read falls outside
|
||
`HttpClient.Timeout` — a slow-drip host would otherwise hang forever. Same mechanic as #289.
|
||
|
||
**The size cap is enforced during the copy, not from `Content-Length`.** The advertised length is
|
||
only a cheap early reject; it can be absent or a lie, so the byte counter in the copy loop is what
|
||
actually bounds transfer and buffering. (It bounds the *wire*, not the decode — see below.) Both
|
||
paths are covered by tests, and both were negative-controlled (disabling the checks fails exactly
|
||
those two tests).
|
||
|
||
**The byte cap does not bound decoding, so there are two further budgets — and the decode bound is
|
||
imposed on the DECODER, not read from the header.** A decompression bomb is by definition small on
|
||
the wire: a 4 KB PNG can declare 30000x30000 (~3.6 GB to decode), and a 60 KiB GIF can declare
|
||
2500x2500 across 600 frames (~14 GiB). All of that passes `Content-Length`, the content-type check
|
||
and the 10 MiB copy cap in milliseconds.
|
||
|
||
Getting this right took three attempts, and the two failures are the interesting part:
|
||
|
||
1. The first version checked dimensions (50 MP) and frame count (600) **independently**. The
|
||
2500x2500 x600 GIF above passes both while costing ~14 GiB. *Independent caps do not compose
|
||
into a budget* — the bound has to be on the PRODUCT.
|
||
2. The second version checked the product, but sourced the frame count from
|
||
`Image.IdentifyAsync`. **Measured on ImageSharp 3.1.12: an APNG reports
|
||
`FrameMetadataCollection.Count == 0` while the decoder produces every frame.** A 4000x4000 x600
|
||
APNG is ~134 KiB on the wire, is charged as ONE frame (16 MP, comfortably inside the budget) and
|
||
decodes to ~36 GiB — 2.5x worse than the bomb that version was written to stop. Header-derived
|
||
limits are advisory; a limit the decoder does not enforce is not a limit. (GIF, WebP and TIFF
|
||
report honestly; PNG/APNG is the sole divergence, which is exactly why trusting the header is
|
||
untenable — you cannot audit every format.)
|
||
|
||
So `DecodeRemoteImage` now: checks the header **dimensions** (which are trustworthy — a GIF whose
|
||
image descriptor exceeds its logical screen is clamped by the decoder, verified); derives how many
|
||
frames of that size the budget affords; passes that to **`DecoderOptions.MaxFrames`**, which the
|
||
decoder enforces regardless of what the header claimed; and then **re-verifies the real
|
||
`image.Frames.Count`** after decoding, disposing and rejecting if it is over. `MaxFrames` was
|
||
measured as honored by every animated decoder in play (APNG, GIF, WebP, TIFF), which is what makes
|
||
it a real bound rather than another advisory one. The code asks for `affordable + 2` so an
|
||
animation exactly at the limit still decodes in full while anything over it is visible to the
|
||
post-decode check; the slop is at most two frames, since `MaxFrames = N` yields `N` frames for
|
||
GIF/WebP/TIFF but `N-1` for APNG — the exact count varies by format, so only the upper bound is
|
||
relied on.
|
||
|
||
- **Decode budget** — `width x height x frames <= 50 MP`, verified against the decoded image.
|
||
- **Retention budget** — `frames x scaledWidth x scaledHeight <= 200 MP` (~800 MB at 4 bytes/px),
|
||
checked once the scale is known. Independent of the decode budget in both directions: a 100x100
|
||
source is trivial to decode but retains ~5 GB of `SKBitmap` at 600 frames scaled to 1920x1080,
|
||
because `LoadImage` clones and resizes every frame to output resolution and keeps them.
|
||
|
||
**The enforced peak is up to 3x the nominal decode budget, and that is deliberate.** Detecting
|
||
"over the limit" requires actually decoding more frames than the limit allows, so the ceiling is
|
||
`(affordable + 2) x perFrame`. In the pathological case — one frame that alone fills the budget,
|
||
so `affordable = 1` — that is 150 MP (~600 MB at Rgba32, ~1.2 GB for a 16-bit TIFF at Rgba64)
|
||
rather than 50 MP. Bounded and survivable, against the ~36 GiB it replaces, and the alternative
|
||
(tightening the single-frame allowance to budget/3) would reject legitimate 8K stills at 33 MP.
|
||
Stated here because the previous three versions of this entry each claimed a bound the code did
|
||
not actually enforce.
|
||
|
||
Other caveats: the budgets are in pixels, but a 16-bit PNG decodes to `Rgba64` (8 B/px), so the
|
||
byte cost doubles; the retention budget is per element, with no global ceiling across concurrent
|
||
streams; and 200 MP caps a full-frame 1080p animated overlay at ~96 frames (~3.2s at 30fps), which
|
||
is the one limit here that could plausibly bite a legitimate user rather than an attacker.
|
||
|
||
**A workaround rides along with the header pre-pass.** `Image.IdentifyAsync` is called with
|
||
`MaxFrames = 1` — not as a limit, but because a *default* `Identify` throws
|
||
`InvalidImageContentException` on most APNGs (measured: 13 of 16 shapes, including files
|
||
ImageSharp's own `PngEncoder` wrote) that `Image.Load` reads back perfectly. Adding the pre-pass
|
||
without it would have silently disabled every animated-PNG logo that worked before this change —
|
||
a functional regression introduced *by* a hardening change, caught only because the reviewer swept
|
||
shapes rather than trusting the one the tests happened to use.
|
||
|
||
Both budgets are enforced by pure functions (`EnsureDecodeAffordable`, `EnsureScaledFramesAffordable`)
|
||
so the arithmetic is tested at every boundary without materializing multi-gigabyte images, and both
|
||
call sites have wiring coverage (deleting either one fails a test). The APNG case is pinned by a
|
||
regression test that asserts the header under-reports *and* that the decode is rejected anyway.
|
||
Local images are deliberately exempt: those are files an operator put on disk, not bytes an
|
||
arbitrary host returned.
|
||
|
||
**Content type is checked permissively.** A positively-not-an-image type (an HTML error page) is
|
||
rejected before the decoder sees it, but a *missing* type and `application/octet-stream` are
|
||
allowed — hosts omit the header and static file servers default to octet-stream often enough that
|
||
strictness would break working logos, while buying little: ImageSharp decodes by magic bytes, so
|
||
the size and dimension caps are what actually protect the decoder.
|
||
|
||
**Redirects stay enabled but capped at 3** (the default is 50). Logo hosts and CDNs legitimately
|
||
redirect, so disabling them would break real configurations.
|
||
|
||
**Not cached — re-fetched on each element initialization, i.e. per playout item.** #502's entry
|
||
noted that a fetch-once-into-the-image-cache design would also erase its per-frame cost asymmetry,
|
||
so caching was considered here and rejected *for now*: with pooling, a 10s ceiling and a 10 MiB cap,
|
||
one small GET per item transition is not a cost worth a cache's invalidation policy (when does an
|
||
admin's logo change take effect?) and lifetime questions. It also keeps the codebase-wide
|
||
"external artwork passes through, it is not downloaded into the image cache" convention #502
|
||
established intact. Revisit only with a measurement showing the re-fetch actually costs something.
|
||
|
||
**SSRF is accepted, not mitigated.** `ChannelValidations.ValidateLogo` still only checks the scheme,
|
||
so an admin-set logo URL remains a request origin inside the container's network, and the redirect
|
||
cap bounds hop count, not destination — an approved host can still redirect hop three to
|
||
`169.254.169.254` or `127.0.0.1:<port>`. This is deliberate, and the load-bearing reason is that
|
||
the primitive is **blind**: the response body is never returned to any user, only decoded and
|
||
composited into a video frame, and failures surface only as a log line. Combined with the
|
||
capability being admin-only — and with LAN-hosted logos being a legitimate, common setup here, so a
|
||
private-IP denylist would break working installs — the exposure does not justify the breakage.
|
||
Revisit if logo URLs ever become settable through a lower-privilege path, or if any fetch result
|
||
becomes readable by a caller; note that "admin-only" is the weaker half of this argument and
|
||
blindness is the stronger.
|
||
|
||
Failures are surfaced as exceptions rather than a failure value, because both call sites
|
||
(`WatermarkElement`, `ImageElement`) already wrap initialization in a catch that sets
|
||
`IsFinished` — so a dead, slow or oversized URL degrades to "element disabled" and never kills the
|
||
stream. `WatermarkElementRemoteImageTests` pins that contract, including that a local path never
|
||
touches the fetcher.
|
||
|
||
The fetcher distinguishes its own deadline (rethrown as `TimeoutException`) from caller
|
||
cancellation (propagated as `OperationCanceledException`) via an exception filter on
|
||
`cancellationToken.IsCancellationRequested`. Be aware this distinction is currently **observationally
|
||
inert**: the elements' pre-existing blanket `catch (Exception)` swallows both one frame up, so a
|
||
shutdown mid-tune logs a spurious per-element warning. The filter is kept because it makes the
|
||
fetcher correct on its own terms and the log message names the real cause; making the elements
|
||
re-throw cancellation is a separate, pre-existing concern.
|
||
|
||
## 2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)
|
||
`key: docs.decision-lifecycle` · `status: active` · `since: 2026-07-21` · `supersedes: docs.append-only-guard@2026-07-12` · `superseded-by: none`
|
||
**Rule:** every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement.
|
||
**Signals:** decision lifecycle, supersession, archive · paths: `scripts/decisions_lib.py`, `scripts/decisions_validate.py`, `scripts/build_decisions_catalog.py`, `docs/decisions/archive/` · issues: #520, #521
|
||
**Mechanics:** `scripts/decisions_validate.py` (`PYTHONPATH=. python3 scripts/decisions_validate.py`); catalog regen `scripts/build_decisions_catalog.py`; `docs/decisions/migration-map.md`
|
||
|
||
**This replaces `docs.append-only-guard` (ersatztv#303 H9), not just extends it.** The old guard treated
|
||
`docs/decisions.md` as a single append-only text file: any line-delete was blocked by a Husky
|
||
`commit-msg` hook + CI job, and a "supersession" was a hand-written `> **Superseded …**` banner
|
||
prepended to the old entry plus an `(superseded)` Index tag — prose conventions an agent had to
|
||
remember and apply correctly by hand, with no machine check that the banner, the tag, and the new
|
||
entry's back-reference actually agreed with each other. That is exactly the failure mode this arc
|
||
(#520/#521) set out to fix: state should be derived and checked, not asserted in prose (the same
|
||
throughline as #303 H6's `## Done-when`).
|
||
|
||
**The new model.** Every decision record — whether still in force or not — gets a `key` (dotted,
|
||
stable identity independent of its heading text or which file it lives in), a `status`
|
||
(`active`/`superseded`/`retired`/`legacy-unmigrated` for anything not yet carrying the schema), a
|
||
`since` date, and a reciprocal `supersedes`/`superseded-by` pair of keys. `scripts/decisions_lib.py`
|
||
parses the schema; `scripts/decisions_validate.py` enforces: at most one `active` record per key;
|
||
`superseded`/`retired` records MUST live under `docs/decisions/archive/`, never in an active file;
|
||
an `active` record MUST NOT live under `archive/`; every `supersedes`/`superseded-by` key must
|
||
resolve to a record that actually exists (active or archived); and — replacing the append-only
|
||
guard's line-delete detection — a record's rationale prose (the schema strips only the contiguous
|
||
metadata block before comparing) must not change between a PR's base and head unless the commit
|
||
carries `[decisions-edit]`, and a record that disappears from the active set without a matching
|
||
archive copy is flagged as an unlogged removal. `scripts/build_decisions_catalog.py` regenerates
|
||
`docs/decisions/README.md`, a compact router listing only `active` records by key, so an agent
|
||
doesn't have to read the full chronological log (or the archive) to find the current rule.
|
||
|
||
**Why this is a strictly better enforcement of the same spirit, not a loosening.** The old guard's
|
||
actual invariant was never "no line may change" — it was "history is never silently rewritten to
|
||
erase *why we changed our mind*." A hand-prepended banner satisfied that invariant by convention; a
|
||
validator that checks reciprocal keys, archive placement, and rationale-prose stability satisfies it
|
||
by construction, and additionally catches classes of mistake the old guard could not (a supersession
|
||
banner added without moving the entry out of the active read-path; a `superseded-by` reference to a
|
||
key that doesn't exist; two `active` records quietly claiming the same key after a copy-paste).
|
||
`[decisions-edit]` is kept, narrowed to its one remaining legitimate use — an actual rationale-prose
|
||
edit (fixing a factual error) — since routine lifecycle moves (a status flip, an archive relocation)
|
||
are now token-free and machine-checked instead.
|
||
|
||
**This PR is the first to exercise the new regime, on itself.** `docs.append-only-guard` (the H9 half
|
||
of #303's combined H9/H3 entry; H3's root-screenshot guard is untouched and stays active) is marked
|
||
`status: superseded` and relocated to `docs/decisions/archive/release-ci-governance.md`, with this
|
||
record as its `superseded-by` and this record's `supersedes` pointing back — the same-PR supersession
|
||
pattern the new rule itself mandates for every future reversal. `docs/decisions/migration-map.md`
|
||
tracks the legacy-heading → key → status → location mapping as the rest of the log migrates
|
||
(ersatztv#520/#521, ongoing — most existing headings remain `legacy-unmigrated` until touched).
|
||
|
||
## 2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520)
|
||
`key: startup.parallel-orientation` · `status: active` · `since: 2026-07-21` · `supersedes: docs.queue-state-gitea-tracker@2026-07-11` · `superseded-by: none`
|
||
**Rule:** A fresh session runs two concurrent tracks at startup — Orientation (`AGENTS.md`/`CLAUDE.md` → `docs/README.md` task-signal map → the active decisions catalog `docs/decisions/README.md`) and, only when no issue is named, Selection (`scripts/select-queue.sh N`, deterministic live-Gitea ranking). A named issue skips Selection entirely. ersatztv#237, the closed pickup tracker this replaces, is reduced to a single archival breadcrumb and MUST NOT be read for live state.
|
||
**Signals:** startup protocol, queue selection, MemPalace retrieval, breadcrumb rule · paths: `docs/handoffs/chicorytv-issue-queue.md`, `scripts/select-queue.sh`, `docs/README.md`, `scripts/check-kickoff-guard.sh` · issues: #237, #520, #521, server-management#642
|
||
**Mechanics:** `docs/handoffs/chicorytv-issue-queue.md` → "Two concurrent tracks at session start" + "Knowledge retrieval"; `scripts/select-queue.sh`; regression guard `scripts/check-kickoff-guard.sh` (wired into the `decisions lifecycle` CI job)
|
||
|
||
**Why this reverses `docs.queue-state-gitea-tracker` (2026-07-11), not just extends it.** That
|
||
record's rule was "queue state lives in pinned tracker #237, not in the handoff file" — sound in
|
||
2026-07-11 when the arc was live and #237's body/comments were the only concurrency-safe place to
|
||
put session state. Two things changed: (1) `scripts/select-queue.sh` (2026-07-19) already replaced
|
||
the *mechanical* selection logic with a deterministic query over live Gitea state — no script reads
|
||
#237's prose; and (2) the arc itself completed and #237 **closed** on 2026-07-13. A closed issue
|
||
cannot be "where live queue state lives" — continuing to point agents at it was no longer just
|
||
stale, it was actively wrong, and the old record's own text ("Pinned tracker issue #237 holds the
|
||
goal + ordered arc") was live regression bait: a session reading it verbatim would try to treat a
|
||
closed tracker as authoritative. So this is a genuine reversal (the *store* changes from "a specific
|
||
Gitea issue" to "live Gitea queries, no pinned issue required"), not a mechanical follow-on.
|
||
|
||
**What the new protocol is.** Session start runs two independent tracks, neither blocking the other:
|
||
**Orientation** — read `AGENTS.md`/`CLAUDE.md`, then `docs/README.md`'s task-signal map (replacing
|
||
the old mandatory 1–10 reading order), then the active decisions catalog `docs/decisions/README.md`
|
||
for "why do we do X" questions. **Selection** — only when the user hasn't named an issue, run
|
||
`scripts/select-queue.sh [N]`, which excludes `in-progress`/`parked`/PRs, resolves
|
||
`GET /issues/{n}/dependencies`, tiers by LOCAL `.milestone.state`/`review`/`priority:` filters, and
|
||
orders deterministically; it flags `CLAIM?`/`UMBRELLA?` for the orchestrator to resolve by reading
|
||
the flagged issue, and does not rank an arc tier (none is active in maintenance/backlog mode — if
|
||
one is ever re-established, extend the script, never revert to prose-derived ranking). A
|
||
user-named issue skips Selection outright and goes straight to focused retrieval on that issue.
|
||
|
||
**MemPalace retrieval contract (server-management#642, the sole Gitea→MemPalace exporter).**
|
||
MemPalace is candidate discovery only, verified against its cited source before use, at a default
|
||
`ErsatzTV-Decisions` wing; the four load-bearing orientation bullets — catalog-first for current
|
||
rules, issue history as evidence not authority, the breadcrumb rule (a file path in a historical
|
||
comment resolves by concept via the active wing, never by literal path-chase), and the
|
||
catalog→`rg` exact-search fallback when MemPalace is stale/down — are carried verbatim in
|
||
`docs/README.md` and `docs/handoffs/chicorytv-issue-queue.md`. Never derive live queue state from
|
||
MemPalace, #237, or historical comments; live Gitea state via `scripts/select-queue.sh` is the only
|
||
source of truth for pickup ordering. The `## Closing record` template (Outcome / Root cause /
|
||
Decisions changed / Reusable knowledge / Verification / Deferred / Docs updated) is the structured
|
||
per-issue artifact this retrieval contract is built to consume — see `CLAUDE.md` → Task Completion
|
||
Protocol and the kickoff's "Closing record" section.
|
||
|
||
**Regression guard.** `scripts/check-kickoff-guard.sh` greps the standing startup docs
|
||
(`docs/handoffs/chicorytv-issue-queue.md`, `docs/README.md`, `CLAUDE.md`, `scripts/select-queue.sh`)
|
||
for #237-as-live-state phrasings ("read #237", "tracker #237", "queue state lives in", …), exempting
|
||
lines/sections that are explicitly archival. It is wired into the `decisions lifecycle` CI job so a
|
||
future PR cannot silently reintroduce the reversed rule.
|
||
## 2026-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)
|
||
`key: graphics.channel-logo-caching` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** An external `http(s)` channel-logo URL is fetched, decode-budget-validated, and stored in the image cache under a content-hash name at SAVE time — becoming byte-identical to an uploaded logo — so the render path never fetches a logo over HTTP; a bad URL fails the save with a 422 (BaseError → ValidationProblemDetails).
|
||
**Signals:** channel logo url · watermark on-screen bug · paths: `ErsatzTV.Infrastructure/Images/RemoteLogoCacher.cs`, `ErsatzTV.Core/Images/RemoteImageDecodeBudget.cs`, `ErsatzTV.Application/Channels/Commands/*ChannelHandler*.cs`, `ErsatzTV/Services/RunOnce/ExternalLogoMigratorService.cs` · issues: #525, #511, #502
|
||
**Mechanics:** `docs/channels.md` → Channel logo & on-screen bug; `docs/api-conventions.md` → error mapping
|
||
|
||
`ImageElementBase.LoadImage` used to fetch an external-URL logo over HTTP *inside stream startup*,
|
||
once per playout item. #511 bounded that fetch (timeout, wire cap, redirect cap, decode budgets) but
|
||
left it in the render path, where a dead/slow/oversized/non-image URL surfaces only as a render-time
|
||
log line — invisible to the operator who typed it, and re-paid every playout-item transition.
|
||
|
||
**A URL is now an input method, not a storage format.** On save, `IRemoteLogoCacher` fetches the URL
|
||
(reusing #511's hardened `IRemoteImageFetcher`), validates it against the shared
|
||
`RemoteImageDecodeBudget` (via `IRemoteImageValidator`), and writes the bytes through
|
||
`IImageCache.SaveArtworkToCache`, storing the returned MD5 content-hash name in `Artwork.Path`. After
|
||
a successful save the logo is indistinguishable from an uploaded one, so **every downstream consumer
|
||
is unchanged** — M3U, XMLTV and the SPA mapper all resolve `Artwork.Path` to an `/iptv/logos/…` URL,
|
||
and the render path finds a local cached file. To refresh a changed remote image the operator
|
||
re-enters the URL; there is deliberately no refresh button and no staleness/ETag tracking (the
|
||
content hash makes a re-add of unchanged bytes a natural no-op and of changed bytes a natural new
|
||
name).
|
||
|
||
**Content-hash name, not a GUID.** `SaveArtworkToCache` already returns an opaque MD5-of-bytes name
|
||
identical to the upload path, so there is one cache convention rather than two — and dedup + change
|
||
detection fall out for free. A GUID would deviate for no benefit.
|
||
|
||
**The decode budget now guards uploads too.** The byte/wire cap does not bound decoding, so the same
|
||
`RemoteImageDecodeBudget` (product of `width × height × frames ≤ 50 MP`, `≤ 600` frames, enforced on
|
||
the decoder via `DecoderOptions.MaxFrames` and re-verified against the decoded image — header frame
|
||
counts lie, see #511) is applied at BOTH the URL-download path and `UploadArtworkHandler`. One rule:
|
||
anything entering the logo cache is budget-checked, however it arrived. This closes a pre-existing
|
||
gap that this feature would otherwise have widened (a URL logo becoming an unchecked upload).
|
||
|
||
**Narrows `ffmpeg.remote-image-fetcher-bounded` (#511) and `ffmpeg.external-logo-graphics-engine`
|
||
(#502), does not reverse either.** #511's `IRemoteImageFetcher` bounded-fetch primitive and its "not
|
||
cached, re-fetched per element init" statement REMAIN active for operator-authored YAML `image:`
|
||
graphics elements, which still legitimately fetch a URL at render time — only channel logos moved to
|
||
save-time caching. #502's "external artwork passes through, it is not downloaded into the image
|
||
cache" still describes the CLIENT-facing consumers (M3U/XMLTV/SPA emit whatever `Artwork.Path`
|
||
resolves to) — now a cache URL rather than the raw external URL, because the row no longer holds a
|
||
URL. So neither predecessor is superseded (both stay `active`); this is a new decision layered on
|
||
top, hence `supersedes: none` — not a keyed supersession.
|
||
|
||
**Existing rows migrate at startup, fail-open.** `ExternalLogoMigratorService` (a run-once
|
||
`BackgroundService`, after the schema migrator + DB cleaner) downloads existing URL logo rows into
|
||
the cache; a row whose download fails is left exactly as-is with a warning naming it, and
|
||
`WatermarkSelector.ChannelLogoWatermarkOptions` degrades such a leftover URL to "no on-screen bug"
|
||
(a warning, never a render-time fetch). The migration is idempotent by construction — a converted
|
||
row's path is no longer a URL, so a second pass selects it out — and all-or-nothing on cancel (a
|
||
single trailing `SaveChangesAsync`).
|
||
|
||
**Accepted residual:** the SPA can render a not-yet-migrated external-URL logo as an `<img>` preview
|
||
that looks working while the server-side bug won't resolve until the row is re-saved/migrated — a
|
||
narrow transitional-state cosmetic mismatch, since the startup migration eagerly converts old rows.
|
||
|
||
## 2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529)
|
||
`key: ffmpeg.qsv-extra-hw-frames-floor` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all.
|
||
**Signals:** QSV, extra_hw_frames, hwupload, hardware frame pool, ENOMEM, "Could not open encoder before EOF", readrate as an incidental allocation bound · paths: `FFmpegState`, `QsvPipelineBuilder`, `HardwareUploadFilter`, `ScaleQsvFilter`, `DeinterlaceQsvFilter` · issues: #529, #350, #516, #519
|
||
**Mechanics:** `FFmpegState.MinimumQsvExtraHardwareFrames`; `FFmpegState.QsvExtraHardwareFrames`; `QsvPipelineBuilder` logs once when a configured value is raised
|
||
|
||
- **`extra_hw_frames=0` is not a valid pool size; it is a dead channel waiting for an unthrottled
|
||
read.** `FFmpegState.QsvExtraHardwareFrames` honored a stored `0` literally, so
|
||
`hwupload=extra_hw_frames=0` reached FFmpeg with no headroom for frames in flight through the
|
||
filter graph. Measured against the deployed FFmpeg 8.1.2 on one real logged command (software
|
||
mpeg4 decode → `hwupload` → `vpp_qsv` → `h264_qsv`): the graph fails with `-12 (Cannot allocate
|
||
memory)`, `h264_qsv` reports "Could not open encoder before EOF", and **zero segments are
|
||
written**. It now clamps to `MinimumQsvExtraHardwareFrames` (64), which is also the value
|
||
`IfNone` already used for an unset profile and the seeded profile default.
|
||
- **Input throttling was the only thing hiding it, which is why this looked like a #350
|
||
regression.** Truth table, same command, same binary, only the marked tokens differing:
|
||
|
||
| readrate | extra_hw_frames | result |
|
||
|---|---|---|
|
||
| `1.05`, no burst | 0 | 14 segments, exit 0 |
|
||
| `1.05` + burst 2 / 4 / 8 | 0 | **ENOMEM, 0 segments** |
|
||
| no readrate at all | 0 | **ENOMEM, 0 segments** |
|
||
| `1.05` + burst 8 | 64 | 14 segments, exit 0 |
|
||
| no readrate at all | 64 | 14 segments, exit 0 |
|
||
|
||
So the defect predates #350's burst: any work-ahead start (which takes no `-readrate`) on a
|
||
pipeline that uploads to QSV was already failing on a profile with `0`. The burst did not
|
||
introduce it — it removed the throttle on *every* realtime session, converting an intermittent
|
||
failure into a near-deterministic one, which is how it finally got noticed.
|
||
- **`-readrate` was doing load-bearing work nobody had written down.** Its stated job is live-TV
|
||
pacing; it was *also* incidentally bounding how fast decoded frames enter the filter graph. This
|
||
is why #350's FFmpeg-level benchmark and #516's argument-generation tests were both green and
|
||
neither could see it: the burst is bounded in **seconds of input**, which is not a bound on
|
||
**memory or hardware surfaces**. Corrects the #350 entry above, which records the burst as
|
||
bounded and safe and does not mention hardware frame pools.
|
||
- **A floor, not a clamp-to-default — and the floor is wider than the evidence.** Values above 64
|
||
are honored unchanged; values below it are raised. We measured only `0` (fails) and `64` (works),
|
||
so `1..63` are **untested, not known-bad**: we raise them rather than trust them, because the
|
||
failure they risk is a channel that serves nothing at all. That is a deliberate over-reach, and
|
||
it is not free — `extra_hw_frames` allocates *additional* surfaces (64 NV12 1080p surfaces ≈
|
||
190 MiB, ≈760 MiB at 4K), so an operator who deliberately set a small pool on a memory-constrained
|
||
iGPU silently gets a larger one. `QsvPipelineBuilder` therefore **logs a warning** naming both the
|
||
configured and the applied value, so the override is discoverable rather than silent. If a smaller
|
||
pool is ever measured safe, lower the floor rather than removing it.
|
||
- **Fixed at `FFmpegState.QsvExtraHardwareFrames`, the single point every QSV upload site reads.**
|
||
`HardwareUploadFilter`, `HardwareUploadQsvFilter` and `WatermarkHardwareUploadFilter` read it
|
||
directly; `ScaleQsvFilter` and `DeinterlaceQsvFilter` take values `QsvPipelineBuilder` passes down
|
||
from it. One guard covers them all rather than five call sites that can drift apart. (A sixth
|
||
formatter, `SubtitleScaleQsvFilter`, also emits `extra_hw_frames` but is currently dead code — no
|
||
construction site exists in the solution — so it is *not* covered by this guard and would need the
|
||
same value threading if it is ever revived.)
|
||
|
||
**Accepted residual:** the floor is applied at render time only, so a stored `0` keeps displaying as
|
||
`0` in the SPA and over `GET /api/v1/ffmpeg/profiles/{id}` while FFmpeg receives 64 — the config no
|
||
longer literally describes the behavior. Render-time was chosen deliberately: it fixes every existing
|
||
deployment with no DB edit and no migration, which matters because this bug is already failing
|
||
transcodes in production. New and updated profiles are normalized on save so stored rows converge on
|
||
the truth, and the warning log closes the discoverability gap for rows that predate it; a backfill
|
||
migration for old rows was judged not worth the dual-provider cost. Two consequences to know about:
|
||
the save-time normalization is **unconditional on `hardwareAcceleration`**, so a non-QSV profile's
|
||
stored value moves too (harmless — only the QSV path ever reads it — but it is a stored-state change
|
||
on a field the user didn't touch); and a machine client that `PUT`s `0` gets a `200` and then reads
|
||
back `64`, which is a silent transform of a submitted value that the OpenAPI description does not
|
||
advertise.
|
||
|
||
## 2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)
|
||
`key: docs.tracker-comment-retrofit` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent.
|
||
**Signals:** tracker retrofit, over-cap issue exclusion, worked-issue-first, tracker-is-not-a-knowledge-store, #237 comment history · paths: `docs/tracker-retrofit-triage-237.md` · issues: #524, #237, #520, #521, server-management#642
|
||
**Mechanics:** `docs/tracker-retrofit-triage-237.md` — method, per-comment classification table, totals, and the one candidate that was raised and disproved
|
||
|
||
**Why the worked-issue-first ordering is the load-bearing part.** The session protocol that produced
|
||
#237's log required each session to post its full closing record on the issue it actually worked, and
|
||
*then* summarize across issues on the tracker. The tracker entry is therefore the lossy copy. A
|
||
comment that looks like a unique source is nearly always a précis of a primary the exporter already
|
||
indexes — so the instinct "the tracker is excluded, therefore its knowledge is orphaned" inverts the
|
||
real dependency. Checking the corpus first and the worked issue second wastes the effort; the reverse
|
||
order settles most items in one lookup.
|
||
|
||
**Worked example.** Comment [106/111] recorded that #497's music-video metadata reconcile deliberately
|
||
excludes Guids (not eager-loaded by `GetOrAdd`, so reconciling would duplicate-insert every scan) and
|
||
Directors (not add-persisted for `MusicVideoMetadata`). Genuinely decision-shaped, and genuinely
|
||
absent from the decision corpus — it survives a corpus-only check and looks like a retrofit target.
|
||
Issue #497's own closing comment states both exclusions with fuller reasoning, and the exporter
|
||
ingests it as `497.md`. One worked-issue lookup disposes of it.
|
||
|
||
**Scope of the claim — deliberately narrow.** The coverage test was applied to **decision-shaped
|
||
items only**. Items classified as cross-cutting lore were classified but *not* coverage-checked, and
|
||
that bucket is **not** empty: two facts from comment [109/111] were found to have no home anywhere —
|
||
`scripts/e2e-local.sh`'s readiness probe hanging on a reused config dir, and the troubleshooting
|
||
playback API's inability to exercise channel branding. Both were swept into the handoff lore by this
|
||
PR. So the correct statement is "no *decision-shaped* orphans," not "no orphans"; a tracker triage
|
||
that skips the lore bucket will leave real knowledge on the floor.
|
||
|
||
**A zero result is a legitimate outcome**, not evidence the triage was done wrong. What would change
|
||
the answer is a tracker whose sessions did *not* also write per-issue closing records — one where the
|
||
tracker genuinely was the primary rather than the narration layer.
|
||
|
||
## 2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)
|
||
|
||
`key: ffmpeg.work-ahead-slot-atomic` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `workAheadSegmenterLimit` is enforced by a single compare-exchange claim on a shared `WorkAheadSlots` pool taken by the *caller* of `Transcode`, which then passes ownership in and gets the release in `Transcode`'s `finally` — never a `Volatile.Read` compare in one place and an `Interlocked.Increment` in another.
|
||
**Signals:** work-ahead slot, workAheadSegmenterLimit, check-then-act across an await, unthrottled tune-in, TOCTOU · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Run`/`Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #536, #350, #529, #231, #250
|
||
**Mechanics:** `WorkAheadSlots.TryAcquire(limit)` CAS loop; `Transcode(bool ownsWorkAheadSlot, …)`
|
||
|
||
**The defect was structural, not a missing `Interlocked`.** The write side already used
|
||
`Interlocked.Increment`, which is why the code read as thread-safe. The read side was a separate,
|
||
earlier `Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(...)` in `Run`, and the
|
||
increment happened later inside `Transcode` — with at least one `await` (a DB-backed config read) in
|
||
between. `Interlocked` on one half of a check-then-act buys nothing. Three simultaneous tune-ins on
|
||
prod with `workAheadSegmenterLimit: 1` all observed `0 < 1` and all ran with no `-readrate`.
|
||
|
||
**Why the caller claims and the callee releases.** Moving the whole acquire/release pair inside
|
||
`Transcode` would be more symmetric, but `Run` needs the outcome *before* the call: it sets
|
||
`_state = SeekAndWorkAhead | SeekAndRealtime`, and `Transcode` reads that state on entry
|
||
(`wasSeekAndWorkAhead`) to decide whether the item starts at `DateTimeOffset.Now` or at
|
||
`_transcodedUntil`. Folding acquisition inward would have silently changed that branch. Instead the
|
||
parameter was inverted — `Transcode(bool ownsWorkAheadSlot, …)` with `realtime = !ownsWorkAheadSlot`
|
||
derived on the first line — so the contract is stated in the signature rather than implied by a
|
||
double negative, and there is exactly one release site guarded by the same flag.
|
||
|
||
**Compare-exchange rather than increment-then-back-off.** The issue proposed
|
||
`Interlocked.Increment` followed by a decrement when the post-increment value exceeds the limit.
|
||
That is correct on holder count, but the counter transiently overshoots, so a concurrent reader of
|
||
`Count` can observe a value above the limit. The CAS loop never publishes a state that violates the
|
||
invariant, which matters because the QSV hardware-frame pool sizing (`ffmpeg.qsv-extra-hw-frames-floor`)
|
||
is derived from that bound.
|
||
|
||
**Testing shape is inherited from #231/#250.** A single `Barrier(N)` + `Task.WhenAll` round does not
|
||
reliably collide on this hardware; the tests hammer 8 threads × 20 000 rounds with a per-round
|
||
barrier that validates the winner count and resets the pool. The negative control is documented in
|
||
the test file: reinstate the check-then-act body (**not** `if (true)`, which trips CS0219 under
|
||
warnings-as-errors and leaves `--no-build` running a stale, still-fixed dll). Verified: with the
|
||
pre-fix shape, 15 912 of 20 000 rounds over-claimed.
|
||
|
||
## 2026-07-21 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68)
|
||
|
||
`key: scheduling.ondemand-guide-refresh-on-thaw` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline.
|
||
**Signals:** on-demand resume, bookmark playout, freeze the clock when unwatched, guide/EPG desync, "guide showed S2E3 while tune-in played S1E1", XMLTV cache staleness, mirror channel guide · paths: `ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs`, `ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs`, `ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs` · issues: #68
|
||
**Mechanics:** `IPlayoutTimeShifter.TimeShift` returns `List<string>` (shifted channel + its mirrors on a non-zero shift, empty otherwise); handler `foreach`-enqueues `RefreshChannelData` on `CancellationToken.None`
|
||
|
||
**This is what #68 ("resume/bookmark for sequential channels") actually needed.** The freeze-when-unwatched, resume-where-you-left-off behavior already existed as `ChannelPlayoutMode.OnDemand`: `Playout.OnDemandCheckpoint` persists the viewer's position, `UpdateOnDemandCheckpoint` advances it (monotonically, minus one segmenter-timeout of rewind-for-context) while watching, and `PlayoutTimeShifter` slides the whole schedule forward by `now − checkpoint` on the next tune-in so the item the viewer had reached is active again. Because the shift rewrites `GuideStart/GuideFinish` alongside `Start/Finish`, ErsatzTV **freezes the guide and playback together** — structurally avoiding the free-running-wall-clock desync the issue was filed about (a guide that keeps ticking while playback resumes from a saved spot).
|
||
|
||
**The one gap was cache freshness, not the timing model.** `RefreshChannelData` was enqueued by playout-build and channel/config edits, but **not** by the on-tune-in time shift, and `GetChannelGuideHandler` serves a cached `.xml` fragment (the live-recomputed SPA JSON guide already self-healed). So after a thaw an external EPG client (Jellyfin) could poll a guide reflecting the pre-shift timeline until the next incidental rebuild. `BuildPlayoutHandler` already refreshes the guide after it time-shifts — including a fan-out to every channel that mirrors the built channel, because `RefreshChannelDataHandler` refreshes only the one channel it is handed and does not cascade downstream. The tune-in path (`TimeShiftOnDemandPlayoutHandler`) was unpatched, so this decision replicates both halves: the source channel's guide **and** its mirrors' guides are rebuilt on thaw. A channel mirroring an on-demand source is an uncommon combination, but omitting it would leave the same desync one hop out.
|
||
|
||
**Why the return-value plumbing rather than enqueuing inside the shifter.** `PlayoutTimeShifter` lives in `ErsatzTV.Infrastructure`, which cannot reference the `RefreshChannelData` request type (an `ErsatzTV.Application` type), so the enqueue must happen in the Application-layer handler. `TimeShift` therefore returns `Option<string>` — `Some(channelNumber)` only when a non-zero offset was actually persisted, `None` on every early-out (wrong mode, active-and-unforced, empty playout) and on a zero-offset re-tune — so a guide rebuild fires exactly once per real thaw, never on a no-op. The zero-offset `None` gate is covered by a dedicated non-vacuous test.
|
||
|
||
**Per-viewer resume was deliberately not built.** `OnDemandCheckpoint` is a single value on the playout, so resume is per-channel, not per-viewer. #68 states per-channel suffices for a single household; multi-viewer identity would diverge from this model and is out of scope.
|
||
|
||
## 2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)
|
||
|
||
`key: session.shared-checkout-refresh` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Session end runs `scripts/refresh-shared-checkout.sh`, which fast-forwards `/Users/timothy/ersatztv` to `origin/main` (and reinstalls `web/node_modules` when the lockfile moved), refusing to touch anything unless that tree is on a clean, non-ahead `main`.
|
||
**Signals:** shared checkout, stale kickoff paste, `/Users/timothy/ersatztv`, session-end protocol, H13 · paths: `scripts/refresh-shared-checkout.sh`, `docs/handoffs/chicorytv-issue-queue.md` · issues: #541, #520, #311, #312
|
||
**Mechanics:** `docs/handoffs/chicorytv-issue-queue.md` → session-end step 6 + the shared-tree lore bullet
|
||
|
||
**The existing rule had a hole, and it is a hole no rule can close.** The standing guidance — never
|
||
commit in the shared tree, never read its `HEAD`/`git log`/`git status` as truth about `main` — is
|
||
written entirely around a session **reading git state**. On 2026-07-21 the trap arrived as a **file**:
|
||
the kickoff prompt was pasted out of that tree while it was 81 commits behind, and the handoff doc it
|
||
carried still described the queue protocol #520 had retired the previous day (read tracker #237, which
|
||
`main` now says must *not* be read for queue state). No `git` command touched that tree all session, so
|
||
no discipline check could have fired. Selection happened to go through `scripts/select-queue.sh`, which
|
||
is why nothing broke — routing luck, not a control.
|
||
|
||
**So the fix removes the stale condition rather than adding a check**, which is what the shared-tree
|
||
lore bullet already prescribed for its first two failure modes: *"a check does not stay true."*
|
||
|
||
**The script is deliberately timid, because the tree is shared.** It refuses — loudly, exit 0,
|
||
changing nothing — when the tree is not on `main`, is dirty, has local commits, or is mid-rebase or
|
||
mid-merge. It never switches branches, never stashes, never discards. A refusal is a normal outcome,
|
||
not a failure, because the common reason for one is that another session is legitimately mid-flight.
|
||
|
||
**Two details that testing forced.** The first version used `npm install`, which **rewrote
|
||
`package-lock.json`** and left the shared tree dirty — the exact state the next run refuses on, so the
|
||
tool would have disabled itself after one use. It uses `npm ci`, which installs strictly from the
|
||
lockfile and never writes it. And it asserts the tree is clean at exit, reporting loudly if not:
|
||
leaving the shared tree dirty is the one outcome that would make this script a net negative.
|
||
## 2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539)
|
||
|
||
`key: ffmpeg.work-ahead-slot-release-never-negative` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `Release()` reads the count and compare-exchanges `current - 1` only when `current > 0`; a release against an empty pool records an unbalanced release and returns `false` **without ever writing a negative value**. It never decrements first and clamps afterward. The single caller (`HlsSessionWorker.Transcode`'s `finally`) logs a warning on the `false` return.
|
||
**Signals:** work-ahead slot release, unbalanced release, negative slot count, over-admit at limit 1, phantom work-ahead room, UnbalancedReleases counter · paths: `ErsatzTV.Application/Streaming/WorkAheadSlots.cs`, `HlsSessionWorker.Transcode`, `ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs` · issues: #539, #536, #529, #231, #250
|
||
**Mechanics:** `bool Release()` CAS loop guarded by `current <= 0`; call site `if (ownsWorkAheadSlot && !_workAheadSlots.Release()) _logger.LogWarning(...)`
|
||
|
||
**Why never-negative, given the release is already unreachable-when-unbalanced.** Today `HlsSessionWorker` has exactly one release site, guarded by `ownsWorkAheadSlot`, so an unbalanced release cannot happen — these were three **Low** findings from the #536 re-review, filed against the day someone adds a second release site. The pre-#539 shape decremented first (`0 → −1`) and clamped afterward, leaving a reachable interleaving where a concurrent `TryAcquire(limit)` reads the `−1`, sees phantom room, and admits a holder the budget doesn't have (a second acquirer then reads `0` and admits another) — two unthrottled transcodes at limit 1, re-opening the #529 QSV pool exhaustion. Because `TryAcquire` is the sole, CAS-guarded increment path, a count proven never-negative is exactly what forecloses that over-admit. Clamping *before* the decrement also records the breakage **synchronously on the offending thread**, instead of the decrement-first shape where the blame lands on a later, innocent release.
|
||
|
||
**`Release()` returns `bool` so the breach is visible in the logs, not only to tests.** #536's motivation was "a silently inflated budget with nothing in the logs to find it by," yet `UnbalancedReleases` was reachable only from a debugger or a test. `WorkAheadSlots` has no logger by design (it is a leaf primitive), so rather than plumb one in, `Release()` returns `false` on an unbalanced release and the caller — which already holds an `ILogger` — logs the warning. This is the one in-band signal that the ownership contract broke.
|
||
|
||
**`UnbalancedReleases` can under-count, and that is documented rather than fixed.** It only increments when a release finds the pool already empty. An over-release while the count is positive — e.g. one cancelling out a coexisting leak — decrements a real-looking slot and is never recorded, so the two bugs hide each other. There are no false positives (non-zero still means the contract broke), but zero does not prove correctness. Exact accounting would need per-owner tokens, which the #536 "ownership is a discipline, not a token" decision deliberately avoids; the docstring now states the limitation instead.
|
||
|
||
**Negative control (inherited from #231/#250).** A dedicated test hammers unbalanced releases on an empty pool while reader threads sample the count; none may ever observe a value below zero. Reinstating the pre-#539 decrement-first body makes it fail (`sawNegative > 0` — the readers catch the transient `−1`); verified. As with the #536 tests, break the primitive by reverting the real body, **not** `if (true)` (CS0219 under warnings-as-errors leaves `--no-build` running a stale, still-fixed dll).
|
||
## 2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135)
|
||
|
||
`key: api.from-lineup-clear-to-none` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `POST /api/v1/channels/from-lineup` (and the Auto-Tune per-channel `advanced`, which reuses the same DTO) distinguishes *inherit* from *clear-to-none* with a typed `clear` enum list on `advanced`. A field left null/omitted still inherits the template value (unchanged for every existing client); naming a field in `clear` forces it to none on the new channel even when the template sets one. Sending both a set value and a clear for the same field is a validation error.
|
||
**Signals:** clear to none, inherit vs none, advanced override, watermark/filler clear, template-minus-one-setting · paths: `ErsatzTV.Core/Api/Channels/CreateChannelFromLineupClearField.cs`, `ErsatzTV.Application/Channels/Commands/CreateChannelFromLineupHandler.cs`, `ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs`, `web/src/builder/advancedOptions.tsx` · issues: #135, #89, #385, #386
|
||
**Mechanics:** `CreateChannelFromLineupHandler.ResolveClearable`/`ValidateClear`; SPA `applyOverridesToRequest`/`collectClears`/`CLEAR` sentinel; api-conventions.md §2, spa-conventions.md
|
||
|
||
**Why a `clear` list, not a sentinel or per-field flags.** The gap (found in #89 review) was that the handler resolved every advanced override with `advanced.X ?? template.X`, so a client sending `null` always *inherited*. That is correct for the common path but leaves "this channel should have NO watermark / pre-roll filler even though the template has one" inexpressible. The fix had to keep `omitted = inherit` byte-stable for existing clients (`/api/v1` is frozen-additive, #286), so it is a new optional field, not a reshaping of the existing ones. A `{set, value}` wrapper per field would have rewritten every field's wire type; a reserved `0` sentinel is magic and asymmetric between int ids and strings; parallel `clearX` bools add one field per clearable. A single **typed enum list** is additive, self-documenting, type-checked (an invalid value is a 400 at model binding), covers ids and strings with one mechanism, and extends by adding an enum member. The clearable set is the eight template-inheritable fields where "none" is meaningful: watermark, the four fillers, and the preferred audio/subtitle language + audio title.
|
||
|
||
**Set + clear of the same field is rejected, not silently resolved.** The SPA never produces that state (a select is inherit, a value, or None), so the check exists to keep hand-crafted / machine-client requests unambiguous rather than picking a winner. A null/empty set value alongside a clear is fine (redundant, not conflicting).
|
||
|
||
**The enum lives in `ErsatzTV.Core`, not the Application command, on purpose.** The OpenAPI string-enum pass (`Startup.UseStringEnumSchemas`) scans the Core assembly wholesale; an enum defined in `ErsatzTV.Application` renders as a bare `integer` in the spec while every sibling advanced-options enum (`PlaybackOrder`, `ChannelSubtitleMode`, …) is a string enum. Placing `CreateChannelFromLineupClearField` in `ErsatzTV.Core/Api/Channels/` makes the wire contract a string enum by construction, matching its siblings.
|
||
|
||
**SPA is id-fields-first; the API is complete ahead of the UI.** The Channel Builder + Auto-Tune DetailPanel re-add a real "None" option to the five id selects (watermark + fillers) — the pickers #89 had degraded to "Inherit"-only — routed through a `CLEAR` overrides sentinel folded into `advanced.clear` at request-build time (`applyOverridesToRequest`, so the sentinel never leaks as a field value). The three string clear-fields are covered by the backend enum for machine clients (MCP) but the SPA text inputs keep "empty = inherit"; adding a tri-state to those inputs is deferred, not blocked. This is the deliberate "REST API is a real audience" posture (`rest-api-purpose-mcp-and-new-ui`).
|
||
## 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)
|
||
|
||
`key: api.channel-preview-capability` · `status: active` · `since: 2026-07-21` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** Whether a channel can be previewed in the browser is declared by the server, not derived by the SPA, as an additive `Preview` field (`{Availability, ManifestUrl, UnavailableReason}`) on `ChannelResponseModel`.
|
||
**Signals:** a Play button that does nothing; preview eligibility inferred from a display string; a green preview on a Transport Stream channel being read as validating its configured pipeline · paths: `ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs`, `ErsatzTV.Application/Channels/Mapper.cs`, `web/src/screens/channels/ChannelPreviewPanel.tsx` · issues: #60
|
||
**Mechanics:** `Mapper.GetPreview(StreamingMode, channelNumber, iptvJwtEnabled)` is pure; `JwtHelper.IsEnabled` is read only at `ChannelController.GetAll` and passed in
|
||
|
||
`ChannelPreviewAvailability` is one of `Available`, `ForcedHlsOnly`, or `Unavailable`, computed in one
|
||
place from the real `StreamingMode` enum plus JWT status. The SPA renders and acts on it and derives
|
||
nothing — deriving it client-side would mean keying behavior off `Mapper.GetStreamingMode`'s
|
||
human-readable display label, where a copy tweak would silently break playback.
|
||
|
||
Only the two HLS modes are browser-playable; a browser cannot play the `video/mp2t` that the
|
||
Transport Stream modes serve over `/iptv/*`. Those are declared `ForcedHlsOnly`: preview is offered
|
||
only as an explicit opt-in that requests `/iptv/channel/{n}.m3u8?mode=segmenter`, and is always shown
|
||
with a caveat that the check does not exercise the channel's configured pipeline. Fatal HLS errors
|
||
are reported, never auto-recovered — a diagnostic surface must show the fault rather than retry past
|
||
it; a user-initiated Retry re-issues the manifest request via a real `playToken` because the manifest
|
||
GET starts a server-side session, so a byte-identical repeat URL would otherwise be a no-op.
|
||
|
||
`/iptv/*` does not accept the SPA's `ctv-session` cookie, and nothing mints a JWT for the SPA, so
|
||
under a JWT-enabled deployment preview is declared `Unavailable` with the reason `IPTV JWT
|
||
authentication is enabled` rather than failing silently against a 401.
|