b1d5fbefcba02fdc6c19fef85cec1c4e82fc8dea
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0d2cd89782 |
docs(563): supersede the scripted-golden deferral with the in-process coverage rule
The deferral record described ScriptedScheduleController as a "1:1 pass-through" to SchedulingEngine. It is not: an unparseable playback order is a 400, an unparseable filler kind SILENTLY degrades to FillerKind.None, an unknown build id is a 404, and the engine's no-progress InvalidOperationException is translated to a 400. Carrying that wording forward would have shipped a false statement, so the successor states a thin adapter with named mappings, each pinned by a test. - new record testing.scripted-engine-in-process-net (active, since 2026-09-05) - predecessor testing.scripted-playout-golden-deferred git mv'd to docs/decisions/archive/testing/ with frontmatter retargeted only; body prose byte-identical, so no Decisions-Edit trailer - docs/decisions.md Index line retargeted to the archive path plus a new dated line for the successor - catalog regenerated with scripts/build_decisions_catalog.py - docs/testing.md: the Golden-file nets paragraph now points at the new coverage instead of "tracked in ersatztv#563"; a new "Scripted playout coverage" section states what is covered where and what is deliberately not covered (Cli.Wrap launch, Kestrel + Startup middleware, ApiAuthorizationFilter), dated 2026-09-05; Timezone independence records the per-call TZ audit that decided which engine instructions the fixtures may use. Refs #563 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
1d76a088c6 |
Merge pull request 'feat(578): artist typeahead covers music-video and song credits; album_artist stops 404ing' (#676) from feat/578-artist-typeahead-source into main
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m58s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 19m9s
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 3m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
|
||
|
|
fad6805b91 |
feat(651): media-library pickers resolve by search instead of a bounded window
The three `getLibraryBrowseItems` pickers (RerunCollectionsScreen, PlaylistsScreen, FillerPresetsScreen) populated a native <select> from a 100-row window over media-library tables that can hold tens of thousands of rows. #644 made that truncation visible; it did not make the picker usable, and paging to completeness would have been worse than the bug (~200 serial requests, each more expensive than the last). They now resolve by SEARCH through the shared `SearchPicker` over a new `searchLibraryPickerOptions` helper: zero requests on mount or on a type switch, at most ONE bounded request (25 rows) per settled query, nothing below 2 characters. Typed text is compiled via the now-shared `titleContainsQuery` (`title:*<escaped>*`) rather than forwarded raw, since the index's default field does not match bare title words. The current selection renders from the owning record — `selectedName` for rerun collections and playlist items, and for filler presets (which store only an id) a single by-id detail read — so editing an existing record can never lose or fail to name its selection. Class A stays put: bounded-by-construction admin lists still page to completeness via `loadAllPages`, and the collection-family filler-preset types keep their bounded single page (their `query` is a SQL LIKE, which a compiled Lucene query would not match). No server-side cap is raised; this is a web-only change. Folded in from #578: the rule-builder facet typeahead arms on focus rather than on mount (an N-rule tree fired N unrequested lookups), both typeaheads pair their `seqRef` guard with a shared `useIsMountedRef`, and the roundtrip test's LCG divides by 2^32 so `pick()` can no longer index one past the end. Decision record `spa.list-completeness-vs-bounded-pickers` is archived as superseded by the new `spa.library-pickers-resolve-by-search`; spa-conventions §3b rewritten to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1641ca8305 |
fix(578): the LIKE prefilter under-matched every accented artist; make the superset provable
Review of 1b78dc9e found the pre-filter's correctness claim was false, and the claim was in the decision record as well as the code. F1 (high). The pattern JSON-encoded the whole query prefix on the reasoning that the stored text escapes non-ASCII, so encoding the prefix the same way would line up. It does not: SQL LOWER() lowercases the *escape text* (`É` -> `é`); it cannot case-fold the codepoint that escape denotes. So `q=é` built `%"é%`, the stored `Édith Piaf` never matched, and the row was discarded before the in-memory filter could accept it. Every accented artist — Beyoncé, Björk, Sigur Rós, Édith Piaf — was silently unsuggestable, which in a music library is the common case. The invariant that was missing, now stated in the code: the SQL pre-filter is an OPTIMIZATION. It may over-match; it must never under-match. Correctness lives in the in-memory filter. So the pattern now narrows only on the leading run of characters the JSON writer stores verbatim and stops at the first character it cannot prove — `q=Beyoncé` still narrows on `beyonc`, `q=é` narrows on nothing and leans on the row cap. Soundness rests on two facts now asserted by exhaustive computation rather than argued: no non-ASCII codepoint in U+0080..U+10FFFF OrdinalIgnoreCase-equals a printable ASCII character (false for InvariantCultureIgnoreCase, which folds ~190 — the choice of Ordinal is load-bearing), and the exact set of ASCII the encoder escapes. F1b. `UseRequestLocalization` honours Accept-Language, so the culture was caller-controlled and `ToLower()` plus the default linguistic `StartsWith(string)` let a header change the answer. Comparison is now OrdinalIgnoreCase and ordering StringComparer.Ordinal throughout — including the shared FilterSortTake that state/video_dynamic_range/content_rating also use. Sets unchanged, order now ordinal rather than culture-dependent. F2. The merge comment asserted an exactness the code does not have: sources truncate by their own ordering (DB collation / primary key), not the merge's, so a dropped value can outrank a survivor. Comment and record now say best-effort, exact only below the truncation points. F3/F4. The cap now rides `ORDER BY Id` rather than the JSON column: MySQL sorts TEXT by only max_sort_length bytes, so the old ordering was not deterministic there, and sorting the whole matching set was avoidable work. What the cap still does NOT bound is the scan — a leading-wildcard LIKE cannot seek an index — so that cost is now documented as accepted, with a normalized `SongArtist` table named as the follow-up candidate rather than left implicit. Every clause above is covered by a test verified to FAIL when that clause is mutated (old pattern builder: 5 red; culture chain: 3 red; cap=3 / cap=limit / ORDER BY json / no cap: red each). F5. Converted to a proper supersession. The old record did not merely hold a stale fact — it recorded song/music-video credits as an "intentionally-uncovered gap" and album_artist as unsupported, and this reverses that call, which `docs.decision-lifecycle` says is never a line-edit. `api.search-field-values` is archived with its original prose restored, and `api.search-field-values-sources` replaces it carrying the whole endpoint contract. |
||
|
|
fba5233caf |
feat(610): split the decision corpus into one YAML-frontmatter file per record
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a `git mv`. WHY: the monolith was a concurrency problem before an aesthetic one. A 3,900-line append target made parallel sessions collide -- PR #605 and PR #614 both hit append-vs-append conflicts during routine rebases, and hand-resolving those inside the corpus is exactly the operation the rationale-rewrite guard exists to police. HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness does not rest on reading it. The parser was taught BOTH formats first, so the body-diff guard parses the old form at the merge-base and the new form at head -- the migration validates itself, no bypass. The proof is a field-level equivalence harness: 168 records before and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing. Reviewers should scrutinise the harness; it is the actual evidence. What measuring caught that reading would not have: - ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each topic file's preamble, mostly the only copy. Source files are kept and stripped, never deleted. They also cannot be filed per-area: topic files hold several areas and 4 of 23 areas span several files. - Archive discovery was a non-recursive glob; after the split it found ZERO archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure. - ~32 live docs point into the corpus BY DATE, which the split dangles. Each stripped file now ends with a generated "Records formerly in this file" index, which also rescues the identical breadcrumbs in old issue comments. - decisions.md's "In this file:" list was 97 same-file anchor bullets that the split makes WRONG, not merely stale. Dropped; the generated index replaces them with links that resolve. The equivalence harness now runs against a checked-in FIXTURE, not the live corpus. The earlier version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the SCRIPT rather than the repo's current state. Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain two directories for one concept. Renaming a key is not a move -- it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work. refs #610 |
||
|
|
64decd492e |
fix(496): give music videos a per-library server identity; itemId diff + soft trash
Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served by two libraries with overlapping local paths is a single row owned by whichever library scanned it first, so that owner's sweep destroyed a row another library still served — taking collection membership and playout references with it, irreversibly. This is #494's deferred "option 2": - New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table, varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo. - New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per library via LibraryPath.LibraryId. - New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim. - The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep. - Pre-identity rows are ADOPTED in place: the identity row is inserted against the same MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership survives and a local/second-library row is never hijacked. - AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection fills them from the server-reported path, which would break every later PathHash lookup. Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as superseded; new active record scan.musicvideo-server-identity. fixes #496 |
||
|
|
6cf99718ee |
feat(436): arbitrary-depth rule-builder group nesting
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 34s
PR Gates / Docs update reminder (pull_request) Successful in 38s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 42s
PR Gates / decisions lifecycle (pull_request) Successful in 1m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m3s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The rule builder's Group nesting was capped at one level (#176's Kodi model). Generalize it to recursive nesting bounded by a single shared constant, MAX_GROUP_DEPTH (types.ts, = 5, root group is depth 0): - parse.ts: replace the allowNested boolean with a depth counter that recurses to the cap; deeper input stays out of subset (null -> raw-text fallback), so parse remains the exact inverse of compile. Sub-group detection now requires the leading '(' to be the one closed by the trailing ')' (quote/escape aware), so '(a)x(b)' can't be mistaken for one wrapped group. - RuleBuilder.tsx: 'Add group' is offered while depth < MAX_GROUP_DEPTH instead of only at the root; nested group boxes get box-sizing: border-box so per-level padding can't overflow (no global reset). - roundtrip.test.ts: the 500-tree generator nests to the cap and asserts the corpus actually reached it; explicit depth-3 cases added to compile/parse/validation tests and a depth-gate test to RuleBuilder. compile.ts and validation.ts already recursed correctly and are unchanged. No backend/OpenAPI change. Docs: spa-conventions.md §12; decisions lifecycle — new active record spa.rulebuilder-nesting, predecessor spa.smartcollection-rule-builder relocated to docs/decisions/archive/spa.md as superseded. fixes #436 |
||
|
|
65c0e09179 |
feat(415): per-channel fault detection — server-derived health object + Problems filter (#581)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 15m48s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Closes #415. Server-derived health object on the channel list + detail DTOs (built-timeline detection, kind-agnostic across all 5 PlayoutScheduleKind; assessable gate keyed to the owning channel's mode), single "Problems" SPA filter with per-fault badges. Supersedes #72's api.channel-health-signal decision. Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com> |
||
|
|
962dc2a31a |
feat(520): parallel orientation+selection startup; retire #237 as live state; #642 retrieval bullets [decisions-edit]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b1e7e08884 |
feat(521): rework decisions guard — lifecycle validator + narrow [decisions-edit]; header + CI rewrite [decisions-edit]
Retire the line-level append-only mechanic (ersatztv#303 H9) in favor of the lifecycle validator built in Tasks 1-5. .claude/hooks/decisions-guard.sh is now a thin fail-open shim around scripts/decisions_validate.py; .husky/pre-commit calls it for the structural (working-tree) checks, .husky/commit-msg drops the old staged/[decisions-edit]-deletion block and keeps only the Co-Authored-By check. The Gitea decisions-guard job is renamed "decisions lifecycle" and now runs decisions_validate.py --base/--head (structural + body-diff + no-vanish) and build_decisions_catalog.py --check (active catalog drift), with actions/setup-python@v5 added since the bare `small` lane doesn't guarantee python3; the old 1800-line consolidation-floor step is removed (replaced by the validator's aggregate active-corpus budget). docs/decisions.md's header is rewritten from append-only to lifecycle framing (metadata schema, statuses, generated catalog, archive, same-PR supersession); [decisions-edit] is re-scoped (not removed) to rationale-prose edits/factual corrections only. docs/ci-cd.md's release ritual and hook/job descriptions are rewritten to match. Also fixes a pre-existing validator false-positive surfaced while sanity-checking against origin/main: Task 6's #303 H9/H3 split (commit d09be57e) renamed the archived record's heading away from the pre-split original, which the validator's heading-based relocation check reads as "removed without an archive copy." Restored the archived heading to match the original text (functionally unchanged — still status: superseded, same key) and updated the two prose cross-references (migration-map.md, release-ci-governance.md) that pointed at the old anchor. PR1 scope only (per brief): does NOT wire the kickoff-guard CI step or touch the kickoff/README/select-queue docs — that's Task 8/PR2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
417279c072 |
fix(521): split #303 H9/H3 bundle — H3 root-screenshot guard stays active, only H9 archived [decisions-edit]
The archived docs.append-only-guard record bundled the genuinely-superseded H9 (decisions.md append-only) with the independent, still-active H3 (root-screenshot pre-commit guard), relocating both into archive/ and burying an active decision in the history wing. Split into two records: docs.append-only-guard stays in archive/release-ci-governance.md, status: superseded, H3 content removed and replaced with a forward pointer; ci.root-screenshot-guard is a new status: active record in the active docs/decisions/release-ci-governance.md, with the H3 rationale paragraph lifted byte-identical from the original bundle. Updated migration-map.md (new row + a "Migration conventions" note: never relocate an active decision to archive/) and regenerated docs/decisions/README.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
eada44deb1 |
feat(521): reconcile #390/#406 + #411 + append-only supersession; seed exemplars [decisions-edit]
Migrate #406 (ci.runner-placement) and #412 (ci.peak-anon-measurement) to the lifecycle schema as exemplars; #390 and #411 confirmed prose-only (no standalone record). Dogfood the append-only->lifecycle supersession: docs.append-only-guard moved to archive/, superseded by new active docs.decision-lifecycle. Stand up docs/decisions/archive/ + migration-map.md. Refs #521 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |