MediaSourceRepository's Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows stamped SystemTime.MinValueUtc into library.LastScan, so normal use kept minting 0001-01-01 sentinel rows. #409 fixed the READ side (the API coerces the sentinel to null and a migration cleaned the historical residue), so the wire contract was already correct — this stops new sentinel rows being written. Safe because every remaining LastScan reader either coalesces (LastScan ?? SystemTime.MinValueUtc) for its own non-nullable scan-comparison needs, or is the API read-boundary coercion itself — audited every reference under ErsatzTV{,.Application,.Core,.Infrastructure,.Scanner,.Mcp} plus web/. There is no Where/OrderBy/GroupBy on LastScan anywhere, so the SQL null-ordering divergence between SQLite and MySQL has no surface here. Scan-comparison behavior is unchanged. Deliberately ships NO second cleanup migration: rows written between #409's NullOutNeverScannedLastScan and this change keep the sentinel at rest, and the permanent read coercion — not a migration — is what keeps the contract honest for them (as it must be anyway for a restored or hand-edited DB). LibraryPath.LastScan is likewise left untouched: the flows re-add Paths with their original values, and its only readers are the local-library scan handlers, so it has no API surface and no remote-scan effect. Regression test per provider (MediaSourceRepositoryDisableSyncTests), proven non-vacuous: restoring the MinValue writes fails all three. [decisions-edit] — the media.lastscan-null-boundary record documented this write as an ONGOING sentinel source and rested its "the coercion is permanent" argument on it, so the rationale prose is corrected in the same PR per docs.decision-lifecycle. The Rule line is unchanged, so the generated decisions/README.md catalog is byte-identical. MediaSourceRepository.cs also loses its UTF-8 BOM (fix-as-you-touch, #311). fixes #460
3653 lines
352 KiB
Markdown
3653 lines
352 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 — 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-07-25 — Rule-builder group nesting is bounded-arbitrary depth (`MAX_GROUP_DEPTH`), not one level (#436)](#2026-07-25--rule-builder-group-nesting-is-bounded-arbitrary-depth-max_group_depth-not-one-level-436)
|
||
|
||
---
|
||
|
||
## 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 — 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 — 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, #460
|
||
**Mechanics:** `NullOutNeverScannedLastScan` migration; `LibrariesScreen.tsx` `hasScanned` removal; #460 write-side `null`
|
||
|
||
`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 appeared in the
|
||
DB from **two** sources — both now closed:
|
||
1. Old `Reset_*` migrations wrote it via raw SQL (`UPDATE Library SET LastScan = '0001-01-01 00:00:00'`).
|
||
2. Live code wrote it on the Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows in
|
||
`MediaSourceRepository`. **Closed by #460**: those three writes now set `null`, so normal use no longer
|
||
produces new sentinel rows. Safe because every remaining `LastScan` reader either coalesces
|
||
(`LastScan ?? SystemTime.MinValueUtc`) for its own non-nullable scan-comparison, or is the read-boundary
|
||
coercion below — scan-comparison behavior is unchanged.
|
||
|
||
`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 guard** (provider/history-independent):
|
||
`GetAllMediaSourcesForApiHandler.NormalizeLastScan` maps any `< 2000-01-01` value to null. It stays
|
||
*permanent* even after #460 closed source #2: it is what makes the contract hold for sentinel rows this
|
||
codebase is not going to rewrite — a restored or hand-edited DB, and any row written by source #2
|
||
between #409's cleanup migration and #460 — and a migration-only fix would have regressed the next time
|
||
a user toggled 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.
|
||
|
||
**#460 (write side, 2026-07-25):** the follow-up this record deferred is done — the three disable-sync
|
||
writes set `null` instead of `MinValue`, so **no new** sentinel rows are created. Deliberately ships
|
||
**no** second cleanup migration: rows written by source #2 between #409's `NullOutNeverScannedLastScan`
|
||
and this change still hold the sentinel at rest, and the read coercion above (not a migration) is what
|
||
keeps the contract honest for them. Regression-tested per provider in
|
||
`MediaSourceRepositoryDisableSyncTests`. `LibraryPath.LastScan` is deliberately left untouched — the
|
||
disable-sync flows re-add `Paths` with their original values, and the only readers of the path-level
|
||
column are the local-library scan handlers, so it has no API surface and no remote-scan effect. This
|
||
*extends* the rule rather than reversing it: the read-boundary coercion is unchanged and authoritative.
|
||
|
||
## 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, remove-stale+add-new dedup · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496, #500
|
||
**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. #500 — when mirroring the remove-stale + add-new idiom, dedup the incoming set on **the same key its add filter compares** (the filter is materialized before the loop mutates `existing`, so duplicates both pass): `Name`, `Guid` for guids, and for Plex `Actors` an artwork-preferring dedup shared with the remove filter (whose key is `(Name, artwork-presence)`). Remaining un-deduped copies of the idiom: #600.
|
||
|
||
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-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74)
|
||
`key: graphics.channel-level-attachment` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A channel can attach `GraphicsElement`s directly via a new `ChannelGraphicsElement` join table (a base layer under deco/playout-item elements), and a built-in text element (`on-now-next.yml`) is seeded once per database so the On Now/Next overlay works out of the box.
|
||
**Signals:** ChannelGraphicsElement, Channel graphics attachment, GraphicsElementSelector base layer, on-now-next seeded element, GraphicsElementDefaults.OnNowNextFileName, builtIn discriminator · paths: `ErsatzTV.Core/Domain/ChannelGraphicsElement.cs`, `ErsatzTV.Core/FFmpeg/GraphicsElementSelector.cs`, `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs`, `ConfigElementKey.GraphicsOnNowNextSeeded`, `GraphicsElementResponseModel.BuiltIn` · issues: #74
|
||
|
||
#74 asked for a transient "On Now / Next" text bug burned onto the transcoded stream at each
|
||
program transition. The rendering and EPG-template-data infrastructure already existed (upstream
|
||
graphics engine + our #502/#511 remote-image/graphics-engine work); the gap was that graphics
|
||
elements had **no channel-level attachment** — only `PlayoutItem`/`ProgramScheduleItem`/`BlockItem`/
|
||
`Deco` joins existed — and there was **no seeded/built-in graphics element**, unlike watermarks.
|
||
|
||
**Decision: add a 5th join table, `ChannelGraphicsElement`, rather than reuse the watermark FK.**
|
||
Watermarks and graphics elements are separate parallel systems; a channel already has exactly one
|
||
`WatermarkId`, already spent on the #67 logo bug, and multi-line EPG text is a poor fit for the
|
||
single-image watermark model. `ChannelGraphicsElement` is structurally identical to the existing
|
||
four joins (composite key `{ChannelId, GraphicsElementId}`), added via a dual-provider migration
|
||
(`scripts/add-migration.sh Add_ChannelGraphicsElement`).
|
||
|
||
- `GraphicsElementSelector.SelectGraphicsElements` appends channel-level elements at the **final
|
||
fall-through**, alongside `playoutItem.PlayoutItemGraphicsElements` — a **base layer**. A deco in
|
||
`Merge` mode composes with it; a deco in `Override`/`Disable` mode returns earlier and so
|
||
suppresses it (decos are allowed to override channel defaults, a deliberate rule). One more
|
||
suppression path: on a **filler** item, a deco whose graphics-elements section is not set to run
|
||
during filler (`UseGraphicsElementsDuringFiller` false) clears the result and returns for `Merge`
|
||
and `Override` alike, so the channel base layer is dropped there too.
|
||
`HttpLiveStreamingDirect` continues to return empty (ErsatzTV isn't transcoding, so there is no
|
||
frame pipeline to draw into).
|
||
- **Seeded built-in element**, mirroring the `iptv.logo-drives-bug-preset` (#67) pattern:
|
||
`GraphicsElementSeeder.SeedOnNowNext` writes `on-now-next.yml` into the graphics-elements
|
||
templates folder (only if the file is absent — operator edits are never clobbered) and ensures a
|
||
`GraphicsElement` row exists for it, guarded by the `graphics.on_now_next_seeded` `ConfigElement`
|
||
marker so it runs once per database, not once per file-absence (the same reasoning as #67: the
|
||
seeder runs at every startup, so a name/file-only guard would resurrect a deliberately deleted
|
||
preset).
|
||
- The API needed a way for the SPA to find the built-in element without a fragile name-match — the
|
||
direct #67 lesson (`WatermarkResponseModel.imageSource`). `GraphicsElementResponseModel` gained a
|
||
server-derived `BuiltIn` bool, computed by comparing the row's `Path` filename to
|
||
`GraphicsElementDefaults.OnNowNextFileName` rather than trusting the element's editable `Name`.
|
||
- The channel editor's Branding-tab "Show On Now / Next overlay" switch follows the exact pattern
|
||
of the existing logo-bug toggle: on adds the built-in element's id to `graphicsElementIds`, off
|
||
removes it; disabled (with an explanatory caption) when the channel is HLS-Direct.
|
||
|
||
**Accepted trade-off:** all channels that enable the toggle share one seeded element's geometry/
|
||
content; per-channel customization means editing the shared YAML or attaching a different element
|
||
(the join is general, not restricted to the seeded one).
|
||
|
||
## 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, #552
|
||
**Mechanics:** `Mapper.GetPreview(StreamingMode, channelNumber, isEnabled, playoutCount)` is pure and JWT-agnostic
|
||
|
||
`ChannelPreviewAvailability` is one of `Available`, `ForcedHlsOnly`, or `Unavailable`, computed in one
|
||
place from the real `StreamingMode` enum plus the channel's enabled/playout state. 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.
|
||
|
||
`Unavailable` covers two causes, checked in this order (first match wins): the channel is disabled
|
||
(`IptvController` 404s a disabled channel, so preview must not even try), and the channel has zero
|
||
playouts (a manifest request against one blocks indefinitely). At first pass these two were keying
|
||
preview on `StreamingMode` alone, so a disabled or playout-less channel was declared `Available` and
|
||
then failed confusingly.
|
||
|
||
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.
|
||
|
||
Originally, a JWT-enabled deployment made preview `Unavailable` (reason `IPTV JWT authentication is
|
||
enabled`) because `/iptv/*` does not accept the SPA's `ctv-session` cookie and nothing minted a JWT
|
||
for the browser. #552 closed that: the SPA now mints a short-lived token and appends it as
|
||
`?access_token=`, so this projection no longer inspects JWT status at all. See
|
||
`security.iptv-browser-token`.
|
||
## 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381)
|
||
|
||
`key: testing.scripted-playout-golden-deferred` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it.
|
||
**Signals:** why is there no scripted golden; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; ScriptedScheduleController 1:1 pass-through; engine-level scripted regression net; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`, `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs` · issues: #381, #163, #395, #563
|
||
**Mechanics:** docs/testing.md → Golden-file nets
|
||
|
||
**Sequential (YAML) is golden-able and TZ-independent.** `SequentialPlayoutBuilder` reads a YAML schedule
|
||
file (`Playout.ScheduleFile`) rather than a `ProgramSchedule`/Block calendar, but it is still a pure
|
||
in-process build: content resolves from an in-memory-SQLite `Collection` by name, and the `count`/`all`/
|
||
`duration` handlers do UTC-only arithmetic off the caller-supplied `start`. So the `Sequential_yaml` case
|
||
uses the exact same harness as Classic/Block — a committed input fixture
|
||
(`Goldens/Fixtures/sequential-schedule.yml`) with two `count: 2` instructions over one `chronological`
|
||
collection — and needs **no** `Assume`/TZ guard (verified: it passes, not skips, under a non-UTC `TZ`,
|
||
unlike Block). The fixture deliberately avoids the local-time-of-day handlers (`wait_until`, `pad_to_next`,
|
||
`pad_until`) and shuffle order, which would reintroduce TZ- or seed-dependence. The builder checks the
|
||
file via the injected `IFileSystem` but reads bytes with the static `System.IO.File`, so the test commits a
|
||
real fixture on disk and stubs only `IFileSystem.File.Exists`; the JSON-schema validator is stubbed (it
|
||
loads its schema from a runtime cache folder, irrelevant to characterizing builder output).
|
||
|
||
**Scripted: transport is integration-only, but the engine is in-process testable.** `ScriptedPlayoutBuilder`
|
||
builds nothing itself — it `Cli.Wrap`-executes a user-authored external program, hands it
|
||
`http://localhost:{Settings.UiPort}` + a build id, and after the process exits reads the result straight off
|
||
the in-process engine (`schedulingEngine.GetState()`/`GetAnchor()`). The program drives the build by calling
|
||
back over HTTP, but `ScriptedScheduleController` is a **1:1 pass-through**: every action resolves the engine by
|
||
build id and forwards to a single `ISchedulingEngine` method (`AddCollection`, `AddCount`, `PadUntil`, …) with
|
||
no scheduling logic of its own. So "Scripted is un-golden-able" conflates two different things:
|
||
|
||
- The **end-to-end pipeline** (real external process + Kestrel on `UiPort` + HTTP loopback) genuinely is
|
||
integration-test territory — the in-memory golden harness (shared SQLite, no I/O, deterministic clock) can't
|
||
pin it, and that harness is deferred to **#563**. This part of the original decision stands.
|
||
- The **scheduling behavior** the scripts drive is 100% in `SchedulingEngine`, a plain DI-substitutable object
|
||
already unit-tested (`SchedulingEngineTests`, `new SchedulingEngine(…4 substitutes…)`). It is directly
|
||
drivable and snapshot-testable with no process and no HTTP — the same way the YAML golden drives its builder,
|
||
just skipping the script/HTTP front-end. #395 extracts the enumerator-construction switch that the Scripted
|
||
and Sequential/YAML engines share to `ContentEnumeratorBuilder` and adds a direct regression net
|
||
(`ContentEnumeratorBuilderTests`) over it — the real safety net its dedup needs (not #563).
|
||
|
||
The #381 "documented decision" arm correctly deferred the *pipeline* golden; it overstated the case by writing
|
||
off engine-level coverage too. Scripted scheduling *behavior* is now covered in-process; only the
|
||
external-process pipeline remains #563's.
|
||
|
||
## 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392)
|
||
|
||
`key: sched.clock-padding-schedule-toggle` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388).
|
||
**Signals:** per-schedule clock padding, PadToNearestMinute on ProgramSchedule, offline gap on pad, ClockPadOfflineTarget, synthetic Pad without FillerPreset · paths: `PlayoutModeSchedulerBase.AddFiller`, `PlayoutSchedulerResult.ClockPadOfflineTarget`, `FallbackFillerForPad`, `/app/schedules` · issues: #392, #77, #388
|
||
**Mechanics:** `PlayoutBuildGoldenTests` (One/Flood/Duration/Multiple clock-pad cases), midnight-crossing invariant tests
|
||
|
||
A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content
|
||
item up to the next N-minute clock boundary WITHOUT a hand-wired Pad `FillerPreset`. It reuses the existing
|
||
per-content-item Pad path (`PlayoutModeSchedulerBase.AddFiller`, already called once per emitted item): a
|
||
self-contained synthetic branch engages only when the item has no own `FillerMode.Pad` filler (the item's Pad
|
||
wins — no double-pad) and the parent schedule declares a positive divisor. The gap fills with the schedule
|
||
item's `FallbackFiller` via the existing `FallbackFillerForPad`; when no fallback content exists, the branch
|
||
records an offline target and the four schedulers advance `PlayoutBuilderState.CurrentTime` to the boundary,
|
||
leaving an implicit offline gap (the same representation fixed-start items use — absence of a `PlayoutItem`,
|
||
rendered as "Channel is Offline" at stream time). That advance is carried by a transient per-build
|
||
`PlayoutSchedulerResult.ClockPadOfflineTarget` (never serialized — no anchor schema, no migration); the
|
||
day-seam anchor clamp is exempted by exact equality with that target.
|
||
|
||
This is the per-schedule convenience layer deferred behind #388 in `sched.clock-padding-existing`; that
|
||
record's per-item Pad-preset behavior is unchanged. Determinism needs no new anchor/seed state (the pad math
|
||
is a pure function of offsets). Coverage is per-scheduler-mode (One/Flood/Duration/Multiple) via golden and
|
||
invariant tests across midnight crossings. The SPA schedule editor exposes it as a 5/10/15/30/60 minute
|
||
picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
|
||
|
||
## 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414)
|
||
|
||
`key: channel.origin-marker` · `status: active` · `since: 2026-07-23` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** A new `Channel.Origin` (`ChannelOrigin` enum — `Unknown`/`UserCreated`/`AutoTuned`) records how a channel row was created and is stamped exactly once at insert (`AutoTuned` in `CreateChannelFromLineupHandler`, `UserCreated` in `CreateChannelHandler`), and is never mutated on a later edit. It is surfaced as a raw `origin` field on `ChannelResponseModel`; the SPA badges only `AutoTuned`. Rows predating the column read `Unknown` — provenance is **not** back-filled.
|
||
**Signals:** channel origin, auto-tuned vs user-created channel, ChannelOrigin enum, immutable creation provenance, Origin column stamp at insert, do not back-fill origin, Channel Lineups playlist-group heuristic rejected, auto-generated then user-edited stays AutoTuned · paths: `ErsatzTV.Core/Domain/ChannelOrigin.cs`, `Channel.Origin`, `CreateChannelFromLineupHandler.BuildChannel`, `CreateChannelHandler`, `ChannelResponseModel`, `web/src/screens/ChannelsScreen.tsx`, `/app/channels` · issues: #414, #415, #72
|
||
**Mechanics:** `CreateChannelHandlerTests` (UserCreated stamp), `CreateChannelFromLineupHandlerTests` (AutoTuned stamp), `ChannelsScreen.test.tsx` (badge only on AutoTuned); dual-provider migration `Add_Channel_Origin`
|
||
|
||
This is #72 scope item (a), deferred in `api.channel-health-signal` because "no honest signal exists": `ChannelPlayoutSource.Generated` is a *playout-strategy* value that SPA-created blank channels also carry, so it would mislabel them, and a join through the `"Channel Lineups"` system playlist group was rejected as a heuristic that breaks the moment a user edits the channel. The fix is a dedicated `Origin` column — a fact, not a derivation.
|
||
|
||
**Immutable provenance, not a mutable "still managed" flag.** `Origin` records how the row was *born* and a later user edit never changes it, so "auto-generated then user-edited" stays `AutoTuned`. This deliberately avoids reviving the fragile "detect when it's been edited away" heuristic the issue rejected. A future "has diverged from its auto-tune template" signal, if wanted, is a *separate* concern owned by the #383/#384 auto-tune arc (which knows the template), not this column — mirroring the `api.channel-health-signal` reasoning that kept health a raw fact rather than freezing a policy enum.
|
||
|
||
**`Unknown = 0` is the honest legacy default.** A new non-null int column defaults existing rows to `0`; making that `Unknown` (rather than `UserCreated`) means pre-migration rows say "we never recorded this" instead of asserting a provenance we cannot know. The SPA badges only `AutoTuned`, so `Unknown` and `UserCreated` both render unbadged. Enum (not `bool IsAutoTuned`) so a future origin (e.g. `Imported`) is additive without a wire-contract break. Stamped in `CreateChannelFromLineupHandler.BuildChannel`, which is the single channel-construction primitive `CreateAutoTunedChannelsHandler` delegates to, so both the lineup endpoint and bulk auto-tune are covered by one stamp site. Empty-schedule and broken-source fault detection remain deferred to #415.
|
||
## 2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434)
|
||
|
||
`key: api.search-field-values` · `status: active` · `since: 2026-07-23` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** `GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50).
|
||
**Signals:** facet-value typeahead, rule builder value combobox, distinct field values, GetSearchFieldValues, text field allow-list, DB-sourced distinct values, content_rating split · paths: `ErsatzTV/Controllers/Api/SearchController.cs`, `ErsatzTV.Application/Search/Queries/GetSearchFieldValues.cs`, `ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs`, `web/src/api/search.ts` · issues: #434, #176
|
||
**Mechanics:** `SearchController.GetSearchFieldValues`; `GetSearchFieldValuesHandler`; api-conventions.md; spa-conventions.md §12
|
||
|
||
Enum fields (e.g. `type`, `content_rating` group) already ship their allowed values inline on
|
||
`SearchFieldResponseModel` from the existing `GET /api/v1/search/fields` catalog (`spa.smartcollection-rule-builder`,
|
||
#176), so they need no endpoint — a client already has the full value set. **Text** fields (title, studio,
|
||
genre-as-free-text, etc.) don't: their values are whatever strings the library actually contains, so the
|
||
rule builder's value input for a text field needs a live lookup rather than a fixed list.
|
||
The handler allow-lists on `field.Type != "text"` (matching the same `SearchFieldCatalog.Fields` the
|
||
`/fields` endpoint serves) and returns `Option.None` → 404 for anything else, rather than silently returning
|
||
an empty list for a field that will never have values — a 404 tells a caller "wrong field kind," an empty
|
||
200 would look like "no matches yet."
|
||
|
||
**DB-sourced, not the search index.** The handler injects `IDbContextFactory<TvContext>` and resolves an
|
||
explicit per-field-name `IQueryable<string>` (or, for a few special cases, an in-memory list) rather than
|
||
querying `ISearchIndex`: `genre`/`show_genre` → `Set<Genre>()`, `studio` → `Set<Studio>()`, `director` →
|
||
`Set<Director>()`, `writer` → `Set<Writer>()`, `actor` → `Actors`, `artist` → `ArtistMetadata.Title` (entity
|
||
artists only — free-text music-video/song artist credits are a known, intentionally-uncovered gap), `tag` →
|
||
`Set<Tag>()` excluding `Tag.NfoCountryTypeId`/`Tag.PlexNetworkTypeId` (reapplying the indexer's own
|
||
exclusions so country/network strings don't leak in as tags), `network` → `Set<Tag>()` filtered to
|
||
`Tag.PlexNetworkTypeId`, `collection` → `Collections`, `video_codec` → `MediaStreams` filtered to
|
||
`MediaStreamKind.Video`, `album` → `MusicVideoMetadata.Album` concatenated with `SongMetadata.Album`. Every
|
||
DB-sourced field runs the same pipeline: `.Where(v => v.ToLower().StartsWith(qLower)).Distinct().OrderBy(v =>
|
||
v).Take(limit)`, translated to SQL by EF for both SQLite and MySQL. Two fields are computed in memory instead
|
||
of queried: `state` (the fixed 4-value `MediaItemState` enum) and `video_dynamic_range` (the literal
|
||
`["hdr", "sdr"]`). `content_rating` is special-cased: the DB stores an unsplit `"PG-13/TV-14"` string across
|
||
`MovieMetadata`/`ShowMetadata`/`OtherVideoMetadata`/`RemoteStreamMetadata`, so the handler pulls the distinct
|
||
raw strings then `Split('/')`s, trims, and dedupes in memory before the same prefix-filter/sort/take — this
|
||
matches what search actually matches on, rather than surfacing the compound string as one facet value.
|
||
**`title`, `show_title`, `album_artist` are explicitly NOT supported** (404, free-text fallback): `title`/
|
||
`show_title` are near-unique free-text fields spanning ~9 metadata tables where a distinct list of every
|
||
title isn't a useful facet; `album_artist` backs onto `SongMetadata.AlbumArtists`, a value-converted
|
||
`IList<string>` column EF can't translate into a server-side distinct query.
|
||
|
||
**Why a thin query, not a cache.** No result cache, no debounce on the server side (the SPA combobox
|
||
debounces the keystroke) — each per-field query is a bounded, indexed `Distinct`/`Take`; adding a cache
|
||
before there's a measured cost would be premature.
|
||
|
||
## 2026-07-23 — Relative-date rule builder operators are a frontend-only mapping onto existing Lucene macros (#435)
|
||
|
||
`key: rulebuilder.relative-date-macros` · `status: active` · `since: 2026-07-23` · `supersedes: none` · `superseded-by: none`
|
||
**Rule:** The visual rule builder's `inLast`/`notInLast` date operators compile to/parse from the pre-existing `CustomMultiFieldQueryParser` macros `released_inthelast`/`released_notinthelast` and `added_inthelast`/`added_notinthelast`, value form `"<n> day|week|month|year"`; there is no backend change.
|
||
**Signals:** relative date operator, inLast, notInLast, inthelast macro, released_inthelast, added_inthelast, date unit picker, rule builder relative dates · paths: `web/src/builder/rules/dateMacro.ts`, `web/src/builder/rules/compile.ts`, `web/src/builder/rules/parse.ts`, `web/src/builder/rules/types.ts`, `web/src/builder/rules/validation.ts` · issues: #435, #176, #438
|
||
**Mechanics:** `dateMacro.ts` (`compileRelative`/`parseRelative`, `OP_TO_SUFFIX`/`SUFFIX_TO_OP`, `RELATIVE_SYNTHETIC`); spa-conventions.md §12
|
||
|
||
`released_inthelast`/`added_inthelast` (+ their `notinthelast` negations) already existed in
|
||
`CustomMultiFieldQueryParser` as free-text macro fields before the rule builder could reach them — they
|
||
were only usable by typing raw Lucene. #435 exposes them as first-class builder operators without touching
|
||
the parser: `release_date`/`added_date` gain `inLast`/`notInLast` alongside the existing `before`/`after`/
|
||
`between`, backed by a numeric-value input plus a `day|week|month|year` unit picker (`types.ts`'s `unit?:
|
||
DateUnit`). `dateMacro.ts` is the single seam — a small `field↔macro-prefix` table (`release_date↔released`,
|
||
`added_date↔added`) plus the `inLast/notInLast ↔ inthelast/notinthelast` suffix maps — that `compile.ts`
|
||
delegates to for these two fields and `parse.ts` recognizes via `RELATIVE_SYNTHETIC` before falling into the
|
||
generic field:value grammar. Validation (`ruleError` in `validation.ts`) requires the value to parse as a
|
||
positive integer; a non-numeric or non-positive value is a builder-side error, never sent to the server.
|
||
|
||
**Frontend-only because the macros are the query-string wire format, not a new query kind.** The compiled
|
||
query for `release_date inLast "7 day"` is literally `released_inthelast:"7 day"` — the same string a user
|
||
could type by hand — so nothing downstream (search index, SmartCollection storage, Auto-Tune) needs to know
|
||
the rule builder exists. This keeps `rulebuilder.relative-date-macros` symmetric with
|
||
`spa.smartcollection-rule-builder`'s "compile-only, no new stored AST" stance (#176): a relative-date rule is
|
||
just another point in the same closed grammar subset, proven by the same compile→parse round-trip discipline
|
||
(`dateMacro.test.ts`, and the property test in `roundtrip.test.ts`, #438) rather than a special case.
|
||
|
||
## 2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415)
|
||
`key: api.channel-health-object` · `status: active` · `since: 2026-07-23` · `supersedes: api.channel-health-signal@2026-07-17` · `superseded-by: none`
|
||
**Rule:** `ChannelResponseModel`/`ChannelDetailResponseModel` carry a server-derived `health` object (`ChannelHealthResponseModel { Status, Faults[], PlayoutCount, BrokenSourceItemCount }`) computed **read-time** from the built timeline (`Playout.BuildStatus` + upcoming `PlayoutItem → MediaItem.State`, `Finish >= now`), kind-agnostic across all 5 `PlayoutScheduleKind` values; `Status`/`Faults` are const-string classes (`ChannelHealthStatus`, `ChannelFault`), not C# enums, so the SPA hand-maintains the union (mirrors `ChannelPreviewAvailability`). This supersedes #72's "raw fact only, no derived enum, empty-schedule/broken-source deliberately not computed" stance now that the auto-tune taxonomy churn (#383/#384) it was waiting on has landed (see `channel.origin-marker` sibling record, #414).
|
||
**Signals:** channel health, ChannelHealthResponseModel, ChannelHealthStatus, ChannelFault, Healthy/Problems/Unknown, NoPlayout/NeverBuilt/BuildFailed/EmptyUpcoming/BrokenSource, built-timeline detection, BuildStatus, PlayoutItem MediaItem.State FileNotFound Unavailable, assessable gate, on-demand suppresses absence signals, Problems rollup filter, willNeverPlay hasProblems · paths: `ErsatzTV.Core/Api/Channels/ChannelHealthResponseModel.cs`, `ChannelRepository.GetAll`, `Mapper.GetHealth`, `GetAllChannelsForApiHandler`, `web/src/screens/ChannelsScreen.tsx`, `api-conventions.md`, `domain-model.md`, `spa-conventions.md` · issues: #415, #72, #71, #383, #384, #414
|
||
**Mechanics:** `docs/superpowers/specs/2026-07-23-channel-fault-detection-design.md` (full design); `api-conventions.md` (health object shape); `domain-model.md` (channel-health row); `spa-conventions.md` (Problems filter + badge convention)
|
||
|
||
#415 was deferred from #72 scope item (b): "empty schedule" and "broken/missing source" faults were real but uncomputed, each explicitly ruled out in the superseded record for a stated reason. This record reverses both rulings now that the blocking condition — the #383/#384 auto-tune status taxonomy churning the DTO shape — has resolved (#414 landed the origin column as a sibling, non-health field).
|
||
|
||
**Built-timeline (kind-agnostic) detection, not per-kind config introspection.** Every fault falls out of what the scheduler has already materialized — `Playout.BuildStatus` (`{LastBuild, Success, Message}`) for never-built/build-failed, and `Playout.Items` (the built `PlayoutItem` timeline, each carrying `MediaItemId`/`MediaItem`) for empty-upcoming and broken-source. Because the timeline is the same shape for all five `PlayoutScheduleKind` values (Classic, Block, Sequential, Scripted, ExternalJson), coverage is *by construction* — the #71 "verify a shared primitive covers ALL variants" trap, which the superseded record's own `EmptyScheduleHealthCheck` (Classic-only) fell into, cannot bite here. Scripted, which has no schedule entity to introspect at all, needs no special case. `MediaItem.State` flips on scan (not build), which rules out a build-time snapshot — detection is necessarily read-time, costed as one bounded `GROUP BY PlayoutId` aggregate query (not an N+1) over upcoming `PlayoutItem`s.
|
||
|
||
**Five-fault taxonomy, rolled up to one `status`.** `NoPlayout` (0 playouts, the absorbed #72 fact), `NeverBuilt` (assessable playout never built), `BuildFailed` (last build `Success == false`), `EmptyUpcoming` (built OK, 0 upcoming items), `BrokenSource` (≥1 upcoming item pointing at a `FileNotFound`/`Unavailable` `MediaItem`). Rollup: `Problems` if any contributing playout has a fault, `Healthy` if any is assessable-and-clean with none, `Unknown` if nothing is assessable — never a false `Healthy` and never a false `Problems`.
|
||
|
||
**The assessable gate distinguishes absence signals from presence signals.** `NeverBuilt`/`EmptyUpcoming` are inferred from *missing* content and are suppressed for `PlayoutMode == OnDemand` (an idle on-demand playout legitimately has no fresh build and drains its timeline between tune-ins — without suppression this is a false-positive storm across every on-demand channel; a suppressed absence signal contributes `Unknown`, not a false `Problems`). `BuildFailed`/`BrokenSource` are proven by content that *is* there and is bad, so they stay live in every `PlayoutMode` — they only fire when the bad thing actually exists and so cannot false-positive on legitimate idleness.
|
||
|
||
**Server owns the rollup so SPA and MCP read one verdict.** `health` rides the same `list channels`/`get channel` response both clients already fetch — no second endpoint to correlate by id, and no client re-deriving policy from raw facts (the thing the superseded record explicitly avoided freezing before the taxonomy existed). `PlayoutCount` is retained unchanged on the DTO for backward compatibility (additive-only `/api/v1` freeze); the SPA's "Problems" filter (`web/src/screens/ChannelsScreen.tsx`, `hasProblems`, replacing the old single-fault `willNeverPlay`/"No playout" filter) and per-row badges read `health.status`/`health.faults` instead.
|
||
|
||
## 2026-07-25 — Rule-builder group nesting is bounded-arbitrary depth (`MAX_GROUP_DEPTH`), not one level (#436)
|
||
`key: spa.rulebuilder-nesting` · `status: active` · `since: 2026-07-25` · `supersedes: spa.smartcollection-rule-builder@2026-07-18` · `superseded-by: none`
|
||
**Rule:** The visual rule builder's `Group` nests recursively to a single shared cap, `MAX_GROUP_DEPTH` (`types.ts`, currently 5, root group = depth 0) — read by the UI's "Add group" gate, `parse.ts` and the round-trip property-test generator alike; everything else about the builder is unchanged from #176 (compile-only closed Lucene subset over the stored query string, no stored rule AST, field vocabulary from `GET /api/v1/search/fields`).
|
||
**Signals:** rule builder nesting depth, MAX_GROUP_DEPTH, nested groups, Kodi one-level model reversed, recursive Group, compile parenthesization, parse recursion, closed subset, SmartCollection query, ChannelBuilder smart query · paths: `web/src/builder/rules/types.ts`, `parse.ts`, `compile.ts`, `RuleBuilder.tsx`, `roundtrip.test.ts` · issues: #436, #176, #437, #438
|
||
**Mechanics:** `spa-conventions.md` §12; superseded predecessor in `docs/decisions/archive/spa.md`
|
||
|
||
#176 shipped the builder with a deliberate one-level "Kodi" nesting cap (`type:movie AND (genre:Horror
|
||
OR genre:Thriller)`) and scoped recursive nesting out as YAGNI, "revisit only if a real query needs
|
||
it". #436 is that revisit. **What is reversed is only the cap** — the compile-only closed-subset
|
||
stance, the no-stored-AST stance and the catalog-driven field vocabulary all carry forward verbatim
|
||
from the archived record.
|
||
|
||
**Bounded, not unbounded.** The `Group` type is structurally recursive (it always was — `children:
|
||
Array<Rule | Group>`), and `compile.ts` already parenthesized recursively, so the honest change is a
|
||
*policy* one: how deep may a tree be? Truly unbounded nesting buys nothing a 5-deep tree can't express
|
||
while making the UI illegible (each level insets 12px inside a fixed-width dialog) and admitting
|
||
pathological hand-written input into the compiler. `MAX_GROUP_DEPTH = 5` is a judgment call sized to
|
||
"deeper than any smart-playlist query anyone has actually asked for", not a technical limit.
|
||
|
||
**One constant, three consumers — never re-hardcode a depth.** `MAX_GROUP_DEPTH` lives in `types.ts`
|
||
and is read by (a) `RuleBuilder.tsx`'s "Add group" gate (`depth < MAX_GROUP_DEPTH`, replacing `depth
|
||
=== 0`), (b) `parse.ts`, which returns `null` for input nested deeper than the cap, and (c)
|
||
`roundtrip.test.ts`'s tree generator. A second hardcoded depth anywhere would silently desynchronize
|
||
the UI from the parser and break the round-trip invariant the whole module rests on.
|
||
|
||
**`parse` stays the exact inverse of `compile`, including at the cap.** Anything deeper than
|
||
`MAX_GROUP_DEPTH` is *out of subset* and yields `null` — the existing, well-defined degradation to
|
||
raw-text editing — rather than a truncated or best-effort tree, which is the same rule that already
|
||
covers fuzzy queries, boosts and mixed AND/OR at one level. Generalizing the recursion also tightened
|
||
the sub-group detection: a part is a sub-group only when its leading `(` is the one closed by its
|
||
trailing `)` (quote- and escape-aware), so adjacent groups like `(a)x(b)` can no longer be mistaken
|
||
for one wrapped group.
|
||
|
||
**The property test asserts depth coverage, not just the invariant.** `roundtrip.test.ts` generates
|
||
500 trees to the full cap and additionally asserts the generated corpus actually *reached*
|
||
`MAX_GROUP_DEPTH` — a generator that silently stopped nesting would otherwise keep passing every
|
||
round-trip assertion while testing nothing new (the "fan-out needs count-guards" lesson applied to a
|
||
generator). Explicit depth-3 cases live in `compile.test.ts` / `parse.test.ts` / `validation.test.ts`
|
||
so a regression is diagnosable without decoding a random seed.
|
||
|
||
**No backend change.** Nesting depth is entirely a client-side model concern; the stored query string
|
||
is still plain Lucene, so nothing about `/api/v1`, the search index or the MCP surface moves.
|