Review Low x2, both correct and both the stale-comment class: The claim that prepush-rebase-check.sh forbids merging main into a PR branch is false. That hook refuses a branch that is BEHIND origin/main; a merge makes origin/main an ancestor, so the push is allowed. Merging main in is discouraged by convention only. So --no-merges does cost a real false negative: an author who marks ONLY a conflict-resolving merge commit gets a legitimate rewrite rejected. Keeping --no-merges and stating the trade explicitly -- that failure is loud and costs one extra commit, whereas honoring forge-composed merge bodies disables the guard silently, which is #609 itself. The module comment also still claimed a quoted example cannot arm the guard, which contradicts the residual the decision record now states accurately. Aligned both, and narrowed the record's Rule line from "some commit" to "some NON-MERGE commit" so the stated contract matches the implementation. No logic change -- comments, docstring, record prose and regenerated catalog only. fixes #609 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
387 KiB
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 itskey. Exactly one active record per key.superseded— reversed by a newer record; relocated todocs/decisions/archive/withsuperseded-by: <new key>pointing forward, and the successor'ssupersedespointing 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.)
Two optional fields (ersatztv#603). `stale-after: YYYY-MM-DD` on the metadata line marks a
record that asserts a fact about the outside world as due for re-confirmation once that date
passes — an absolute date, no TTL. A Sources: line in the metadata block cites the evidence the
record rests on (a CI run, a measurement, a log line), as distinct from Signals:, which is recall
keywords. Both are optional and never required; a malformed stale-after is a blocking error (it
would silently never fire), but a past-due record is only a non-blocking notice — going stale is
the passage of time, not a defect in whatever commit happens to be under test. Dated records are
listed in the catalog's Review due section, which renders the date only and never a
clock-derived verdict.
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: yes (a git trailer — the message's final paragraph, alongside
Co-Authored-By:; the pre-#609 [decisions-edit] substring is retired and no longer exempts
anything) is 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— ETag / If-Match /Versionoptimistic concurrency (#253, #259, #265, #269). Mechanics:api-conventions.md§7a–§7c.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— release / CI / merge governance (#303 H3/H4/H5/H6/H9/H10, #311, #314, #315, #335).decisions/spa-modularization.md— App.tsx screen/shell extraction epic #243 (#244, #245, #247).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 — 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 — PUT-replace list endpoints derive
Indexfrom 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-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 — Season/episode/music-video drill-in via
parentId, not new child-listing endpoints - 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 — 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 — 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 — 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-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 column sorting: allow-listed
sortField/sortDirectiononGET /api/logs - 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-10 — Shared "Add to…" layer lives in
web/src/media/addTo/; select-mode is an explicit toggle - 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-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved
- 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 — 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 — 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 — Post-commit side effects run on
CancellationToken.None(generalized from #251 to #254) - 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-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-13 — API versioning: the whole
/apisurface is mounted at/api/v1, additive-only after freeze (#286) - 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 — Optional advertised IPTV base URL (
iptv.base_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 — 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 — 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 — 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 — Weighted / fair-share distribution is a new
WeightedShuffleorder;ShuffleInOrderis anti-clumping, not fair-share (#70) - 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never
if:-skips them (#416) - 2026-07-17 — Docs-only detect must be shallow-checkout safe: FETCH_HEAD + two-dot, not origin/main + three-dot (#416 follow-up)
- 2026-07-17 — Pre-push guard: don't push a file whose working-tree copy is uncommitted (H13, #416 session)
- 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-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 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)
- 2026-07-18 — Collapsible sidebar + nav-group accordions: two
ctv-sidebar-*localStorage keys, labeled groups default-collapsed (#396) - 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 — Never-scanned
LastScansurfaces 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 — Health-check results are TTL-cached;
?refresh=trueforces a fresh run (#431) - 2026-07-19 — The
formatgate runsdotnet format whitespace . --folder, not the full solution format (#469) - 2026-07-19 — CI
testjob reports a sampled true peak-anon, not cache-inflatedmemory.peak(#412) - 2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473)
- 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)
- 2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480)
- 2026-07-20 —
ILibraryRepository.GetOrAddFolderresolves the folder from the DB, not the caller'sLibraryPath.LibraryFoldersnavigation (#488) - 2026-07-20 —
JellyfinMusicVideoLibraryScannerreconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494) - 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds
- 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)
- 2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74)
- 2026-07-20 (#498) — QSV decode is split from QSV encode via a single
QsvPreferNativeDecoderbool - 2026-07-20 —
runs-on: smallmeans git-only; the twodocker buildjobs move toubuntu-latest(server-management#639) - 2026-07-20 — External-URL channel logos pass through to the graphics engine; never
File.Exists-gated, never ffmpeg-native (#502) - 2026-07-20 — HLS cold start is fixed with
-readrate_initial_burst, not by raising the work-ahead limit (#350) - 2026-07-20 — MCP server (
ErsatzTV.Mcp) built fresh over frozen/api/v1: read + cautious writes (#58) - 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 — 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 — QSV hardware-frame headroom is a floor, not an operator preference (#529)
- 2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's 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 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68)
- 2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)
- 2026-07-21 —
WorkAheadSlots.Release()clamps before decrementing and reports unbalance in-band (#539) - 2026-07-21 —
from-lineupadvanced overrides express "clear to none" via a typedclearenum list (#135) - 2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)
- 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381)
- 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392)
- 2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414)
- 2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434)
- 2026-07-23 — Relative-date rule builder operators are a frontend-only mapping onto existing Lucene macros (#435)
- 2026-07-23 — Channel health = a server-derived
healthobject on the channel DTOs, built-timeline detection (#415) - 2026-07-25 — Rule-builder group nesting is bounded-arbitrary depth (
MAX_GROUP_DEPTH), not one level (#436) - 2026-07-25 — UI-E2E: headless Playwright flows in the existing
functional-e2ejob, browser baked into the CI image (#445)
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
BuildPlayoutalready 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:LockPlayoutpublishesPlayoutUpdatedNotification(UI churn per mutation) and would make mutations block builds, a semantics change out of scope for restoring Blazor parity. -
reset-allis deliberately NOT gated — it stays 202.ResetAllPlayoutsHandleralready 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 —
PlayoutListItemResponseModelgains anIsLockedbool (set fromIsPlayoutLockedin 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 callsquery.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):
- Bare-channel create lives on the channels list, not a form-first route. Blazor's
/channels(noId) is a full add form; the SPA instead adds a "New blank channel" action next to "Add Channel" onChannelsScreen(web/src/App.tsx) that POSTsCreateChannelRequestwith Blazor's computed add-mode defaults ((max existing int-parsed channel number) + 1,name: "New Channel",group: "ErsatzTV",ffmpegProfileId=GET /api/settings/ffmpeg'sdefaultFFmpegProfileId,streamingMode: "TransportStreamHybrid",isEnabled/showInEpg: true, every other field at its C#default(T)— seeChannelEditor.razor'selsebranch 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-lineupChannelBuilderflow) is unrelated and untouched. - External logo URL wins over an uploaded logo, mirroring
ChannelEditViewModel.ToUpdate/ToCreate. The channel's logo isArtworkContentTypeModel { path, contentType, isExternalUrl }; on hydration,isExternalUrl: truepopulates a separate "External logo URL" field and the uploaded-logo draft state is treated as empty (EMPTY_LOGO = { path: '', contentType: '' }inChannelEditScreen.tsx) so the two never disagree. On submit, a non-blank URL always wins:logo: { path: url, contentType: '' }, exactly matchingExternalLogoUrl's precedence in the C# view model. Uploading a file clears the URL field (the last-set field wins, Blazor parity viaUploadLogo'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 Inputs to Selects 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 ints 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 noId=0adds either). The controller validates the submitted id set againstGet{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}LibrarySyncremoves and re-adds the row with a fresh id, so the SPA keys its draft to(name, mediaKind), never toId, and refetches after every save (the PUT returns the reloaded list). - Path replacements (
PUT .../{id}/path-replacements) — id-based (existingId=update,Id<1=add, absent=delete) as documented, but the repo UPDATE SQL had no source-id predicate (WHERE Id = @id, noAND {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), notId;Idin the request is advisory. Renaming a path is delete-old+add-new under the hood (itsLibraryPath.Idchanges). 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):
- On the
maincommit immediately preceding the removal merge (the last commit that still containsErsatzTV/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)"thengit push origin blazor-final. The tag name isblazor-final(notv*) so it does not trigger thev*prod-release build in.gitea/workflows/docker-build.yml. - 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 onmain. Alternativelygit revertthe single deletion merge commit (keep the deletion as one squash/merge commit specifically to make this a one-liner). - 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 newQueueShowScanResultenum (6 outcomes incl. an honestScanFailed→422, distinct fromUnsupported).ChannelController.ResetPlayout— 200 → 202 (queue-triggering; 404/409 unchanged).PlayoutController.ResetAll— 202 (no body) → 202 +ResetAllPlayoutsResponseModelreportingqueuedPlayoutIds/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-lessNotFound()→ 404/422 ProblemDetails with distinguishing detail. Status codes the SPA HLS player depends on were preserved — verifiedHlsPlayer.tsxnever branches on this endpoint's status (playback state comes from the separate/api/troubleshoot/playback/statuspoll); 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}/scangains?deep=false, threaded throughQueueLibraryScanByLibraryId(LibraryId, DeepScan=false)intoForceSynchronize{Plex,Jellyfin,Emby}LibraryById(id, deep)(was hardcodedfalse). Non-breaking: existing callers omit it. - External-collections scan: new
POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=falseon the three #202 media-source controllers, dispatchingSynchronize{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 acatchif the enqueue throws (§3b), andScannerServicereleases in itsfinally. 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 twoConfiguration/settings handlers — they commit via several sequentialIConfigElementRepository.Upsertcalls with an interleaved enqueue; "when is it committed" is a partial-commit-under-cancellation question broader than the clean single-SaveChangesF4 pattern. Left for a separate follow-up.- Response-projection reloads (
ReplaceProgramScheduleItemsHandler/AddProgramScheduleItemHandlerpost-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-Nonethere 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 aSunsetheader whenApi:LegacyRoutesSunsetis 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), plusUpdateFFmpegProfile(genuine 500;CreateFFmpegProfilewas already guarded) andCreatePlaylist(its DTO coalescesnull→"", so an empty-name persist, not a 500) all didif (request.Name.Length > 50)on a client-nullablestring Name→ unhandledNullReferenceException. Fixed toif (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 50)— kills the NRE, and also rejects empty/whitespace names (matching the group-create handlers'NotEmptybehavior, closing a latent "block/template named ''" gap). Chose the one-line guard over refactoring each handler onto theNotEmpty/NotLongerThancombinator to keep the blast radius tiny and preserve each handler's existing error message + 422 mapping. Convention captured inapi-conventions.md§3b handler-hardening checklist. - Exact-duplicate template items bypassed overlap validation.
ReplaceTemplateItemsHandler's O(n²) overlap loop skipped onitem == otherItem, butBlockTemplateItemis arecord, 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/groupsandPOST /api/templates/groupsdeclared[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 deterministic assertions (scripts/e2e-functional.sh) as an advisory (non-blocking) job, not a build dependency or required check. Originally curl-only; since #445 the same job carries a second, headless-browser step for the contracts curl cannot express — see ci.ui-e2e-harness.
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. (All three have since landed: the two 409s in #363/#444, the Playwright flows in #445 —
ci.ui-e2e-harness — as a second step of this same job.) 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-collectionCollectionEnumeratorStatealready 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, andreset-allusesRefreshfor Classic, so it never reseeds Classic) plus visibility. POST /api/v1/playouts/{id}/reshufflefirst runsErasePlayoutHistory(reseedsPlayout.Seed+ clears anchors/rerun-history) and then enqueuesBuildPlayout(id, PlayoutBuildMode.Reset)to rebuild.Resetalone only reseeds the seed for Classic playouts (PlayoutBuilder); Block/Sequential/Scripted rebuild deterministically from the existing seed, soErasePlayoutHistoryis 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 thanreset-allfor Classic: an explicit single-channel action rolls a new order; the bulk action stays non-disruptive.Playout.Seedis 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
FillerPresetwithFillerMode.Pad+PadToNearestMinute = Nsnaps 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, theFillerMode.Padblock). Live SPA editor at/app/filler-presets(increment options 5/10/15/30). - Sequential (YAML) playouts: the
pad_to_next: Nandpad_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
Blockstarts at a fixedTemplateItem.StartTime), so it already yields clean guide times. - EPG reflects the padding with no extra code:
ChannelGuideProjector.ProjectFloodcoalesces trailing PostRoll/Tail/GuideMode/Fallback/DecoDefault filler into the programme window, so a programme'sStopis 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 → PlayoutItems 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 ShuffleSourceBuilderinErsatzTV.Core/Scheduling(sibling to the also-staticMultiCollectionGrouper/MultiPartEpisodeGrouper; dependencies passed as parameters, not a DI service —PlaylistEnumerator.Createis 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.
GetGroupedMediaItemsForShufflenow takesbool keepMultiPartEpisodesTogether, bool treatCollectionsAsShowsinstead of aProgramSchedule(verified those are the only two properties it read). This deletesPlaylistEnumerator's fakenew ProgramSchedule { KeepMultiPartEpisodesTogether = false }(its TODO becomes an honestfalse, false) and gives future callers with noProgramSchedule(#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
Marathonarm produces aPlaylistEnumerator(a factory consumed byPlaylistEnumerator.Createthat also builds one is a dependency cycle); Playlist hardcodesKeepMultiPartEpisodesTogether=false/randomStartPoint=falsewhere Classic reads schedule flags (a merge silently changes behavior); and Classic'sCustomOrder/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 differentBlockPlayoutShuffledMediaCollectionEnumeratorfor 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.
PlayoutBuildGoldenTestsonly pinnedPlaybackOrder.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 aclassic-shufflegolden (pinnedPlayout.Seed+Continuebuild, sinceResetrandomizes the seed) and aPlaylistEnumerator.Createreach-in characterization (Shuffle + ShuffleInOrder items) before the move, plus directShuffleSourceBuilderunit 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 byProgramScheduleAlternate(Classic — picks theProgramSchedulefor a date) andScheduling/PlayoutTemplate(Block — picks theTemplate+ optionalDecoTemplatefor a date). - The evaluator:
AlternateScheduleSelector.GetScheduleForDate— first match inIndexorder, broadest/unconditional row last = catch-all. Wired intoPlayoutBuilder(:572, :956),EffectiveBlock,BlockPlayoutFillerBuilder, andDecoSelector. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31 clamped) are already handled;AlternateScheduleSelectorTests+playoutTemplateCalendar.test.tspin it. - SPA:
PlayoutScheduleEditors.tsxships 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 bothStartYearandEndYear(only one → silently still yearly), and explicit years forcereverse = 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/EndTimeare time-of-day. The date dimension lives exclusively on thePlayoutTemplate/ProgramScheduleAlternatejoin 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
ProgramScheduleItemrows 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
PlaybackOrderswitch 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 · stale-after: 2027-01-15
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
Sources: the 2026-07-17 bumblebee incident — load 340, 21 GiB swapped, ~238 MiB free, prod down until reboot (server-management#604) · host sizing at the time: 25 GiB / 12 cores
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.
ShuffleInOrderdoes NOT already do fair-share, despite looking like it. Its balanced shuffle (keyj) pads every source to the longest withOption<MediaItem>.Nonespacers — but spacers emit nothing (ShuffleInOrderCollectionEnumerator.cs,result.AddRange(maybeItem)over anOption). 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
Shuffleis 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 =
WeightedShufflewith 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 everyPlaybackOrderdispatch site must also consult, multiplying the blast radius below. Retrofitting weights ontoShuffleInOrderwas rejected: it would silently change shipped users' output. - Weights live on
MultiCollectionItemANDMultiCollectionSmartItem(int Weight, DB default 1, dual-provider migrationAddMultiCollectionItemWeight). 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; itsCount/PlayAllalready mean "how much of this source", and drain-N-consecutivelyA A A Bis a different product from smooth interleaveA 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.MultiCollectionGroupercollapses sources intoGroupedMediaItem+.Distinct(), so by the time theShufflepath has its list, which source an item came from is gone.CollectionWithItemsgainsint Weight = 1;WeightedShuffleCollectionEnumeratorconsumesShuffleSourceBuilder.GetCollectionItemsForShuffleInOrderunchanged — 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 (CollectionEnumeratorStatehas 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: aResetbuild re-randomizesPlayout.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. ScheduleAsGroupis not read by this order, unlikeShuffleInOrder, which merges every non-ScheduleAsGroupcollection into ONE source. Here eachCollectionWithItemsis 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
PlaybackOrderfails silently at five of six dispatch sites: Classic substitutesRandomizedMediaCollectionEnumerator(PlayoutBuilder'sdefault:,// TODO: handle this error case differently?);PlaylistEnumeratorhas nodefault:arm, so the enumerator stays null and the item is dropped from the playlist (and null is legitimate there forSeasonEpisodewithCount == 0, so nothing flags it);BlockPlayoutBuilderfilters block items against an allow-list andcontinues past the rest; YAML/Scripted returnOption.None, which their callers read as "no content". OnlyMultiCollectionGroupthrows. A weighted order degrading to unweighted random is the worst case, because the output is supposed to look arbitrary. So the write path rejectsWeightedShuffleeverywhere 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, soEnum.Parseaccepts 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.ReplacePlaylistItemsis the expected one;CreateChannelFromLineupis the other — a lineup of 2+ entries is stored as aPlaylist, and its own guard only covered MultiCollection entries, so a multi-entry lineup of plain collections slippedWeightedShufflestraight through toPlaylistEnumerator's null-drop. Both are gated. The full writer set, since "persisting writer" alone is the wrong axis:Add*ToPlaylistandTraktCommandBasedo persist the field but hardcode it (Shuffle/Chronological), so no caller value reaches them;ReplaceBlockItemswrites the different fieldBlockItem.PlaybackOrder(also gated); andPreviewPlaylistPlayout,PreviewBlockPlayoutandEngine/PlaylistHelperbuild 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 (missingCreateChannelFromLineup); the first correction miscounted by conflatingPlaylistItemwithBlockItem; 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'sHasDefaultValue(1)substitutes 1 for a0on 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, soSum(weights)cannot overflow regardless of what is stored. Historical note: an earlier revision of this PR filteredWeight > 0instead 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.
- Exactly two writers persist a CALLER-SUPPLIED
- 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
Versiontoken (If-Match/412, §7a). - Scope: Classic engine only; no SPA (the schedule-editor weight UI is gated on #388). Plain
single/smart collections (the
GroupIntoFakeCollectionspath) 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 aPlaylistItemcannot even reference a search query. Nothing here forecloses it — the enumerator reads weights offCollectionWithItemssource-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'sAutoTunedChannelRequestgained three optional per-channel fields —templateId,advanced, and an uploadedlogoimage. 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.advancedreuses the manual Channel Builder'sCreateChannelFromLineupAdvancedOptionsRequestverbatim — the same 24-field override set, the sameToCommand(), and the sameadvanced.X ?? template.Xstamp-at-create contract thatPOST /api/v1/channels/from-lineupalready 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 (SeasonEpisodefor a single show,Shufflefor a genre) fillsPlaybackOrderonly when the caller left it null, so an explicit per-channel order wins but an unset one is never dropped.logois the uploaded channel image, forwarded toCreateChannelFromLineup.Logo(persisted as anArtworkKind.Logoartwork) and run throughArtworkContentTypeModel.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/logoare resolved per channel insideCreateAutoTunedChannelsHandler.CreateOne, so one channel's bad override still yields a per-channelFailed/Skippedoutcome 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
WeightedShufflereads weights only offMultiCollectionItem/MultiCollectionSmartItemrows, but an auto-tuned channel is backed by a single SmartCollection (→GetFakeMultiCollectionCollections, every fake group forced toWeight = 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 FFmpegWatermarkSelectorbug, with no persisted bug-initials/colour state onChannel. Making it configurable needs either newChannelcolumns (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 uploadedlogoabove 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 AppRoutes. (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 oneuseOverlayBehaviorhook withDialog(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
AdvancedPanelinternals in the 84 KBChannelBuilder.tsxwere extracted toweb/src/builder/advancedOptions.tsx(enum catalogs,ADVANCED_KEYS,effectiveValue, and the INHERIT/omituseAdvancedOverrideshook). 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 intoadvancedonly 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 viaGET /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
AutoTuneScreendraft 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 SmartCollections, 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).
SearchAllItemsgains optionalpageNum(0-based) +pageSize, clamped exactly like the §1 Logs / siblingSearchprecedent:pageSize = Math.Clamp(pageSize, 1, MaxAllItemsPageSize)withMaxAllItemsPageSize = 1000,DefaultAllItemsPageSize = 500.pageNumis clampedMath.Clamp(pageNum, 0, MaxAllItemsPageNum)withMaxAllItemsPageNum = 2_000_000— the upper bound keepspageNum * pageSize(the search skip) insideintrange so an absurd page number can't overflow to a 500 (the siblingSearchonly 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 ≤pageSizeids of each of the ten kinds), so one response is bounded to ≤ 10 ×pageSizeids.QuerySearchIndexAllItemscarriesPageNum/PageSize; the handler passesskip = PageNum × PageSize,limit = PageSizeintoISearchIndex.Search(native skip/limit) and readsSearchResult.TotalCount(the true total, free) per kind. - Response (additive, frozen-v1-safe). The ten
…Idsbuckets are unchanged; a new non-null nestedTotals(SearchResultAllItemsTotalsResponseModel, ten…Countints) 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
pageSizenow gets one page (default 500 / kind) plusTotals, 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 readTotalsand page. Recorded as intentional, not a regression. - SPA pages to completeness.
web/src/api/search.tsgetSearchAllItems(query, pageNum, pageSize)gains the params; a newgetAllSearchItemIds(query)loops pages (requestingpageSize = 1000, the server max), accumulating every bucket until each kind has collected itsTotalscount (with an empty-page safety break against total-count drift), and returns the mergedSearchAllItemIds.SearchScreen.addAllcalls 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.tsuseSidebarState()owns both pieces of state + persistence;AppShellconsumes it (nothing else needs it) and stampsctv-app-shell-collapsedon 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 quotedctv.sidebar.collapsed/ctv.sidebar.groups, but every existing client-local pref is hyphenated (ctv-theme,ctv-logs-page-size— spa-conventions §5d), so we usectv-sidebar-collapsed("1"/"0") andctv-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-groupsentry ⇒ 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
titletooltip), 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.mdchange.
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'sPlayoutBuilderdefault:arm (was// TODO, silently returned Random), thePlaylistEnumerator.Createswitch (had nodefault:arm → null enumerator → item dropped), andBlockPlayoutBuilder's allow-listcontinue(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#70already shipped for YAML/Scripted.PlaylistEnumerator.Createwasstaticwith no logger, which is why the playlist drop was unreportable. It gained an optionalOption<ILogger> logger = default(mirrors the existingGetStartTimeAfter(..., Option<ILogger>.None)pattern); the three callers that have a logger pass it, the rest default toNone.BlockPlayoutBuilder'sGetEnumeratorswitch gained an explicitPlaybackOrder.Randomarm. 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, perSchedulingEngineKind(Classic / Playlist / Block / Yaml / Scripted), BOTH aSupportedand anUnsupportedset.PlaybackOrderSupportTestsasserts the two sets partition everyPlaybackOrdervalue (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: derivingUnsupportedas "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 —BlockPlayoutBuilderconsumes 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-400ing 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.Nonearms that map an enumerator class back to aPlaybackOrderforPlayoutHistorywere 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+objartifact 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,compileserializes beforemigrations' 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@v4does not work on this Gitea instance — it throwsGHESNotSupportedError, because the@actions/artifactv2 client library rejects any non-github.comhost.@v3is 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:
- Old
Reset_*migrations wrote it via raw SQL (UPDATE Library SET LastScan = '0001-01-01 00:00:00'). - Live code wrote it on the Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows in
MediaSourceRepository. Closed by #460: those three writes now setnull, so normal use no longer produces new sentinel rows. Safe because every remainingLastScanreader 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.NormalizeLastScanmaps any< 2000-01-01value 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 (emptyUp/Downotherwise, bothTvContextModelSnapshot.csbyte-identical). The< '2000-01-01'predicate matches the0001-01-01sentinel robustly on both providers (ISO-text compare on SQLite, whatever the out-of-range zero date stored on MySQL — where noReset_*LastScanmigration ever ran, so it's a safe no-op there) without depending on the exact stored bytes; no real scan predates ErsatzTV.Downis 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. WeightedShuffleis offered ONLY forcollectionType === 'MultiCollection'(itemRules.tsMULTI_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}/itemswith 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 (notMULTI_COLLECTION_ORDERS) and already omitWeightedShuffle, so nothing extra was needed there.WeightedShufflejoinsShuffleInOrderin thefillWithGroupexclusion.fillWithGroupModeEligiblealready excludedShuffleInOrder; WeightedShuffle is excluded for the same structural reason —PlayoutBuildersplits 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
WeightedShufflewith 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
weightis read initemsFromMultiCollectionand written intoItemRequest; 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 inspa-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.
Inputgainedmin/max/inputMode/onBlurpassthroughs 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_memoryCacheentry ("healthcheck.results",TimeSpan.FromSeconds(30)) holds the lastList<HealthCheckResult>; a non-forced call returns it directly on a hit, skipping both the 14 checks and the summaryPublish. 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 byGetHealthCheckSummary) is kept as-is (un-expiring) so its fallback behavior is unchanged. PerformHealthChecksgained abool forceRefreshfirst parameter (interface signature change; one implementer, 3 live callers).forceRefresh: truebypasses 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 separatePOST …/refreshendpoint 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
RunHealthChecksServiceand 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 legacyGetAllHealthCheckResultshandler 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
SemaphoreSlimas 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.
--includenarrows 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-solutiondotnet formatlocally (matching the issue's "7+ min").--foldertreats the tree as a plain folder of files and skips MSBuild/Roslyn entirely: ~0.5s, and it needs nodotnet 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 aboutformatnot shrinking is now moot). - Coverage is unchanged, not merely "good enough". Folder mode reads
.editorconfigand 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 witherror WHITESPACEon an injected trailing-whitespace line anderror CHARSETon 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 awarning-severity naming violation (local_constantsnotALL_UPPER) passed the full solution format (exit 0): the only.editorconfigrule above:suggestion/:noneseverity is that one naming rule, and naming violations have nodotnet formatbatch code-fixer, so--verify-no-changesreports no change regardless of severity. The analyzers that must block (NU1904,S3981) are enforced at compile time viaWarningsAsErrorsinDirectory.Build.props, never by this job. - Fix command for a violation:
dotnet format whitespace . --folder --include <files>. The fulldotnet format ErsatzTV.sln --include <files>is a superset (also applies style) and still works, so existing muscle memory and the #311 lore'sdotnet format --includeguidance 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 · stale-after: 2027-03-15
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
Sources: scripts/ci-peak-anon.sh (the sampler itself) · cgroup v2 memory.peak vs memory.stat anon accounting, measured on the bumblebee runners #412
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 returnedhttp://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 inHlsSessionWorker'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 — everyPlayoutItemWithPathit 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
caselabel does and does not do (a review corrected an earlier draft of this entry): the skip-to-next-item sizing comes frommaybeDuration/finish, computed before the switch — thedefault:arm already had it. Addingcase PlayoutItemNotAvailableFromMediaServer:alongsidePlayoutItemDoesNotExistOnDiskchanges 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
ValidatePlayoutItemPathare covered.ExternalJsonPlayoutItemProviderbuilds its own/media/plex/...URL and its result is assigned without passing throughValidatePlayoutItemPath, 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
InternalControllerreturnsNotFoundwhen 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
ConfigElementtoggle. The behaviour strictly dominates the status quo, and the probe is bounded by a 2s linked-CTS timeout (notHttpClient.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
HlsSessionWorkerretry 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 (VAAPIhwupload -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
MediaItemStatefrom the streaming path. Only the scanner writesState, and breaking that ownership wouldn't even have fixed this: the failing item isRemoteOnly, whichPlayoutBuilder'sPlayoutSkipMissingItemsskip does not exclude. On Jellyfin (ServerSupportsRemoteStreaming)RemoteOnlyis 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
GETwithRange: bytes=0-0, which on/Videos/{id}/stream?static=trueis a real (if minimal) playback request and may register a session or touch play-state on some media-server versions.GETwas 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 whenincomingCount == 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
pagesfromTotalRecordCount, 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
JellyfinApiClientthrough 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.
MediaServerReconciliationGuardTestspins 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.
StreamRemotelynow probes the URL via the sameIRemoteStreamProberseam before returning it, mirroring the three generated-playout branches. The handler-side single-choke-point option (route everyPlayoutItemWithPaththrough one validator) was rejected: the external-JSON provider constructs a syntheticMediaItem/PlayoutItem(from the JSON + Plex API) whose path is already a/media/plex/...URL, so it does not fitValidatePlayoutItemPath'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 insideIRemoteStreamProber, 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 constructedMediaItem, so the probe runs beforeGetPlexEpisode/GetPlexMovie. A gone item therefore also skips the Plex API metadata call (which would otherwisethrow NotSupportedExceptionon aLeft). - 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 DBPlayoutItemrows (their schedule is a JSON file), so the handler'smaybeNextStartquery 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.
ExternalJsonPlayoutItemProviderTestspins both directions (unavailable →LeftPlayoutItemNotAvailableFromMediaServerand no metadata call; available →Rightwith the/media/plexURL). The fail-open contract itself stays pinned byHttpRemoteStreamProberTests. Proven non-vacuous by neutralizing the probe guard and watching the unavailable assertion flip toRight.
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.LibraryFoldersby(LibraryPathId, Path)(the method already opened adbContextfor the insert). This removes the implicit, undocumented loading contract entirely — correct for all nine callers — rather than the caller-side option (eager-loadLibraryFolderson the Jellyfin path), which would have left the contract intact for the next remote scanner to trip. The contract change is now stated onILibraryRepository.GetOrAddFolder. null≠ empty — do not coerce. Anullnavigation meant unknown, not known-absent; treating it as an empty collection would fall through toCreateNewFolderand insert a duplicateLibraryFolderfor 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 beforeGetOrAddFolder, 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-SQLiteTvContext) drivesGetOrAddFolderwithLibraryPath.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'sArgumentNullException('source'). Note:CleanEtagsForLibraryPathstill readslibraryPath.LibraryFoldersdirectly, 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 (#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
MoviesandTV Showslibraries, 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.
MediaItemis table-per-type with no discriminator andLibraryPathIdon the abstract base, so heterogeneous items under oneLibraryPathwere always legal;MediaItemRepository.GetAllTrashedItemsalreadyCOALESCEs 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. MediaKindis 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-itemLibraryBrowseMediaType. 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.UpdateProgressfrom 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 threadingprogressMin/progressMaxper path (ScanLocalLibraryHandler), but the JellyfinScanLibrarysignature 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.
ScanMixedLibraryreturnsLeftif any arm failed, soLastScanis never stamped and the whole library re-scans every interval. Single-kind libraries already behave this way;Mixedwidens the blast radius to the other two kinds. Accepted: the alternative — stampingLastScanon partial success — would silently mask a broken kind, which is worse. JellyfinMusicVideoLibraryScannerperforms no reconciliation at all — noGetExisting*, 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 (noItemId/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 Bugrow untouched, so an operator's tuned geometry is never overwritten, and aConfigElementmarker (watermark.channel_bug_seeded) makes it run once per database rather than once per name-absence —ChannelWatermarkhas noIsSystemflag andDbInitializer.Initializeruns at every startup, so a name-only guard would resurrect a deliberately deleted preset forever. Covered byDbInitializerChannelBugWatermarkTests. WatermarkResponseModelgainedimageSource(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,imageSourceidentifies the class but not the default, sofindLogoBugWatermarkprefers 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 (becausegetWatermarks()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
watermarkIdmean "give me a watermark" would surprise machine clients of the frozen API.POST /api/v1/channels/auto-tuneis therefore unchanged. - Not fixed here: external-URL logos never render a bug (
WatermarkSelectorFile.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 GraphicsElements 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.SelectGraphicsElementsappends channel-level elements at the final fall-through, alongsideplayoutItem.PlayoutItemGraphicsElements— a base layer. A deco inMergemode composes with it; a deco inOverride/Disablemode 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 (UseGraphicsElementsDuringFillerfalse) clears the result and returns forMergeandOverridealike, so the channel base layer is dropped there too.HttpLiveStreamingDirectcontinues 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.SeedOnNowNextwriteson-now-next.ymlinto the graphics-elements templates folder (only if the file is absent — operator edits are never clobbered) and ensures aGraphicsElementrow exists for it, guarded by thegraphics.on_now_next_seededConfigElementmarker 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).GraphicsElementResponseModelgained a server-derivedBuiltInbool, computed by comparing the row'sPathfilename toGraphicsElementDefaults.OnNowNextFileNamerather than trusting the element's editableName. - 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.QsvPreferNativeDecoderboolean, 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)(SQLiteINTEGER/MySQLtinyint, nullable withDEFAULT 1— the column isbool?so an explicitfalsepersists, whileNULL/absent reads as ON via!= false; a non-nullablebool+ store default would make EF silently drop a create-with-false), no opt-in required. - Rejected: a general
DecodeHardwareAccelerationenum 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 —FFmpegStatealready separatesDecoderHardwareAccelerationMode/EncoderHardwareAccelerationModeinternally (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, soQsvPipelineBuilder.SetTonemap(which only selectsTonemapQsvFilterforDecoderHardwareAccelerationMode == Qsv) falls to the softwarezscale/tonemapchain 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 totonemap_qsvis tracked in #505. - Native decode is Linux-only. Guarded with
!OperatingSystem.IsWindows()in the QSV builder — FFmpeg has novaapihwaccel 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
smalllane 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'sbuildis a 1-second skip on PR runs (but a real image build on main/tags), andci-image.yml'sbuildwas 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,smallis a checkout plus agit 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.mdis 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
buildback toubuntu-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 hardcodeslocalhost, issue #1) is also killed by the sameFile.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 privateGetWatermarkOptions) has always returned its resolved path unchecked, so #502'sFile.Existsdefect 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.
-readratewas also incidentally bounding how fast decoded frames enter the filter graph, and removing that on a QSV pipeline whose profile storesqsvExtraHardwareFrames: 0exhausts the upload pool: the graph fails with-12 (Cannot allocate memory),h264_qsvnever opens, and zero segments are written. The burst is not the root cause (a work-ahead start takes no-readrateat 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. Seeffmpeg.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 read0 < limitand 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 untilffmpeg.work-ahead-slot-atomic.
-readratethrottles 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. SinceOutputFormatHls.SegmentSecondsis 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.
HlsSessionWorkergrants an unthrottledSeekAndWorkAheadstart 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 reachedfirstGopin 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/-analyzedurationbuys 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_limitwould 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 defaultInitialSegmentCountof 1), after which live pacing resumes. An operator who raisesInitialSegmentCountabove 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.
SetRealtimeInputruns on every pipeline build andHlsSessionWorkerspawns 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 thatwork_ahead_limitprovides is weaker than before — weaker, not absent, becauseHlsSessionWorker'stranscodedBuffer <= 1mingate 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 throughFFmpegState; 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, sinceFFmpegPlaybackSettingsCalculatormakes 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 outerWrapSegmenter/Concatprocesses still pace atreadrate 1.0with no burst. - Gated on runtime capability, not on a parsed version.
-readrate_initial_burstneeds FFmpeg ≥ 6.1, andFFmpegKnownOption/HasOptionalready existed for exactly this (itsAllOptionslist had simply been empty). Detection parsesffmpeg -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 inNvidiaHardwareCapabilities.
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 reservedifMatchheader, or (write verbs only) the JSON body — andadditionalProperties:falserejects 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 (theResponseHeadersReadgotcha, #289), the response-size cap, and the reverse-proxy prefix preservation. - Machine-key auth, CSRF-exempt. The server sends
X-Api-Keyon every request and noX-CSRFheader (§9: key-authed requests are CSRF-exempt).ERSATZTV_API_KEYis effectively required. If-Matchis opt-in per call, and ETags are surfaced, not managed. Only the replace-all PUTs honorIf-Match(hereupdate_collection_custom_order); a responseETagheader 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 — notablyadd_collection_items, whose re-add is an idempotent no-op.- Channel create/update is exposed (28-field DTO): only
name/number/ffmpegProfileIdare required, enums take the enum name and are API-validated, andget_channelmakes 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/apiendpoint 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:
- 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.
- The second version checked the product, but sourced the frame count from
Image.IdentifyAsync. Measured on ImageSharp 3.1.12: an APNG reportsFrameMetadataCollection.Count == 0while 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 ofSKBitmapat 600 frames scaled to 1920x1080, becauseLoadImageclones 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 the rationale-edit marker (Decisions-Edit: yes, a git trailer since #609 — see
ci.decisions-edit-trailer), 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).
The rationale-edit marker 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 marker-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=0is not a valid pool size; it is a dead channel waiting for an unthrottled read.FFmpegState.QsvExtraHardwareFrameshonored a stored0literally, sohwupload=extra_hw_frames=0reached 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_qsvreports "Could not open encoder before EOF", and zero segments are written. It now clamps toMinimumQsvExtraHardwareFrames(64), which is also the valueIfNonealready 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 burst0 14 segments, exit 0 1.05+ burst 2 / 4 / 80 ENOMEM, 0 segments no readrate at all 0 ENOMEM, 0 segments 1.05+ burst 864 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 with0. 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. -
-readratewas 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) and64(works), so1..63are 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_framesallocates 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.QsvPipelineBuildertherefore 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,HardwareUploadQsvFilterandWatermarkHardwareUploadFilterread it directly;ScaleQsvFilterandDeinterlaceQsvFiltertake valuesQsvPipelineBuilderpasses down from it. One guard covers them all rather than five call sites that can drift apart. (A sixth formatter,SubtitleScaleQsvFilter, also emitsextra_hw_framesbut 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 PUTs 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 toContentEnumeratorBuilderand 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 TextFields 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 PlayoutItems.
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.
2026-07-25 — UI-E2E: headless Playwright flows in the existing functional-e2e job, browser baked into the CI image (#445)
key: ci.ui-e2e-harness · status: active · since: 2026-07-25 · supersedes: none · superseded-by: none
Rule: The UI-interactive E2E flows run as headless Playwright specs (web/e2e/*.spec.ts, driven by scripts/e2e-ui.sh) in a second step of the existing advisory functional-e2e job, never their own job; the browser is chromium-headless-shell baked into the CI toolchain image (docker/ci/Dockerfile, PLAYWRIGHT_VERSION kept equal to web/package.json's EXACT @playwright/test pin), never installed per run; specs are serial with retries: 0 and assert only contracts the curl harness structurally cannot reach.
Signals: UI-E2E, Playwright headless, boot-gate/Setup/Login flows, chromium-headless-shell, PLAYWRIGHT_BROWSERS_PATH=/ms-playwright, browser baked not installed, e2e-ui.sh, vitest e2e/** exclude, port 8410, no retries · paths: scripts/e2e-ui.sh, web/playwright.config.ts, web/e2e/boot-gate.spec.ts, docker/ci/Dockerfile, .gitea/workflows/docker-build.yml, docs/e2e-local.md · issues: #445, #363, #299
Mechanics: scripts/e2e-ui.sh; docs/e2e-local.md -> "UI-E2E harness"; docs/ci-cd.md -> "CI toolchain image"
Completes the last #299/#363 follow-up — the UI-interactive flows both prior records deferred.
Only assert what curl structurally cannot. The curl harness already covers the auth HTTP contracts
(setup-claim 200/409, login 401/200, CSRF 403, stamp rotation); re-asserting them through a browser buys
nothing but flake surface. Scope is the four things curl cannot express: client-side form validation
(the Setup confirm-password gate is pure React state, makes no request), AuthGate's rendered states,
the session cookie authenticating the SPA's own /api XHRs (curl proves the cookie works for curl,
not that the app sends it), and sign-out via UserMenu. This scoping rule is the durable part —
extend the browser suite only when a contract fails that test.
Baked browser, not a per-run install. chromium-headless-shell lands in /ms-playwright at image
build time, so the job installs nothing — the "jobs install nothing at run time" rule of the shared CI
toolchain image (#390, which has no standalone record — see ci.runner-placement and docs/ci-cd.md).
Measured on the real base: headless shell 267M vs full chromium 656M (+171M compressed pull),
and chromium.launch() resolves to the shell anyway; the tradeoff is that a headed run in the image
fails. Verified rather than assumed: Chromium runs as root in-container with no --no-sandbox
opt-out. The browser revision is tied to the npm package version, so that pin is EXACT and e2e-ui.sh
guards drift by launching a browser — not by path, since executablePath() reports the
full-chromium path such an image lacks.
Why that job and not a new one (docs/ci-cd.md has the detail): its npm ci + Release build are
already done, so a separate job would duplicate the dominant cost to add ~5s of browser work. The UI step
boots its own fresh instance on port 8410 — the first spec asserts the one-shot Setup gate the curl
step has already claimed.
serial + retries: 0, and vitest must not collect these files. Server state is shared and partly
one-shot (the setup-claim), so specs are serial/single-worker; each test still gets its own browser
context, which gives the login specs a signed-out browser without a logout dance. Retries are 0 even in
CI — a retry lets a flaky flow merge looking green. Coupling worth knowing: vitest's default include
glob would run web/e2e/*.spec.ts under jsdom, so vite.config.ts excludes e2e/** by spreading
configDefaults.exclude — not by narrowing include to src/**, which would silently stop collecting
the real vitest test under web/scripts/.
Lifecycle correctness in scripts/e2e-ui.sh — all four found by adversarial review, none by a
passing run; that is the transferable lesson (green runs never exercise the failure/interrupt paths).
(a) Install the cleanup trap before boot and have e2e-local.sh publish its PID to an opt-in
ETV_PIDFILE as it forks, so a mid-boot signal can still reap the server — a "kill whatever LISTENS on
the port" fallback is wrong twice over: it needs lsof (absent from the CI image) and it infers
ownership instead of proving it. (b) Re-raise signals, so a cancelled run cannot exit 0. (c) Never
if ! cmd; then status=$? — under ! bash sets $? to the logical negation, so it reads 0 and a
FAILING run exits 0, silently passing CI. (d) exec the app inside the backgrounded subshell, or $!
is the SUBSHELL on bash 3.2 (stock macOS) and every PID-based kill targets the wrong process.
2026-07-25 — OKF evaluated and rejected as a replacement; two of its optional fields adopted (#603)
key: docs.decision-optional-provenance · status: active · since: 2026-07-25 · supersedes: none · superseded-by: none
Rule: Decision records gain two OPTIONAL fields — stale-after: YYYY-MM-DD on the metadata line and a **Sources:** line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format.
Signals: OKF, Open Knowledge Format, knowledge-catalog, okf-skills, stale-after, staleness, Sources, provenance, re-confirmation, review due · paths: scripts/decisions_lib.py scripts/decisions_validate.py scripts/build_decisions_catalog.py · issues: #603
Mechanics: docs/decisions.md → header schema; docs/decisions/README.md → Review due
Sources: https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf (SPEC.md v0.2) · https://github.com/scaccogatto/okf-skills
OKF is not a replacement for this system, and the reason is structural, not stylistic. Its
conformance rules are deliberately permissive exactly where ours are deliberately strict: a consumer
MUST NOT reject a bundle for broken cross-links, unknown type values, or missing optional fields,
and its status: deprecated is a bare flag with nothing pointing at the successor. The single
catastrophic failure this corpus exists to prevent is a superseded rule read as current, and what
prevents it is the validator — one active record per key, reciprocal supersedes/superseded-by,
no record vanishing from the active set without an archive copy, catalog-drift detection. OKF has no
equivalent of any of those. It also has no key: its stable identity is the file path, which is
precisely what the breadcrumb rule tells agents not to trust. Adopting it would additionally mean a
second ingestion format alongside MemPalace for the same corpus.
What it does have that we lacked is lifecycle and provenance metadata. Records that encode
measured facts about hosts, runners or upstream behavior drift silently — nothing marked them due for
re-confirmation. stale-after fixes that, and Sources: gives the evidence a record rests on a
home other than buried prose. Both are additive: no format migration, and nothing changes for a
record that adopts neither. Adopt them opportunistically, on records that actually assert an external
fact — the three seeded here (ci.runner-placement, ci.infra-shaped-red-under-load,
ci.peak-anon-measurement) are the ones encoding measured host behavior, with dates staggered so
they don't all come due in the same week.
The staleness check is non-blocking, and the generated catalog is clock-free — both on purpose.
A calendar date turning an unrelated PR red, or silently drifting docs/decisions/README.md into a
--check failure with no commit touching the corpus, is a self-inflicted CI outage. So evaluating
today >= stale-after lives only on the validator's ::notice:: path, and the catalog's Review-due
section renders the date itself, never a stale/fresh verdict.
OKF's verified: {by, at} trust tiers were considered and deferred, not rejected. Given how
much of this corpus is agent-authored, distinguishing agent-inferred from human-confirmed-after-
measuring is real signal; it just needs its own actor convention and wasn't worth coupling to this
change.
2026-07-25 — A media-server sweep also refuses when the api client silently dropped items whose projection threw; the ratio threshold is rejected (#484)
key: scan.projection-failure-sweep-guard · status: active · since: 2026-07-25 · supersedes: none · superseded-by: none
Rule: MediaServerReconciliationGuard takes a per-enumeration projection-failure count and refuses the file-not-found sweep (logged loudly) whenever it is non-zero against a non-empty existing set — at all six sweeps, including the nested per-show season and per-season episode ones #477 left unguarded (via ShouldFlagMissingDescendants, which applies the failure refusal but not #477's empty-fetch branch). Deliberate guard-clause skips (STRM, virtual, unsupported type) are explicitly not failures and never suppress a sweep. The missing-fraction / ratio threshold floated by #477 is rejected, not deferred.
Signals: projection failure, silently dropped item, deferred ratio threshold rejected, STRM skip vs failure, library sweep anti-nuke follow-up, nested season/episode sweep guard · paths: MediaServerProjectionResult, MediaServerProjectionFailureCounter, MediaServerReconciliationGuard, JellyfinApiClient.GetPagedLibraryItems, EmbyApiClient.GetPagedLibraryContents · issues: #484, #477, #476
Mechanics: MediaServerReconciliationGuardTests policy table · JellyfinApiClientTests.ProjectionFailureCounter · MediaServerTelevisionLibraryScannerTests.Projection_Failure_Suppresses_The_Partial_Deletion_Sweep
This resolves both items the scan.zero-item-fetch-guard record (#477) left deferred. That record's rule
is unchanged and still in force: a zero-item fetch against a non-empty library still refuses the sweep.
This is a second, independent refusal on the same guard, plus a decision not to build the first.
- The two ways a projection produces nothing are NOT the same thing, and conflating them is the main
way to get this wrong. Each media-server api client maps every item the server returned through a
private
ProjectTo*and drops the ones that yield nothing. A deliberate skip is a guard clause at the top of the projection — JellyfinProjectToMovie/ProjectToMusicVideo(LocationType != "FileSystem",.strm),ProjectToEpisode(same two),ProjectToCollectionMediaItem(both plus an unmatcheditem.Type); EmbyProjectToMovie(noMediaSources),ProjectToEpisode(LocationType == "Virtual"),ProjectToCollectionMediaItem(unmatcheditem.Type). These are permanent and expected: a library holding one STRM file emits one on every scan, forever. A failure drop is thecatch (Exception ex) { LogWarning(ex, "Error projecting …"); }that everyProjectTo*in all three clients ends with — the server DID return that item, we just could not build it, and at the reconcile step that is indistinguishable from a deletion. Only failure drops suppress the sweep. Counting deliberate skips would permanently disable reconciliation for any library containing a single STRM file, so stale rows would accumulate forever — a regression, not a safe default.Jellyfin/EmbyProjectToShow,ProjectToSeasonandProjectToCollectionhave no guard clause at all, so for them every drop is a failure. - Rejected — the missing-fraction / ratio threshold. It is a two-sided heuristic. Set low it silently suppresses legitimate bulk deletions (stale rows persist invisibly, and the user's only signal is a warning nobody reads); set high it misses the partial-fetch case it exists for. Choosing the number needs per-install telemetry we do not collect, and no default is defensible for both a 20-item library and a 20,000-item one. Decisively: the failure it approximates is exactly observable by the mechanism above, so accepting an unbounded false-suppression risk to approximate it is a bad trade. A genuine bulk deletion produces zero projection failures (and a correspondingly smaller server-reported total), so the deterministic signal has no false positives on the very case the ratio threshold would have broken.
- A three-state projection result, not a wider tuple. The seam is deliberately narrow.
IAsyncEnumerable<Tuple<TItem, int>>appears in ~90 signatures acrossErsatzTV.Core/Interfaces/ {Jellyfin,Emby,Plex}, the three api clients and ~15 scanners; widening it for this would be a disproportionate, risky refactor. Instead the private mapper contract inside each client changed fromOption<TItem>toMediaServerProjectionResult<TItem>(projected / skipped / failed) — private, so zero public churn — and the paged helper countsIsFailurein exactly one place per client. The scanner reads the count through an optional trailing parameter on only the library-level methods that actually feed a sweep (IJellyfinApiClient.GetMovieLibraryItems/GetMusicVideoLibraryItems/GetShowLibraryItemsWithoutPeople,IEmbyApiClient.GetMovieLibraryItems/GetShowLibraryItems) and the nested season/episode ones (GetSeasonLibraryItems,GetEpisodeLibraryItems,GetEpisodeLibraryItemsWithoutPeople), so every other call site is untouched. A library that cannot be resolved (Option<TLibrary>.Noneinside a mapper) is classed as a failure rather than a skip; that branch is defensive and currently unreachable (every counted path passes a concrete library, and the onlyNonecaller — collections — does not route through it), kept only so the default classification is right if a future caller does passNone. - The counter is per-enumeration state, never ambient.
MediaServerProjectionFailureCounteris created by the scanner that owns the sweep, handed to the single enumeration whose result it will diff, and read only after that enumeration completes. It is never a field on an api client (those are long-lived and shared) and never static, so concurrent scans of different libraries cannot leak failures into each other's sweep decision; increments are interlocked so a paginator that ever fans out stays correct. - Wired into all six sweeps, not #477's four. The four library-level ones — the three base scanners
(
MediaServerMovieLibraryScanner,MediaServerTelevisionLibraryScanner,MediaServerOtherVideoLibraryScanner) plusJellyfinMusicVideoLibraryScanner— and the two nested TV ones,ScanSeasons'FlagFileNotFoundSeasonsandScanEpisodes'FlagFileNotFoundEpisodes.ScanLibraryWithoutCleanup(single-show rescan) passes no counter because it runs no sweep. - The nested season/episode sweeps get the failure refusal but NOT #477's empty-fetch branch.
scan.zero-item-fetch-guardleft them unguarded because "the blast radius is one show's seasons / one season's episodes, not the whole library." That reasoning is sound for a per-parent empty fetch — a plausible legitimate state, bounded to one parent — but it does not transfer to a projection failure, which is systematic by construction: one bad code path fires on every parent, so aProjectToEpisoderegression makes every season enumerate zero episodes andexisting.Except([])sweeps the entire episode library in a single scan, whichEmptyTrashHandlerthen deletes permanently. Same shape forProjectToSeasonplus #476's cascade. So the nested sweeps callMediaServerReconciliationGuard.ShouldFlagMissingDescendants— the same class and the same private failure predicate asShouldFlagMissing, deliberately without the empty-fetch branch. A separate entry point rather than reusingShouldFlagMissingwholesale, because importing #477's zero-count branch here would silently change per-parent behaviour and break #476's cascade, which depends on an emptied parent still sweeping. One class still owns the invariant; only the empty-fetch policy differs, and the difference is pinned by a test. - Known residual: a mass Skip is not covered, by design, and one skip site is response-shape
dependent. The whole split rests on skips being permanent and item-intrinsic. Emby's
ProjectToMovieguard (MediaSources is null || Count == 0) does not fully satisfy that: it is response-shape dependent, so a Refit/DTO drift or afields-parameter regression would present as a mass Skip, which by construction leaves the sweep enabled. A total such regression is caught by #477's zero-incoming branch; a partial one (say 1,200 incoming vs 2,000 existing, zero failures) would sweep 800 healthy movies intoFileNotFound. Plex's pre-projection.Filter(m => m.Media.Count > 0 …)is the same class. This is stated rather than engineered around: re-classifying shape-dependent skips as failures would reintroduce exactly the STRM-style permanent suppression the split exists to prevent. Treat it as the known next question if a mass-skip incident ever occurs. - Log-contract note. When both refusals apply, only the #484 "silently dropped …" warning is emitted,
not #477's "returned zero items" line — the failure names the actual cause, and emitting both would
imply two independent problems. Operator alerting that greps the #477 string will not fire in that
case. The message property is
{Scope}(e.g.library Movies,show Keeper seasons,season 1 of show Keeper episodes), not{Library}, so one alert pattern covers all six sweeps. - Plex reports zero failures because it has none.
PlexServerApiClient's movie/show/other-video projections return a bare entity with noOptionand nocatch, so a bad item throws and unwinds the whole scan — the pre-existing "protection by accident of control flow" that #477 named. The Plex scanner overrides therefore accept the counter and ignore it; that is honest, not a gap. If those projections ever grow a swallowingcatch, they must report into the counter at the same time. - Tests.
MediaServerReconciliationGuardTestspins the extended policy table: a failure count skips and warns; a short incoming set with zero failures (the deliberate-skip case) still sweeps; a 1-of-500 bulk deletion with zero failures still sweeps; failures against an empty local set stay a silent no-op; the failure branch is reported when both refusals apply.JellyfinApiClientTests .ProjectionFailureCounterdrives the real client over real JSON and proves the split at the layer that makes it — a STRM plus a virtual item leave the counter at 0 while the healthy item still flows, a projection made to throw yields no items and a count of 1, and two enumerations on one client instance keep separate counts.MediaServerTelevisionLibraryScannerTests .Projection_Failure_Suppresses_The_Partial_Deletion_Sweepis the integration pair to #476's cascade test: identical arrangement, only the counter differs, so the cascade test is the positive control that keeps the new test from passing vacuously. The nested levels getSeason_Sweep_Respects_Projection_FailuresandEpisode_Sweep_Respects_Projection_Failures, each parameterised with its own zero-failure positive control, plus guard tests asserting the descendant entry point still sweeps a per-parent empty fetch (the #477-scope invariant) and still sweeps when only deliberate skips shortened the incoming set. - Tests pin the JOIN, not just the two halves. A client test proving the counter fills, plus a
scanner test proving the guard honours a counter it is handed, would both stay green if a refactor
handed the api client a fresh counter while the sweep read the local one — silently killing the
protection.
MediaServerMovieLibraryScannerTests.Projection_Failure_From_The_Api_Suppresses_The_Sweep_ End_To_End,MediaServerTelevisionLibraryScannerTests.Public_ScanLibrary_Joins_The_Api_Counter_To_The_ Sweep, the two nested TV cases, andJellyfinMusicVideoLibraryScannerTests .MusicVideo_Sweep_Respects_Projection_Failuresall drive the realScanLibraryentry point and record the failure from inside the enumeration, so same-instance wiring is what makes them pass.
2026-07-25 — Music videos carry a per-library server identity; reconciliation is an itemId diff + soft trash (#496)
key: scan.musicvideo-server-identity · status: active · since: 2026-07-25 · supersedes: scan.musicvideo-reconciliation@2026-07-20 · superseded-by: none
Rule: Jellyfin music videos carry a per-library server identity (JellyfinMusicVideo : MusicVideo with ItemId/Etag, TPT table + ItemId index), so JellyfinMusicVideoLibraryScanner folds onto a shared MediaServerMusicVideoLibraryScanner base that diffs the server item id and soft-trashes (FlagFileNotFound) instead of diffing local paths and hard-deleting. Rows predating the identity are adopted in place — the identity row is inserted against the same MediaItem id, scoped to the scanned library's own LibraryPath — never deleted and re-added.
Signals: music-video server identity, JellyfinMusicVideo ItemId/Etag, itemId diff, soft FileNotFound trash, adoption of pre-identity rows, cross-library false-trash, path-replaced PathHash · paths: JellyfinMusicVideo, JellyfinMusicVideoRepository, IMediaServerMusicVideoRepository, MediaServerMusicVideoLibraryScanner, JellyfinMusicVideoLibraryScanner · issues: #496, #494, #477, #484, #488, #497, #500, #606
Mechanics: dual-provider migration Add_JellyfinMusicVideo; GetOrAdd takes localPath explicitly and never reads the projection's path; adoption probe joins MediaFile.PathHash + NOT EXISTS (JellyfinMusicVideo) filtered to the library's LibraryPath; GetByItemId filters LibraryPath.LibraryId; legacy (identity-less) rows reconciled by GetExistingLegacyMusicVideoPaths + FlagFileNotFoundByPaths and counted into the #477 guard; ScanLibrary_Should_Adopt_Using_The_PathReplaced_Local_Path, ScanLibrary_Should_Adopt_PreExisting_MusicVideo_Preserving_Identity_And_Collections, ScanLibrary_Should_Not_Adopt_A_MusicVideo_Owned_By_Another_LibraryPath, ScanLibrary_Should_Not_Resolve_An_ItemId_Owned_By_Another_Library, ScanLibrary_Should_Flag_A_Legacy_Row_The_Server_No_Longer_Reports
#494 gave music videos a trash sweep but had to key it on (LibraryPathId, path) and hard-delete, because
music videos carried no server identity. That left the known limitation this issue is named for: a file served
by two libraries with overlapping local paths is one row owned by whichever library scanned it first, and that
owner's sweep destroyed the row the other library still served. This is the deferred "option 2".
- Identity is the server item id, per library.
JellyfinMusicVideomirrorsJellyfinMovieexactly (TPT table,ItemId/Etagvarchar(36), index onItemId).GetExistingMusicVideosandFlagFileNotFoundboth joinLibraryPath.LibraryId, so the existing-set and the flag set are scoped to the scanning library — one library's sweep can no longer resolve, let alone remove, another library's row. - Soft trash replaces hard delete. The sweep now flags
FileNotFound(State = 1) like the movie/TV/other-video base scanners. The row survives, so collection membership, playout references and artwork survive with it, and removal isEmptyTrash-governed and reversible. The visible consequence is thatDeleteEmptyArtistsno longer fires from a sweep — a trashed music video still belongs to its artist. - Pre-identity rows are ADOPTED, not re-added. Every music video on an existing install has no
JellyfinMusicVideorow and so can never be found by item id. Deleting and re-adding would mint a newMediaItemid and silently dropCollectionItemmembership;MediaItemRepository.MediaFileAlreadyExistswould in fact block the re-add outright and the item would error on every scan forever. SoGetOrAddprobes for an identity-lessMusicVideoat the samePathHashwithin the scanned library's ownLibraryPathand inserts the identity row against that same id. The scoping is the point: the localMusicVideoFolderScannerwrites the sameMusicVideotable, and a local (or second-library) row must never be hijacked into this library's identity. - The adopted row is written with an empty etag so the ordinary "etag changed ⇒ refresh" path picks it up once, rather than needing a second adoption-specific update path.
- The path-replaced local path is the ONLY path this seam may match on or store.
GetOrAddtakeslocalPathas an explicit argument; theitemit also receives still carries the path the media server reported, and on any install with path replacements configured those are different strings. An earlier cut of this change let the repository derive the path from the projection, which hashed the wrong string, missed the row adoption was meant to find, slipped pastMediaFileAlreadyExists(which hashes that same wrong string), and inserted a duplicate row under a server-side path while the original collection-linked row stayed identity-less forever. The identity path-replacement stub in the test harness hid it; the regression test now uses a mapping that genuinely replaces. - Identity lookup is scoped to the scanning library, not just the item id. Two media sources can present the same item id (a cloned Jellyfin database); a global match would let one library silently repoint another library's row.
- Legacy (identity-less) rows are reconciled by local path until adopted. A row that predates the identity
AND is already gone from the server is never adopted — adoption only ever runs for an incoming item — and
carries no identity, so the itemId diff cannot see it either. Without a residual path diff it would sit
Normaland schedulable forever, strictly worse than the hard delete it replaced. Legacy rows are also counted into the #477 empty-fetch guard: on the first scan after this ships they ARE the whole library, so a guard that counted only identity rows would see "0 existing" and sweep all of them on a transient empty fetch. - Both sweeps sit behind #484's projection-failure refusal.
ShouldFlagMissing'sprojectionFailureCountis an OPTIONAL parameter defaulting to 0, so a scanner that never passes it compiles and silently opts out of the protection — which is what this scanner did until the counter was threaded through (MediaServerProjectionFailureCounter, one per enumeration, never a field on the singleton api client). The single guard call covers the legacy path diff too, which is if anything more exposed: a legacy row has no etag to fall back on. #484's own music-video test asserted the pre-#496 hard delete (FindMusicVideoPaths/DeleteByPath) and could not survive this change; it is replaced byScanLibrary_Should_Not_Sweep_When_The_Projection_Reported_Failures(identity sweep) and..._Should_Not_Sweep_Legacy_Rows_When_The_Projection_Reported_Failures(legacy sweep). Their substitute records failures during enumeration, not when the enumerable is handed out — an eager substitute would keep passing if a refactor snapshottedCountbefore the enumeration completed, which is precisely the regression the guard exists to prevent. - Scope honesty: this is parity, not a total fix. One file path is still one
MediaItemrow globally (MediaFileAlreadyExistsis a global path-hash guard), so a second library serving the same file still gets no row of its own — exactly as for movies/TV. What changes is that the first library's sweep now flags rather than destroys that shared row. The unrecoverable data loss is gone; the shared-row limitation is a whole-app property, not a music-video one, and is tracked separately as #606. #496's Done-when was amended to this parity wording rather than ticked as literally written.
2026-07-25 — The rationale-edit marker is a git trailer, not a substring anywhere in the commit range (#609)
key: ci.decisions-edit-trailer · status: active · since: 2026-07-25 · supersedes: none · superseded-by: none
Rule: The body-diff exemption is armed by an affirmative Decisions-Edit: git trailer (yes/true/1, case-insensitive, read with unfold) on some NON-MERGE commit in the PR's merge-base range — never by a substring search over the message text. A non-affirmative value (no) does not arm it, the retired [decisions-edit] substring arms nothing (the validator emits a ::warning:: nudge when it sees one without a trailer), and a git error leaves the guard ON.
Signals: decisions-edit, Decisions-Edit trailer, rationale-prose edit, body-diff guard, decisions_validate, vacuous gate, green no-op · paths: scripts/decisions_validate.py, scripts/tests/test_decisions_validate.py · issues: #609, #603, #521
Mechanics: git log --no-merges --format='%(trailers:key=Decisions-Edit,valueonly,unfold)' <mb>..<head>; dv._edit_trailer_armed
The guard could disable itself by being described. The original check was
EDIT_TOKEN.lower() in git log --format=%B mb..head, so any commit message containing the literal
string anywhere set token = True and suppressed all three rewrite comparisons (active survivors,
active→archive laundering, archive survivors). It fired live in PR #605: a commit message explaining
why no token was needed contained the bracketed token, and armed it. The result was a green
--base/--head run that was vacuous on the body-diff dimension, in the one PR that hand-resolved a
merge conflict inside the corpus the guard exists to police. No content was damaged, but the gate
reported success while checking nothing — the "a gate can merge green yet be a no-op" class.
Why a trailer and not an own-line match. Requiring the token alone on its own line fixes the
observed instance, but not the class: this repo's docs commits routinely quote example commit
messages, and the commit introducing this very record does so. An own-line matcher would arm on the
example. Git recognises a trailer only in the message's final paragraph, so a Decisions-Edit: yes
line quoted mid-body followed by more prose parses as nothing at all (verified against git 2.55).
Requiring an affirmative value closes the same trap one level up: Decisions-Edit: no, the natural
way to record a deliberate non-edit, must not read as consent.
Three exactness requirements the naive trailer read still gets wrong (all found in review, all
regression-tested). --no-merges: on a pull_request event actions/checkout lands on a synthetic
merge commit whose body the forge composes from the PR description, so without it a description
ending in an example marker arms a guard no author armed. That exclusion is not free: merging main
into a PR branch is discouraged but not mechanically blocked (prepush-rebase-check.sh only refuses a
branch that is behind main, and a merge makes main an ancestor), so an author who marks ONLY such a
merge commit has a legitimate rewrite rejected — a LOUD failure costing one extra commit, deliberately
preferred over silently disabling the guard. unfold: a folded Decisions-Edit: no + continuation
yes otherwise yields two lines, and the continuation arms on its own — inverting the value the
author wrote. And resolving the marker fails closed on a git error, the one deliberate exception
to this module's fail-open posture: a fail-open marker lookup is #609 through a different door.
The residual, stated rather than papered over: a quoted example that is the final paragraph of an ordinary commit message is, by git's own grammar, a trailer — and will arm. That is inherent to any trailer scheme; what the change buys is that merely mentioning or discussing the marker, which is what actually happened in #605, can no longer disable anything.
The marker's scope is unchanged — still narrow, still only for a rationale-prose edit to a
surviving or archived record (docs.decision-lifecycle); only its form changed. It coexists with the
Co-Authored-By: trailer the commit-msg hook already mandates, so armed commits carry a
multi-trailer block. The retired form is left detectable on purpose: a contributor with the old habit
gets a warning naming the form change, not a bare "prose changed" failure.