feat(732): On Now / Next gets a background box, and is on by default (#843)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
This commit was merged in pull request #843.
This commit is contained in:
2026-08-26 19:28:25 +00:00
committed by timothy
parent f2551b778e
commit ba6a4b08aa
22 changed files with 2501 additions and 27 deletions
@@ -0,0 +1,67 @@
---
key: graphics.seeded-template-upgrade-by-fingerprint
title: 2026-08-26 — A seeded graphics template is upgraded only when it still matches a shipped default (#732)
status: active
since: '2026-08-26'
supersedes: none
superseded-by: none
rule: '`GraphicsElementSeeder` keeps every default it has ever shipped as a verbatim fingerprint; on an already-seeded database it rewrites the on-disk template only when the file still matches one of them (line-endings and trailing whitespace normalised), so an untouched install gets the new default while any operator edit is left alone.'
signals: 'GraphicsElementSeeder, OnNowNextYamlV1, SupersededDefaults, UpgradeUnmodifiedTemplate, on-now-next.yml upgrade, graphics.on_now_next_seeded · paths: `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs` · issues: #732, #74'
---
#74 seeded `on-now-next.yml` behind the `graphics.on_now_next_seeded` `ConfigElement` marker, writing
the file only when absent so operator edits are never clobbered. That is the right rule for *content*,
but it has a consequence nobody stated at the time: **an already-seeded installation never revisits the
file at all**, so a change to the shipped default reaches new databases only. #732 hit this directly —
the background box is useless if the one install that has the overlay keeps rendering the pre-#732
template forever.
**Decision: upgrade by fingerprint, not by version number or by marker bump.** Every default we have
shipped stays in the source as a verbatim constant (`OnNowNextYamlV1`, …) collected in
`SupersededDefaults`. On an already-seeded database the seeder reads the file and rewrites it **only**
if it still normalises equal to one of those. The comparison ignores line endings and trailing
whitespace, because a volume mount or an editor rewrites those without the operator touching content.
Why this shape:
- **The safety property is derived, not asserted.** "Did the operator edit this file?" is answered by
comparing bytes against what we wrote, rather than assumed — which is exactly the assumption #74
refused to make, and which a marker bump would have to make.
The comparison is not byte-exact in one direction: line endings are normalised and the result is
`TrimEnd`ed, so an edit consisting *only* of trailing whitespace at end-of-file does not opt the
file out and would be overwritten. That is deliberate (a volume mount or editor rewrites those
without operator intent) and the cost is bounded to whitespace nobody can see. Every edit with any
visible effect opts out permanently.
- **It is self-limiting, so it needs no new marker.** After the rewrite the content equals the
*current* default, which is not in `SupersededDefaults`, so the next startup is a no-op.
- **A fingerprint entry is not a template.** Never edit an entry in `SupersededDefaults` — it is a
record of what we shipped. Changing the current default means adding a new constant and pushing the
outgoing one into the list; editing an existing entry silently un-recognises every install carrying
it, and the failure is invisible (the upgrade just never fires).
**The rewrite is write-then-move, with no in-place fallback.** `WriteAllTextAsync` truncates before
it writes, so an interrupted write would leave a partial file matching no fingerprint — never
repairable by a later boot, and rejected outright by the loader. The temp name is random per call,
because a fixed one is shared by two containers on the same config volume — and a process id is not
random enough: the image's ENTRYPOINT is exec-form, so every container is PID 1 in its own namespace
and would compute the same name. The accepted cost: a
template bind-mounted as a single file cannot be replaced by `rename(2)` (EBUSY), so that install
never receives the upgrade. Reaching that needs a pinned file that is *also* byte-identical to a
shipped default, and the alternative — falling back to an in-place copy — reintroduces the truncation
on every IO fault rather than just that one.
**The upgrade is a one-way door, and a downgrade is lossy.** `GraphicsElementLoader.FromYaml<T>` does
not set `IgnoreUnmatchedProperties`, so an unknown YAML key throws and the element is dropped with
only a logged warning. Once a template has been upgraded, rolling ErsatzTV back to a build that does
not know the new keys makes that element fail to load on **every** channel it is attached to — which,
since `graphics.on-now-next-on-by-default`, is every eligible channel that still has it (HLS Direct
was never attached, and an operator may have cleared individual channels). The symptom is "the overlay vanished
everywhere" and recovery is hand-editing the YAML back. Rollback is a supported operation here
(`:prod` is a floating tag promoted manually), so this belongs in the release notes of any version
that adds fields to a seeded template, not only in this record.
**Verify the fingerprint against a real install, not against the constant it was copied from.** The
#732 V1 entry was checked byte-for-byte against the live install (md5 `ef9afc088cf6dba252f725babbf3334f`,
2026-08-26) before being trusted. A fingerprint that does not match anything in the field is a
permanent silent no-op, and no test written from the same source can detect that.