Commit Graph
80 Commits
Author SHA1 Message Date
timothyandClaude Fable 5.1 350509f8bb docs(554): the opt-in's unreachability is about started, not about mount order
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 35s
review-verdict/h10 Review-verdict: MERGEABLE @ 350509f (base: main)
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 16m49s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m20s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m1s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 45s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
"its button renders only before the player has ever mounted" is loose: a channel
switch to a forced channel re-renders the button after a player has mounted for the
previous channel. The load-bearing fact is the one the code comment states — the
button renders only while `started` is false, and the only thing that un-starts the
panel is the channel reset that clears the flag in the same batch.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:45:42 +02:00
timothyandClaude Fable 5.1 f119bfcb29 docs(554): the opt-in is a third path back to starting, and the invariant must say so
The hint's single-guard invariant was stated as "every path back to `starting` clears
the flag itself", enumerating Retry and a channel change. `onOptIn` is a third such
path and clears nothing, so the sentence was false as written — in the code comment,
in the Retry test's comment, and in docs/spa-conventions.md §5b.

Adding a clear to `onOptIn` would be dead code no test could distinguish, which is the
exact shape this branch removed from `onPlaying`. The omission is correct for a reason
none of the three places stated: the opt-in button renders only while `started` is
false, `started` only goes false in the render-phase reset that clears the flag two
lines later, and no player exists to set the flag while `started` is false. State that
exception, and pin the reachability premise it rests on with a test that fails if the
opt-in button outlives the mounted player.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:42:39 +02:00
timothyandClaude Fable 5.1 4e042fb7de fix(554): the hint's cause must be measured, and muting is the consumer's call
Round one measured three defects in the first commit.

onAutoplayBlocked fired on ANY rejected video.play(), so the panel could
say "autoplay was blocked" when it was not. A play() interrupted by
teardown rejects with AbortError — which is exactly what the panel's own
Retry produces while the MANIFEST_PARSED play() is still pending — and
because the element was muted, a genuine NotAllowedError is the rare
case, so the realistic firings were the mislabelled ones. Report only a
DOMException named NotAllowedError, on both the MSE and native paths.

`muted` was applied to the shared player unconditionally, which silently
muted the playback-troubleshooting screen — the tool whose job includes
verifying the audio side of an FFmpeg profile, and which the legacy
Blazor player never muted. Make it an opt-in `muted` prop defaulting to
false; the channel preview passes it, troubleshooting does not, and a
test on each side pins its own value.

The two clauses hiding the hint once playback starts masked each other:
removing either alone left the panel suite green. Every path back to
'starting' (Retry, a channel change) already clears the flag itself, so
the clear in onPlaying could never be the load-bearing guard — drop it
and let the `state === 'starting'` render guard be the single pinned one.

Also cover the native-HLS (Safari) branch, which no test had ever
executed: its play() kick, its playing/error wiring, and both autoplay
rejection names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 01:46:37 +02:00
timothyandClaude Fable 5.1 02e1c583e9 fix(554): mute the preview player so autoplay is never blocked
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 24s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m47s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
review-verdict/h10 Awaiting review verdict for 02e1c58
Review verdict / Set review-verdict status (pull_request_target) Successful in 33s
HlsPlayer's manifest GET can block until segments exist (unbounded
maxTimeToFirstByteMs), so MANIFEST_PARSED can arrive past the
browser's transient user-activation window and video.play() gets
rejected as blocked autoplay — the channel preview panel then sat at
"starting" over a black frame with no hint the operator just needed
to press play.

Render the <video> element muted (browsers permit autoplay of muted
media without user activation) so the common case starts on its own,
and add an optional onAutoplayBlocked callback for the residual case
(stricter policy/extension) that the channel preview panel wires to a
"press play" hint shown only while still starting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 01:12:39 +02:00
timothyandClaude Fable 5.1 e1d211cd1b test(830): pin which ARM the call site reaches, not only that the hook has one
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Review verdict / Set review-verdict status (pull_request_target) Successful in 22s
review-verdict/h10 Review-verdict: MERGEABLE @ e1d211c (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Review measured M6: swapping `reportFailure(...)` for `onAddFailed(...)` in
`AddItemsDialog`'s catch left all 490 tests across the 42 `src/screens` files
green. Under that mutant a failure that lands while the dialog is STILL OPEN
renders into `ManualItemsView`'s screen banner, which sits behind the dialog's
`createPortal` panel with `aria-modal="true"` — covered for sighted users,
hidden from AT, and the surface the user is actually looking at stays blank.
That is the exact shape the decision record calls "its own defect", and the
whole gap was the call-site wiring: the hook's inline branch is pinned at unit
level in `hooks.test.tsx`, but a unit test of the hook cannot see which arm a
consumer reaches.

Adds the integration assertion: fail the POST with the dialog still up, assert
the message is inside `[role="dialog"]` and appears exactly once in the tree.
Re-executed the mutation with it in place — 1 failed / 490 passed, and the red
is this test alone.

Records the new pin as mechanics (5) on
`spa.dismissible-write-failure-reporting` and the general form in
`spa-conventions.md` §3c, so the next site wired to the hook pins both arms at
its call site rather than inheriting the hook's unit coverage.

Refs #830

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 4b12125f4d docs(830): drop a coverage claim this record's own mechanics field contradicts
Round 4 found one clause left, and it is a good example of the thing this record is about. The
one-consumer paragraph said `hooks.test.tsx` "is the ONLY thing pinning the diverted branch".
Measured at the previous head, disarming `reportRef.current(message)` reddens THREE tests -- both
hooks.test.tsx divert tests AND the CollectionsScreen integration test -- which is exactly what the
`mechanics:` field of the same record says 58 lines earlier. So the record asserted a coverage fact
and then contradicted itself.

The concrete harm is not the inconsistency: a future session pruning tests reads "hooks.test.tsx is
the only pin", concludes the CollectionsScreen #830 test is redundant, and deletes the only
end-to-end pin of the whole path -- the one that actually drives Escape-dismissal through the real
dialog. Clause dropped; the argument the paragraph needed (the hook's shape earns its own unit
tests) survives without it.

The clause originated in the reviewer's round-3 wording and I transcribed it without checking it
against a field I had written myself two rounds earlier. Worth recording: a review finding is not
exempt from verification just because it came from the reviewer.

Also:
- the `AddToMenu` clobber sentence now splits what was MEASURED (a late success closes a reopened
  dialog) from what was READ (both parents call `clearSelection()` unconditionally, so the wipe
  follows). On a record whose subject is over-attributing measurements, that distinction has to hold
  in its own prose.
- §3c now carries the same "nothing diverts to those screens today" disclaimer the record's limit
  (2) has, so the two artifacts say the same thing
- rewrapped one 141-char comment line left ragged by the previous round's splice

Docs only, plus one comment rewrap. 1276 tests green, tsc/eslint/build/validator/catalog clean.

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 de9432ff49 fix(830): withdraw the media/addTo extension — two rounds, two defects, one coupled gate
Round 2 of adversarial review found that my round-1 fix introduced an adjacent defect, and it is the
same mechanism both times: `onAdded?.()` and `onClose()` sat behind ONE unmount gate in the four
`media/addTo/` dialogs, and those two callbacks do not mean the same thing.

- Gate both (origin/main): a write that SUCCEEDS after dismissal reports nothing. Measured on
  `AddToCollectionDialog` -- `onAdded` called 0 times after dismissal. That was round 1's finding.
- Un-gate both (my round-1 fix): a late success closes a dialog the user REOPENED to retry, and on
  SearchScreen/MediaBrowseScreen `clearSelection()` wipes a multi-select they rebuilt. Measured
  against the real `AddToMenu`. That was round 2's finding, and I introduced it.
- Gate only `onClose`: still wrong on its own, because `AddToMenu.handleAdded` nulls the dialog
  itself. Needs three coupled edits across five files -- plus a genuine product question nobody has
  answered: should `clearSelection()` fire for a write the user walked away from?

That is a design change, not a bug fix, and #830 never asked for it -- the issue is about
`AddItemsDialog`. Two defects from one mechanism in two rounds is the signal to stop widening, so
the `media/addTo/` extension is REVERTED here and moves to #877 with every measurement attached
(#877 comment). What ships is the thing the issue asked for, proved:

- `useDismissSafeError` + `AddItemsDialog` + `CollectionsScreen` wiring
- the witnessed red is unchanged: disarming `reportRef.current(message)` reddens the integration
  test on `Unable to find an element with the text: Request failed with status 500`

Docs now describe what is actually true rather than what I hoped:
- the record says ONE A1 site is fixed and explains why the other four were withdrawn, keeping the
  wrong first claim visible because "one site read, four assumed" is the lesson
- §3c splits the rule the round-2 defect came from: report the OUTCOME unguarded, gate the DISMISS
  request separately -- the earlier text lumped `onClose` in with `onAdded` and would have
  propagated the clobber to the next screen that adopted it
- §5c no longer tells authors to wire an `onFailed` that the addTo layer does not have; it says the
  layer has no failure channel at all and points at #877
- the reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), which
  is what the docs now say instead of calling it optional
- `mechanics:` no longer implies the shared clause reddens one test; it reddens three, so re-running
  the mutation should expect three

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:09 +02:00
timothyandClaude Opus 5 f61764d051 fix(830): report SUCCESS past dismissal too — the review measured my claim false
Adversarial review (cold, worktree-isolated) blocked the first commit on its central prose claim,
correctly. I wrote "at every one of these sites SUCCESS already outlives dismissal" into three
durable artifacts -- the decision record, spa-conventions §3c and the hooks.ts header -- after
reading ONE site. `AddItemsDialog` does report success past dismissal and says so in a comment; I
generalised from it. The review probed the other four instead and MEASURED `onAdded` called 0 times
after dismissal: all four `media/addTo/` dialogs gated `onAdded?.()`/`onSaved?.()` behind their own
`activeRef`, exactly like the failure path.

So after the first commit those four were still asymmetric, just inverted: dismiss-then-fail loud,
dismiss-then-succeed silent -- and additionally leaving the caller's selection state stale, because
SearchScreen's `onAddedToSelectionTarget` never ran to clear it. The record's own advice ("add the
failure counterpart") followed literally would have reproduced it.

Fixes, each proved by execution:
- the `activeRef` gate above `onAdded?.()`/`onSaved?.()` is removed in all four dialogs; those two
  statements belong to the still-mounted PARENT, which is the reasoning AddItemsDialog already had
- `AddToCollectionDialog.test.tsx` covers the media/addTo half in BOTH directions. It had NO
  coverage before: reverting `reportFailure` to `setInlineError` in all four left the whole suite
  green. Restoring the success gate reddens the SUCCESS test alone; disarming
  `reportRef.current(message)` reddens the FAILURE test alone
- the three prose sites now say what was measured, and the record keeps the wrong first version
  visible, because "one site read, four assumed, written down before measuring" is the finding

Also from the review:
- hooks.ts said "React 18"; package.json pins 19.2.7. Now "React 18+"
- the "nothing better to do" comment overclaimed: diversion reaches ONE level, so Back out of a
  collection mid-add still drops the message. Stated, with where it would be fixed
- recorded two limits rather than leaving them to be rediscovered: useIsMountedRef clears in a
  PASSIVE effect cleanup, leaving a narrow window where the message renders inline into a detached
  tree (useLayoutEffect would close it, but that hook is shared by every async caller -- #877, not a
  bug fix); and Toast is role="status" with one last-writer-wins slot, so it is not equivalent to
  CollectionsScreen's role="alert"
- §5c now cross-links §3c, since that is the section a screen author reads before wiring AddToMenu
- the sweep count is 67 caller-owned + ConfirmDialog's own internal <Dialog>

Three other media/addTo dialogs remain unpinned; they are identical in shape to the covered one,
which is a reason to expect the same behaviour, not evidence of it. Said so in the record.

refs #830, #877

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:08 +02:00
timothyandClaude Opus 5 9ba397e1dd fix(830): a write failure reports to a surface that outlives the dialog that started it
`AddItemsDialog.submit` POSTed to `/api/v1/collections/{id}/items` and reported failure into a
banner rendered from its OWN state. The dialog is dismissible mid-request through three paths that
never consult `adding` -- Escape and a backdrop click (both `useOverlayBehavior`) and the header
close button -- and the caller remounts it on `key={`add-${pickerOpen}`}`, so dismissal genuinely
unmounts it. Select 12 items, Add, press Escape, the request fails: nothing surfaces, the list
reloads unchanged, and the user believes 12 items were added.

drop deliberate rather than accidental. A deliberate drop is still a user who is told nothing.

The asymmetry is the finding: SUCCESS already outlived dismissal everywhere here, because it is
reported through a parent callback (`onAdded`/`onDone`, which the screens turn into a `Toast`).
Only failure died with the surface. So this is not a new notification system -- it routes failure
through the channel success already uses. `AddToMenu` had `onDone` and no counterpart at all.

`useDismissSafeError` (`web/src/hooks.ts`) renders the message INLINE while the surface is mounted
-- the better surface, since it keeps the user's selections and context -- and diverts to a
caller-supplied `onFailed` once it is gone. The surviving surface belongs to the parent and differs
per screen (a `role="alert"` banner on CollectionsScreen, `notice`+`Toast` on MediaBrowse/Search),
so it is a prop contract rather than a rendering decision. Gating dismissal on the busy flag was
considered and rejected: it traps the user behind an in-flight request with no cancel path, and
would not cancel the write anyway.

Applied to the A1 shape -- where the surface owns the error state and is really unmounted:
AddItemsDialog plus the four `web/src/media/addTo/` dialogs, whose failures previously could not
reach the screen Toast that already showed their successes.

Proofs, executed rather than described:
- deleting `reportRef.current(message)` alone reddens the new CollectionsScreen test on
  `Unable to find an element with the text: Request failed with status 500`
- `hooks.test.tsx` pins both branches directly, plus that the report goes through the LATEST
  callback rather than the one captured on first render
- `CollectionsScreen.guards.test.tsx`'s is-mounted read count moves 2 -> 1 because the catch's
  guard migrated into the hook (its `...actual` module mock cannot see the hook's internal
  `useIsMountedRef()`); removing the surviving `finally` guard takes it to 0 and reddens, so the
  anti-masking property that count was added for is intact

Scope is stated rather than implied. A sweep of all 68 Dialog/ConfirmDialog/SlideOver call sites
found three shapes; only A1 is fixed here. A2 -- error state that survives but whose render site is
gated by the same condition dismissal clears, mostly delete-confirm flows -- is left open in #877
because its right answer is probably a shared surface, not twenty prop threads.

fixes #830
refs #877, #740, #685

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
2026-09-05 00:23:08 +02:00
timothyandtimothy e8f80c42ce fix(820): derive where Complete&lt;T&gt; is APPLIED, not just what it means (#883)
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 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 11m1s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 9m58s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m58s
`Complete<T>` (#807) makes SPA full-replace bodies fail typecheck when a builder omits a schema member. Nothing checked it was APPLIED: `completeRequest.guard.test.ts` proves the type's semantics and would stay green with every annotation deleted, and `test_optional_request_members.py`'s COVERED disposition — "the builder is annotated `Complete<T>`" — was a claim about another language's source that nothing verified.

Adds `completeAnnotationScan.ts` (compiler-API scanners) + `completeAnnotations.guard.test.ts`, with a synthetic-source fixture suite. Two derived populations: the `Complete<…>` annotations (SPA AST ∩ git index) and the droppable schemas (parsed from the generated `v1.d.ts`, a pass-through of the OpenAPI `required` array). It asserts a production annotation per schema dispositioned as needing one, NO annotation on the server-computed and load-bearing-omission schemas, that every `Complete<X>` resolves to a generated schema rather than a hand-written mirror, and set equality between droppable schemas and the reviewed dispositions. `test_complete_annotation_dispositions.py` cross-checks that table against the authoritative Python one and ships a declared, harness-executed mutation.

Found one live defect: `playouts.ts` declared two request types as hand-written mirrors SHADOWING generated schemas of the same name, so their `Complete<>` was checking a local copy rather than the contract — the #754 mechanism wearing the annotation meant to prevent it.

Eight review rounds, seven BLOCKED, two independent cold reviewers. A wrapper-signature scanner was built and REMOVED: every blocker traced to that one mechanism (obligation on the wrong population; reachability mistaken for protection, since `Complete<T>` is shallow; body discovery keyed on a parameter name, then parameter-vs-local; and finally `export function` → `export const` blinding the scanner and its cross-check together). Five defects from one mechanism, so the mechanism went rather than a sixth patch.

Residuals stated in §4b, the guard-inventory row and the record: per-SCHEMA not per-site or per-wrapper; token presence not liveness; the phantom direction unchecked (#777); a second `setupFiles` entry could discharge; and plugin-level population integrity borrowed from the sibling guard.

fixes #820

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 22:06:38 +00:00
timothyandtimothy 8aeacd534a fix(819): derive the SPA page-size guard population from the git index (#875)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
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 / Build & push image (amd64) (push) Successful in 5m5s
The guard asserted EXACT completeness over a population enumerated by a directory
walk, so an untracked .ts/.tsx under web/src/ entered it and failed as unregistered
on that developer's checkout while CI — which only ever checks out tracked files —
stayed green.

The glob still supplies file CONTENT; the POPULATION is now the git index, read by
web/vite-plugins/trackedSourceFiles.ts in Vite's own Node context and handed to the
app project as a virtual module. That reaches the index without admitting
@types/node to tsconfig.app.json, the obstacle that deferred this in #818.

Three mechanisms carry the proof, each added because the previous was measured
insufficient: a closed-form restatement of the shared scope predicate (sharing no
helper at any depth with what it checks); a second independent `ls-files --others`
query cross-checking the population; and real-git tests that execute the derivation
against a temp repository.

Six residuals are stated with their MEASURED fail-directions, and
testing.guard-derives-population-from-source gains a bounded exception plus the
closed-form criterion.

fixes #819

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-29 09:14:15 +00:00
timothyandtimothy ed8b602445 feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing (#847)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m23s
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 / Build & push image (amd64) (push) Successful in 6m23s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 22:05:38 +00:00
timothyandtimothy 40a3232d9e feat(734): field-level progressive disclosure — shared FieldHelp trigger + panel (#841)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 15s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m18s
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 / Build & push image (amd64) (push) Successful in 5m27s
Implements the three-level field-help pattern from #734 as a shared component: field name + optional one-sentence summary → a one-short-paragraph panel behind a consistent Info-icon trigger → a future external-docs deep link (`docsHref`, built and typed; no screen passes one yet).

Adopted on FFmpegProfilesScreen (9 fields), documented as docs/spa-conventions.md §15 with decision record `spa.field-progressive-disclosure`, and mirrored into the design-system prototype.

The panel is portalled to document.body: `.ctv-card` sets `overflow: hidden`, which clips a positioned descendant whatever its z-index, and one field's explainer rendered 12px of a 92px paragraph in every state of the Audio card.

A `::before` hover bridge was added and then WITHDRAWN — it held for a vertical descent onto the panel and failed for a diagonal one, leaving a safe sideways exit of 1.25px on an 18px icon. Hover reads the paragraph in place; the panel's interactive content is reached by pinning.

Four cold adversarial review rounds; the first three returned BLOCKED. They found five wrong copy claims across nine paragraphs and two vacuous tests in a row for the same mechanism.

Deferred with owners: #839 (placement verified by hand, not by a test) and #840 (the portal puts a docsHref link at the end of the tab order).

fixes #734

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 18:05:53 +00:00
timothyandtimothy 7453dd3a82 fix(721,740): align Auto-Tune proposal rows on a grid; guard AddItemsDialog's async searches (#831)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 8s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m58s
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 / Build & push image (amd64) (push) Failing after 1m17s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-25 21:55:23 +00:00
timothyandtimothy 3e2c07b42f fix(807): SPA full-replace bodies are built as Complete&lt;T&gt;, with a derived guard over droppable request members (#827)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 14s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 30s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m9s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m49s
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 / Build & push image (amd64) (push) Failing after 15s
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-23 00:58:19 +00:00
timothy 7be42654fe fix(685): suppress both empty-states on error; name the addable-kind derivation
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 18s
Review verdict / Set review-verdict status (pull_request_target) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m33s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m21s
review-verdict/h10 Review-verdict: MERGEABLE @ 7be4265 (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 2m7s
PR Gates / Script tests (pytest) (pull_request) Successful in 2m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Round-4 verification returned MERGEABLE with no new defects and no
BLOCKER/HIGH/MEDIUM. These are its remaining LOW and nits.

- "No results — try a search above." asserts a search that COMPLETED and found
  nothing, so it was false beside a failed request. Both empty-state messages
  are now suppressed on error and the role="alert" banner is the whole message,
  as that test's comment already claimed. Pinned positively and negatively so a
  refactor cannot satisfy the assertion by rendering nothing at all.

- The (typeof ADDABLE_TYPE_LIST)[number] derivation is now the named
  AddableKind, spelled once instead of twice: a third ingress into the searched
  kinds is most likely to be written by copying one of the existing two, and the
  "one list, all ingresses" property should be visible at a glance rather than
  reassembled.

- The §3b lesson cited two measured test counts, which go stale against the very
  suite they describe — a count taken before the helper had unit tests no longer
  holds now that it does. Scoped the observation to the sha it was measured on
  and replaced the counts with the invariant they were evidence for: every gate
  needs at least one test that reddens when that gate ALONE is removed.

- Documented why the error banner stays conditionally mounted while the hint's
  live region does not: role="alert" is the one live-region role screen readers
  reliably announce on insertion, so the two are correct for opposite reasons.
  The review flagged the divergence as unexplained, not as wrong.

refs #740
2026-08-05 19:40:07 +02:00
timothy d7725c274c fix(685): close the second ingress; stop the error path claiming a short query
Verification round returned MERGEABLE with all three blocking findings resolved
by measurement. These are its four remaining items.

- DEFAULT_SEARCH_KINDS was the SECOND ingress into the searched kinds and was
  not derived, so the previous commit's "enforced by the type system" claim held
  for one of two paths. A non-addable kind there typechecked clean and would
  have overstated the hint with every one of its rows dropped — the exact defect
  the derivation exists to prevent. Now derived; verified by mutation that
  adding 'Collection' to it is a compile error.

- The error path fell into the min-query guidance branch, so a valid 2-character
  query that got a 500 told the user to type at least 2 characters. That branch
  conflated "nothing searched yet" with "the last search failed". Newly
  introduced by the previous commit's error-path reset; now gated on !error and
  pinned by a test.

- The aria-live region was mounted conditionally, creating the region and its
  text in one commit — which most screen readers do not announce. It is now
  mounted unconditionally with the condition inside.

- The §3b lesson mis-stated where the duplicate gate lived: it was inside
  runSearch, the genuine single sink, NOT at one of the callers — so the rule as
  written ("put the gate in the single sink, not at each caller") described the
  revision that was rejected. Reworded to the actual lesson: the gate's home is
  the shared helper, and "it's the single sink" is not evidence it is the only
  guard. That misreading is why #685 got this wrong twice.

Declined again, with reasons: the NaN pageSize edge (faithful to the sibling
helper), the clamp test's unpinned lower bound (same), and the registry's
disclosed same-identity substitution gap.

refs #740
2026-08-05 19:20:26 +02:00
timothy b6bf94f129 fix(685): test the helper's bound; delete the masking duplicate gate
Independent review round 2 returned BLOCKED on two findings, both correct.

- The helper's bound was dead code to the suite. searchLibraryBrowseItems had
  zero tests, so deleting its clamp OR its gate left the whole suite green —
  while the registry note claimed a caller "cannot skip the bound". That is the
  previous round's finding relocated, not removed. It now has the three tests
  its sibling searchLibraryPickerOptions already had (clamp, gate, compile),
  plus one pinning the full-row return that is its reason to exist.

- The screen kept a second copy of the min-query check, and the two masked each
  other: the 1-character boundary test passed with EITHER gate alone, so it
  pinned nothing. The screen's copy is deleted; the helper is the sole gate.
  Measured before/after: with the duplicate present, removing the helper's gate
  left that test green; with it gone, the same removal reddens it.

- §3b contradicted itself two lines apart — the parent still said "there is no
  truncation, so there is no truncation hint" above a sub-bullet mandating one.
  Reworded so a hint is permitted, required only where bulk selection makes the
  count actionable. Same correction to the 'search-bounded' definition.

- A failed search left results/totalMatches stale, rendering a confident
  "Showing 75 of 60000 matches" beside the error banner. The catch clears them.

- Results now carry a `Results for "<query>"` heading and the guidance is keyed
  to the settled query, not the live input, so rows are never shown without
  saying which search produced them. `selected` persists across queries (correct
  for a multi-select picker); the Add button's count keeps it discoverable.

- The hint sums pre-filter totalCount against post-filter rows, which is only
  correct because every filterable kind is addable. MediaKindFilter is now
  derived from ADDABLE_TYPE_LIST, making that a compile error rather than prose.

- aria-live on the hint; the #740 doc caveat no longer overstates the typeahead
  rule as a mandate this screen violates.

Declined: the NaN pageSize edge (copied faithfully from the sibling helper) and
the registry's same-identity substitution gap (already disclosed in that file).

refs #740
2026-08-05 19:08:13 +02:00
timothy 4be3f247d8 fix(685): move the picker bound into the helper; surface the per-kind cap
Independent review round 2. Verdict was MERGEABLE with no blockers; this takes
the two recommended fixes plus the structural one it listed as a follow-up.

- The bound was caller discipline, not code: getLibraryBrowseItems does not
  clamp pageSize, so the bound was only the constant this one call site chose
  to pass, and §3b is explicit that a bound a caller can exceed is not a bound.
  New searchLibraryBrowseItems in libraryBrowse.ts owns the min-query gate, the
  pageSize clamp and the titleContainsQuery compile, returning full
  LibraryBrowseItem rows plus totalCount (searchLibraryPickerOptions' {id,name}
  shape loses the mediaType that toAddItemsRequest needs). runSearch keeps one
  early return, for the spinner only, and no longer re-implements the gate.

- The min-query guidance was keyed to the LIVE input, so backspacing below the
  gate after a search wiped the rendered rows and their checkmarks while
  `selected` and the Add button still counted them. Keyed to results.length too.

- "Nothing left to hint at" was false: each kind is still capped at
  LIBRARY_PICKER_RESULTS and totalCount was never read. This is a bulk
  multi-select add, so the cap is surfaced — per-kind totalCounts are summed and
  rendered as "Showing N of M matches" once it exceeds the rendered rows. The
  registry note and the §3b bullet are corrected to stop claiming otherwise.

- Gate boundary tested at 1 character (§3b: inclusive endpoints, or a > for >=
  slip passes the whole suite).

- The guard test's deviation loop iterates an empty list now, so it gains one
  bidirectional assertion that is non-vacuous: the set carrying an `issue` field
  must equal the set classified 'deviation'.

- The §3b bullet no longer reads as a conformance certificate: AddItemsDialog
  still lacks the seqRef and useIsMountedRef guards §3b mandates. That defect is
  PRE-EXISTING, not introduced here, and is tracked in #740.

refs #740
2026-08-05 18:46:43 +02:00
timothy 28ce8c4dfe fix(685): gate AddItemsDialog on a real query instead of windowing the whole type
AddItemsDialog.runSearch was reachable with an empty query two ways — a blank
form submit, and a kind-chip click, which called it immediately — and
getLibraryBrowseItems omits a falsy `query`, so each path degraded into an
unfiltered browse of the whole media-library type (first 50 rows, per kind)
presented as the answer with nothing surfacing the truncation. All ten
ADDABLE_TYPE_LIST entries are spa-conventions §3b Class B media-library types.

The dialog is multi-select, so §3b's SearchPicker (single-select) does not fit;
it takes §3b's constraints instead:

- no request below LIBRARY_PICKER_MIN_QUERY, enforced in runSearch — the single
  sink both entry paths route through, not duplicated per caller
- typed text compiled with titleContainsQuery rather than forwarded raw (a
  second latent §3b violation here: the search index's default field does not
  match bare title words)
- each kind bounded to LIBRARY_PICKER_RESULTS
- merged.slice(0, 50) removed — it silently dropped up to 100 of 150 fetched
  rows even for a real query

Tests assert zero requests below the gate on both paths, exactly one bounded
request per kind above it (20k-row fixture), the compiled+escaped query, and
that no fetched row is dropped. Each was verified to fail with its mechanism
removed.

The pageSize registry entry moves from `deviation` to `search-bounded`. That
leaves zero deviation entries, so the anti-vacuity assertion guarding that list
is deleted deliberately, per its own instruction.

Done-when box 4 (collection-family truncation hint) has no subject: this screen
offers no collection-family type.

fixes #685
2026-08-05 18:46:43 +02:00
timothy 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
2026-07-27 06:09:30 +00:00
timothyandClaude Opus 5 27867e03cf fix(651): make the stated invariant true on Playlists; pin the predicate's endpoints
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 22s
review-verdict/h10 Awaiting review verdict for 27867e0
PR Gates / decisions lifecycle (pull_request) Successful in 23s
Review verdict / Set review-verdict status (pull_request) Successful in 24s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m24s
PR Gates / Script tests (pytest) (pull_request) Failing after 13m19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The review's headline finding was in my prose, not my code: spa-conventions and the round-8
commit both claimed an unbindable id "surfaces as 'no selection' with Save disabled" and that
regressions "assert zero writes are reachable". True on RerunCollections and FillerPresets.
False on PlaylistsScreen in all three respects — `draftFromItem` nulled the id but KEPT
`selectedName`, so the row read "Cool Movie" over a null draft; Save had no selection check;
and clicking it did issue the PUT with `mediaItemId: null`. Only the server's
`ReplacePlaylistItemsHandler` 422 stood there, and the DB would have persisted it
(`PlaylistItemConfiguration` marks all four FKs `IsRequired(false)`).

Rather than weaken the claim, made it true: a dropped id now clears its label, and Save is
gated on every item having a selection, with a visible count as the reason.

`playlistGroupId` was the same class on the same screen — seeded from the wire into
`AddPlaylistDialog`, re-parsed with a bare `Number()`, and POSTed as an entity reference — so
"every path by which an id from the wire becomes editor state" was not literally true. Now
filtered from the group options and normalized on submit.

Added `selectionId.test.ts`. The predicate had become the single point of failure for eleven
call sites across three screens while being exercised only indirectly; nothing pinned the
inclusive endpoints, so a `>` for `>=` slip passed the entire suite. Verified by mutating
each comparison. Also documented why `0` and negatives are accepted — the contract is
bindability, not existence — because every other id check in this repo uses `id > 0` and the
next reader would otherwise "fix" the inconsistency.

Two of my assertions were vacuous, the eighth of that shape on this branch: one clicked a
button it had just asserted disabled (a restatement of `toBeDisabled()`), and one asserted a
POST count on a path that never attempted a save. The first is deleted; the second now
actually attempts the write, which makes it fail against the unguarded parent.

Corrected claim: all five round-8 regressions do fail against their parent, but on their
load-bearing assertions (`getByText('A selection is required')`,
`queryByText('Bogus Collection')`) — not on the write-count ones, which were passengers.

Follow-up filed as #677 (ScheduleItemInspector's unguarded ingresses; list-backed pickers
dropping malformed options silently).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 39c4e8df0a fix(651): review round 8 — put the selection-id predicate at the boundary, not the site
Round 7 added the int32 check inside `isSearchPickerOption` — the place the defect was found
— which left every other door into editor state open. A malformed successful response
carrying `1.5` or `2147483648` still entered `draft` through list-backed options and through
the selection restored from the detail read, keeping Save enabled and sending a value the API
cannot bind, while the identical value arriving via SearchPicker was correctly rejected.

The predicate now lives once, in `web/src/api/selectionId.ts`, and sits on every path by
which an id from the wire becomes editor state. The class crosses all three screens, not just
the one the finding named, so all three are covered:
- RerunCollectionsScreen: `toPickerOptions` (3 list branches) + `draftFromRerun`
- PlaylistsScreen: `toPickerOptions` (3 list branches) + `draftFromItem` (4 id fields)
- FillerPresetsScreen: `draftFromPreset` (5 id fields) + the collection-family browse options
- pickers.tsx: `isSearchPickerOption` now delegates rather than carrying its own copy

An unbindable id is treated as ABSENT, never coerced — rounding 1.5 to 1 would submit a
DIFFERENT record — so it surfaces as "no selection" with Save disabled and a visible reason;
an option that cannot be selected safely is dropped rather than rendered. Five regressions
assert zero writes are reachable via each previously-unguarded path.

Also corrects two of my own test descriptions, per the review: the padded-ETag test is a
regression guard rather than a round-7 defect demonstration (Headers strips outer whitespace
before the app sees it), and the late-settlement test guards the abort/race COMPOSITION —
what it actually fails is an abort-only implementation whose fetch ignores its signal, which
is why its stub ignores `init.signal`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 e605e4006a fix(651): review round 7 — treat "no usable token" as one class, not three values
HIGH: the fail-closed gate rejected `null` but not the adjacent values. `Headers.get('ETag')`
returns `''` for an empty or whitespace-only header, which PASSED the gate and produced an
editable draft; `updateRerunCollection`'s `ifMatch ? … : undefined` then dropped the empty
string as falsy and sent no `If-Match`, silently overwriting a collaborator — the exact class
the gate exists to make unreachable, reached through the value next door. Absent, empty and
whitespace are now one case ("no usable concurrency token"), normalized by a single
`usableEtag` helper that returns the TRIMMED token or null, so `etagRef` can only ever hold
something that will actually be sent. Tested across four blank shapes asserting zero PUTs are
reachable, plus a padded ETag that must be trimmed and USED rather than dropped.

MEDIUM: the deadline abandoned the wait without cancelling the work, so each Retry stacked
another live connection. It now aborts via an AbortSignal (threaded through
`getRerunCollectionWithMeta`) AND clears its timer on settlement and unmount. Both halves are
kept deliberately: aborting cancels the work, while the rejected race stops the UI waiting
even if the abort never propagates — cancellation and giving-up are not the same guarantee,
which the late-settlement test proves by using a stub that ignores its signal.

MEDIUM: `Number.isFinite` accepted ids the API cannot bind — `1.5` and values outside int32
rendered, committed through `onSelect`, and would fail server-side on `selectedId`. Validated
as an int32 integer.

MEDIUM: a malformed or failed page was reported as "No matches", telling the user the library
is empty when the request actually failed and giving no hint that reopening retries. Failures
now surface as a distinct alert.

MEDIUM: `spa-conventions.md` still mandated the deleted "never let a refresh clear an id it
failed to name" guard and said "the client guard stays" — contradicting the initialize-once
bullet 20 lines below it. Rewritten to state that the guard is gone and must not be rebuilt,
with the reason (it only ever preserved a list-seeded value that is null in production).
Grepping the DELETED TERMS across all docs — the lesson from round 6's stale `rule:` — also
caught two stale `signals:` tokens on the record that the rule fix had missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 a973fc48e2 fix(651): review round 6 — fail closed on a missing ETag; validate elements, not containers
HIGH: "the draft is only created alongside the ETag" was not the invariant I claimed. The
response can OMIT the header, in which case the draft was still created and the subsequent
PUT carried no `If-Match` — the round-4 force-write hole in a new form. The editor now fails
closed: no concurrency token, no editable draft (error + Retry/Back). Six tests were leaning
on ETag-less detail mocks, which is exactly how this stayed invisible; every single-record
GET mock now goes through a `detailResponse` helper that always sets one, and the absent
case is tested explicitly — asserting zero PUTs are even reachable.

MEDIUM: the detail GET had neither deadline nor recovery affordance, so a never-settling
request left a bare spinner with no way out. It is now raced against a 15s deadline, the
loading view carries a Back control, and the error view offers Retry.

MEDIUM: the malformed-body guard checked the container, not the elements. `[null]` passes
`Array.isArray`, reaches `setResults`, and throws on `option.id` during render; a wrong-typed
`id` would commit an invalid value through `onSelect`. Each element is now validated, and a
malformed payload is treated as a failed attempt so it stays retryable rather than cached.

MEDIUM: the decision record's `rule:` — the authoritative string, copied verbatim into the
catalog that is the documented entry point — still mandated the machinery round 5 deleted:
touched-field hydration, `replaceDraft`, conflict reconciliation. Anyone following it would
have rebuilt the rejected design. Rewritten to the initialize-once policy and the catalog
regenerated; historical prose no longer says `replaceDraft` "is now" separate.

MEDIUM: the replacement Reload test resolved its second GET immediately and returned a
non-null selection, so it observed neither a pending reload nor the dirty-selection discard —
removing `setDraft(null)` could leave it green. It now holds the reload open, asserts the
form is ABSENT while pending, and returns `selectedId: null` to pin the case round 3 showed
could resurrect a dirty id over a collaborator's change.

Checklist item taken from this: when a mechanism is deleted, the decision record's `rule:` is
the single most likely thing left stale, and the one string that propagates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 78cd9e0ebf fix(651): review round 5 — delete the draft-reconciliation layer instead of extending it
Took the coordinator's proposal. Rounds 2-4 built and rebuilt a layer that merged a late
detail response into a draft the user was already editing; it produced a HIGH finding in
three consecutive rounds, three of them cross-user lost updates. Round 5's finding was
unfixable in kind: `identityConflicts` had no immutable baseline, so it could not tell "the
user changed this" from "the server changed this" — giving both a missed conflict (same
type, different id: v2 ETag installed over the user's id, third lost update) and a false one
(local type switch: dialog opens spuriously, and "Keep editing" leaves `etagRef` null so the
next PUT is a silent FORCE-WRITE).

The race is removed rather than refereed. `RerunCollectionEditor` now initializes its draft
EXACTLY ONCE from the detail GET and withholds the form until it lands; conflicts are
detected at save time by the `If-Match` -> 412 -> Reload path that already existed. Deleted:
`touchedRef`, `hydrateDraft`, `hydrateSelection`, `hydrateIdentity`, `identityOf`,
`identityConflicts`, `replaceDraft`, `replacePending` and the hydrate/replace load mode.
Reload simply sets the draft back to null and re-runs the same load, so the form is
unmounted while the replacement is in flight — round 4's MEDIUM-4 becomes structural.

Two facts make this lossless rather than a regression. The list row could never have helped:
`GetPagedRerunCollectionsHandler` applies ZERO `.Include()`s where
`GetRerunCollectionByIdHandler` applies fourteen, and both project through the same mapper,
so the list response is a strict SUBSET of the detail one — the id round 1 preserved from it
is null in production for every row (#671), and existed only in test fixtures. And
FillerPresetsScreen/PlaylistsScreen already worked this way; RerunCollections was the
outlier, which is why nearly every finding in rounds 3-5 traced to it.

The ETag is now written in the same callback that sets the draft, so `draft != null` implies
an ETag and a PUT without `If-Match` is unreachable by construction.

MEDIUM-2: a failed search retried every debounce forever — a fresh `{ok:false}` re-ran the
effect and the success guard declined it. `ok` (is the held answer authoritative) is now
separate from `attemptRef` (have we already tried this exact source+query); only an explicit
reopen/focus/edit re-arms a retry.

MEDIUM-3: a malformed 2xx body resolves as `undefined` (client.ts swallows the SyntaxError),
and `setResults(undefined)` threw on the next render. A non-array is now treated as a failed
attempt, and `search` is raced against a 10s deadline since a caller-supplied promise carries
no abort signal.

Two tests were hiding findings and are fixed: the failed-search test pressed Escape before
the unintended retry could fire, and the round-4 conflict test claimed to select id 9 while
firing the already-selected id 5. Fourteen tests of the deleted machinery are gone with it,
replaced by six asserting the new invariants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 f601d957a6 fix(651): review round 4 — an id never travels without its namespace
Round 4's through-line: rounds 2-3 made HYDRATION treat {collectionType, selectedId,
selectedName} as one value, but the fix was applied to the structure that was named rather
than to every structure that carries an id. Three more instances of the same class, plus
two independent findings.

HIGH-1 + HIGH-2 (one change, per the structural directive): every result, option and
cached result set now carries its SOURCE, and identity is compared as (type, id).
- `SearchPicker` takes a REQUIRED `source` prop and caches results against (source, query),
  not query text. Keying on text let the round-3 re-query guard SUPPRESS the new source's
  request and leave the previous namespace's hit clickable under the new label — a
  Collection id stored as a SmartCollection id. Results from another source are now hidden
  outright rather than dimmed: they are not stale, they are wrong. Required rather than
  defaulted, because a default would silently opt every caller out.
- `pickerFor` tags list-backed options with the type they were loaded for, on both
  RerunCollectionsScreen and PlaylistsScreen, so the previous type's rows stop being
  selectable the instant the active type changes rather than lingering through the
  replacement load.

HIGH-3: a touched identity contradicting the server's type is a CONFLICT. Pinning the
user's edit was right; adopting the response's newest ETag alongside it authorized a Save
that silently overwrote the collaborator's type change with no 412. The conflict dialog is
raised and the stale ETag kept, so even a forced Save 412s. With round 3's Reload defect
this is the second cross-user lost update, so it is recorded as a category: never install a
save-authorizing ETag over a local edit the server contradicts.

MEDIUM-4: the editor is inert while a Reload is pending. The dialog closes immediately, so
an edit typed before the replacement landed was silently erased along with the touched set
that protected it.

MEDIUM-5: cached search provenance records `ok`, so a transient 500 is retried instead of
being cached as an authoritative "No matches" that reopening can never clear.

Also: `npx tsc --noEmit` typechecks NOTHING in web/ — the root tsconfig is solution-style
("files": [] + references), so it resolves to zero inputs and exits 0. The real gate, and
what CI runs, is `npm run typecheck` (tsc -b). Verified by planting a deliberate type error:
--noEmit stayed green, -b caught it. Running the real gate surfaced four genuine errors in
tests written earlier this branch (a missing required prop and three `never has no call
signatures` from closure-assigned mock variables), fixed with the repo's existing
holder-object pattern rather than casts. Recorded in spa-conventions so the next session
doesn't repeat it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 5b0ba08aab fix(651): review round 3 — cover the class, not the named instance
Round 3 found four defects that are all one mistake: each earlier fix enumerated one
instance of a class instead of covering the class. Fixed by asking what else is in each
class rather than patching the named case.

CRITICAL — cross-user lost update. Conflict "Reload" ran through the refresh policy with a
touched-set reset. Because a reloaded RemoteStream reports `selectedId: null` under the
#671 server gap, the keep-ours-when-the-id-is-missing fallback restored the user's DIRTY
selection, the fresh ETag was installed, and the next Save silently overwrote the
collaborator's change — with the very edits the user had explicitly asked to discard.
"Keep ours when the server omits the id" is a REFRESH policy; a reload is a REPLACE.
`replaceDraft` is now a separate function and the mode travels with the load, so the two
cannot be confused at the call site.

HIGH — `collectionType` and the selection still hydrated apart. `collectionType` says which
table an id indexes, so it is part of the same indivisible value as the id and its name;
splitting it is the identical bug to splitting id from name. A record retyped server-side
mid-load hydrated the new type while retaining the old id, displaying and saving a
Collection id as a RemoteStream id. All three fields now resolve as one `Identity` unit:
either half touched pins the whole thing, a differing type takes the response's unit whole
(null selection included), and only once both sides agree on the type does the id/name rule
apply.

HIGH — stale results were still committable by pointer. Enter was gated and `onClick` was
not: the same defect in another modality. The guard moved into the single `choose()` sink
so every commit path is covered, including any added later, and a superseded list is now
genuinely inert (`aria-disabled` + dimmed) rather than looking normal and silently
no-opping.

MEDIUM — reopening after Escape re-queried an already-current result set; the duplicate
response reset the cursor the user had since moved, leaving Enter doing nothing. The effect
now skips the search when the cached results match the trimmed query, and reopening places
the cursor per the ARIA APG instead of swallowing the keypress.

Also corrects an overstated justification in FillerPresetsScreen: the render-time id check
was dropped because there is no reachable path TODAY, not because "every writer sets both"
— the initial load writes the id alone, and a stale resolver can repopulate the label after
a clear. Same enumeration error as the findings above; the comment now says what is
actually true and what to do if a path appears.

Note: an apostrophe I introduced inside the single-quoted `rule:` scalar broke PyYAML while
`decisions_validate.py` (hand parser) stayed green — the same trap class as the unquoted
`#`, caught only by scripts/tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 ba52219a9a fix(651): review round 2 — hydrate untouched fields, never merge a selection field-by-field
Re-review of a04d9f0b came back BLOCKED: the F1 merge fixed a visible data loss and
introduced a silent one. Four blockers, all web-only.

BLOCKER 1: `selectedId` and `selectedName` were coalesced independently, but they are one
value. Against a Song response (id resolves, name does not), a user selecting a different
song while the detail GET was in flight got the NEW name paired with the STORED id — chip
read "New Song", Save wrote 42, no error and no visual cue. Strictly worse than the
original defect, which at least cleared the field visibly. The same merge resurrected a
deliberate clear and reverted a type switch.

Replaced with two rules. The id/name pair resolves atomically (no id in the response ->
keep ours whole; same id -> its name may fill ours in; different id -> take theirs whole).
And hydration loses every race against the user: a `touchedRef`, fed by a single `edit()`
funnel so "touched" cannot drift from "changed", limits the refresh to fields the user has
not edited; an explicit conflict-reload clears it, since discarding local edits is its
entire purpose. Three interleaving tests hold the detail response open, act as the user,
then release it.

BLOCKER 2: Enter could commit a result from the previous query — highlight Alpha for "Al",
retype "Be", press Enter before the debounce. The highlight now drops on input change
rather than when the next response happens to arrive, and every keyboard action is gated
on the results matching what is typed. The stale list stays visible (hiding it flickers on
each keystroke), it just stops being actionable.

BLOCKER 3: Escape closed the popup while focus stayed in the input, where `onFocus` can
never re-arm it — the picker was dead until the user blurred and refocused. Typing and
ArrowDown now both reopen it.

BLOCKER 4: the LCG boundary test recomputed the divisor instead of exercising `lcg`, so
all three tests passed with the old `/ 0xffffffff`. Since the recurrence is a bijection
mod 2^32, the seed whose first step lands on 0xffffffff is solvable exactly (653637408);
the tests now drive the real generator into that state.

Also: the rerun #id-degrade and re-save tests did not await the refresh, so they were
satisfiable from the initial draft; they now await it and re-read live textContent. The
Episode/MusicVideo cases are relabelled as error-path guards — a 500 never reaches the
hydrate. FillerPresets' render-time id comparison is dropped as unreachable (every writer
sets label and id together, and the one async writer refuses a mismatched id); an
unreachable guard is an untested one.

#671 is referenced from spa-conventions §3b as the server-side root cause the client guard
defends against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 e7e425fa25 fix(651): review round 1 — never clear an unnamed id, complete the Lucene escaping, keyboard-operable picker
Cold cross-family review of 57aefcdf. Six findings, all web-only.

F1 (HIGH, data-loss shaped): RerunCollectionsController.ProjectToResponseModel derives
BOTH selectedId and selectedName from the same eager-loaded navigation, and
GetRerunCollectionByIdHandler loads media metadata only for Show/Season/Artist/Movie
while MediaCollections/Mapper maps RemoteStream through `_ => null`. So opening a
RemoteStream rerun collection returned HTTP 200 with a null selection and the edit-load
refresh CLEARED a stored id, leaving Save permanently disabled. The refresh now merges
instead of replacing, so no path can clear an id it merely failed to name; the label
degrades to `#id`. Covered per affected type — RemoteStream, Episode, MusicVideo, Song,
OtherVideo, Image — plus a re-save assertion. The read-model gaps themselves are
server-side and are NOT touched here.

F2: `&` and `|` were missing from the escaped set, so `Rock && Roll` compiled with the
boolean operator live. Pre-existing in Auto-Tune's original helper, but propagated to
three more pickers — and now fixed for Auto-Tune too, since the helper is shared. The
test that claimed to cover "every Lucene special" carried its own hand-copied sample and
could not see its own omissions; it is now driven per-character off an exported
LIBRARY_PICKER_LUCENE_SPECIALS.

F3: a slow edit-load name resolution could relabel a newer selection. The label is now
keyed to the id it was resolved for AND refuses to overwrite a label naming a different
id — keying the render alone stops the mislabelling but discards the correct new label.

F4: searchLibraryPickerOptions clamps pageSize instead of merely defaulting it. A bound a
caller can exceed is not a bound.

F6: replacing a native <select> with an input+listbox dropped keyboard operability. Full
ARIA combobox pattern added — role/aria-expanded/aria-controls/aria-autocomplete,
Arrow/Home/End over aria-activedescendant, Enter to commit, Escape to dismiss, options as
non-tab-stops, cursor reset on each new result set.

F7: both is-mounted tests were unsound. React 19 no longer warns on setState-after-unmount
and an unmounted tree renders nothing either way, so the DOM assertion could not fail; the
hook re-arm test used rerender rather than an effect cleanup. Now: a hook-module mock
proving SearchPicker actually reads the guard and sees false, and a StrictMode
double-invoke for the re-arm. Both verified by removing the mechanism and watching them
fail. Same for the LCG divisor, which now has a direct boundary test.

F5 (FillerPresets collection-family names) is filed as #670, not fixed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 04:25:14 +02:00
timothyandClaude Opus 5 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>
2026-07-27 04:25:14 +02:00
timothy 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.
2026-07-27 03:10:35 +02:00
timothy cd6f36185c feat(578): artist typeahead covers music-video and song credits; album_artist stops 404ing
`GET /api/v1/search/fields/{name}/values` sourced `artist` from `ArtistMetadata.Title` only —
entity artists — so the free-text credits that `LuceneSearchIndex` also writes to the `artist`
field (`MusicVideoArtist.Name`, `SongMetadata.Artists`) produced no suggestions, and
`album_artist` 404'd outright.

`MusicVideoArtist` turned out to be a real entity table, so it just joins the existing server-side
pipeline as a `Concat` — one bounded `UNION ALL` + `LOWER(...) LIKE ... LIMIT` on both providers.

`SongMetadata.Artists`/`AlbumArtists` are the hard case: EF 9 maps them as primitive collections
(one JSON array per row in a single column), and neither provider can project the elements
server-side — SQLite needs the SQL APPLY operator it lacks, Pomelo MySQL 9.0.0 has no
primitive-collection support at all. Both failures are now pinned by a test, so a provider upgrade
that fixes them shows up as a red rather than as a stale workaround. For those columns the handler
pre-filters on the raw JSON (`LOWER(col) LIKE '%"<encoded-prefix>%' ESCAPE '/'` — a deliberate
superset, since it matches a row and not an element), caps the rows at 1000 with an `ORDER BY` that
makes the truncation deterministic, then splits and exact-filters in memory.

Provider portability is by construction rather than by trusting `LIKE`: the prefix is JSON-encoded
before matching (which is how it is stored, and which makes the pattern pure ASCII, so SQLite's
ASCII-only `lower()` and MySQL's Unicode-aware `LOWER()` agree) and lowercased in C#, so the match
is correct under a case-sensitive MySQL collation as well as a case-insensitive one. The escape
character is `/`, never `\`: `ESCAPE '\'` is not a portable SQL literal.

An empty `q` stays supported for these fields — the row cap already bounds it, and a non-empty-`q`
carve-out would make one group of fields behave differently for the same client code.

Docs: new `api.search-field-values-list-columns` record (additive sibling — the base record's rule
still reads true; only its body's "intentionally-uncovered gap" claim was stale), plus
`api-conventions.md`, `spa-conventions.md` §12 and the regenerated `v1.json`.

Decisions-Edit: yes
2026-07-27 03:10:35 +02:00
timothy daedf003e5 fix(644): round-3 review — split truncated/incomplete picker hints, F2 out-of-list gaps, F3 abort warns, F4 aria wiring, F5 FillerPresetsScreen tests
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 24s
PR Gates / decisions lifecycle (pull_request) Successful in 28s
Review verdict / Set review-verdict status (pull_request) Successful in 6s
PR Gates / Script tests (pytest) (pull_request) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m24s
review-verdict/h10 Review-verdict: MERGEABLE @ daedf00
Addresses the round-3 cold re-review's five low-severity findings on #644's client-side paging fix:

- F1: `loadPickerOptions` (RerunCollectionsScreen, PlaylistsScreen) returned one `truncated:
  boolean` for two different conditions — a real Class B cap hit vs an unconverged Class A
  `loadAllPages` load — so an incomplete multi-collection load rendered the self-contradictory
  "Showing the first 47 of 47 — use search to narrow." Replaced with a `hint: 'incomplete' | 'none'
  | 'truncated'` discriminator and distinct copy per value; 'incomplete' matches the wording already
  used by the Class A list-load warn Badge.
- F2: mirrored the out-of-list current-selection injection (RerunCollectionsScreen/PlaylistsScreen's
  `selectedInList` prepend) into FillerPresetsScreen and ScheduleItemInspector's rerun-collection
  picker, so an id outside the loaded page still renders as selected instead of misrepresenting the
  stored value as "(none)".
- F3: gated the `console.warn` on an incomplete Class A load with `!signal?.aborted` in the `multi`
  branches (RerunCollectionsScreen, PlaylistsScreen) and SchedulesScreen.loadAllRerunCollections, so
  a superseded/aborted load (Retry, or a type switch mid-load) no longer logs a false warning.
- F4: added `Select`'s `ariaDescribedBy` prop and wired the truncation/incomplete hint span to it via
  `useId()` in RerunCollectionsScreen and PlaylistsScreen, so screen readers announce the hint
  (FillerPresetsScreen already routed it through `Row help=`).
- F5: added FillerPresetsScreen.test.tsx (previously untested) covering the Class B single-request
  guarantee, the truncation hint's totalCount>100/<=100 boundary, and the F2 injection; added the
  two assertions the re-review found missing anywhere in the suite — the Class A `incomplete` warn
  Badge actually rendering, and a screen-level seqRef stale-overwrite race — to
  RerunCollectionsScreen.test.tsx.

Updates docs/spa-conventions.md §3b and the
spa.list-completeness-vs-bounded-pickers decision record to describe the hint discriminator.

Decisions-Edit: yes
2026-07-26 21:29:53 +02:00
timothyandClaude Opus 5 94182cdd53 fix(644): split loadAllPages by list class; bound media-library pickers to one page
Cold adversarial review of fe342a6a found the blanket loadAllPages-everywhere fix
dangerous for the three getLibraryBrowseItems pickers (RerunCollectionsScreen,
PlaylistsScreen, FillerPresetsScreen): paging Episode/Song/Image/Movie/MusicVideo
to completeness can mean ~200 serial requests against a 20k-row library, each more
expensive than the last, to populate a <select> with thousands of <option> nodes.

- Class A (bounded-by-construction lists: rerun collections, multi-collections,
  playlists) keep loadAllPages. Class B (media-library pickers) now fetch ONE
  bounded page and surface truncation via a `Showing the first N of M` hint wired
  to the real totalCount, instead of paging to completeness or truncating silently.
- loadAllPages: reports `{ items, complete }` instead of just `T[]` so a caller
  can no longer mistake a defensive empty-page break for a full list (F4); accepts
  an optional AbortSignal so a superseded loop stops issuing further page requests
  (F2); baseParams is now required via a conditional rest-tuple whenever the
  loader's params type has a field beyond pageNum/pageSize (F6); pushes into the
  accumulator instead of re-spreading it every page (F7).
- MultiCollectionsScreen/RerunCollectionsScreen/SchedulesScreen: add a seqRef +
  AbortController guard around the list/bootstrap loads so a stale loadAllPages
  loop can't resolve after a newer one and resurrect deleted rows (F3); log and
  surface an incomplete load rather than rendering it as whole.
- docs/spa-conventions.md §3b rewritten for the Class A / Class B split; new
  decision record docs/decisions/records/spa/list-completeness-vs-bounded-pickers.md
  (spa.list-completeness-vs-bounded-pickers), catalog regenerated.
- Tests: paging.test.ts covers null/undefined totalCount, null page, a
  short-but-non-empty page, a page-2 rejection, the complete:false flag, and
  cancellation (asserting fetch call COUNT stays put after abort), plus a
  compile-time @ts-expect-error pinning the F6 typing fix. Screen-level tests
  pin a real second HTTP request for a >100-item Class A list
  (MultiCollectionsScreen) and exactly one /library/browse request plus the
  truncation hint for a Class B picker (RerunCollectionsScreen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:04:55 +02:00
timothy fe342a6a0b fix(644): page SPA list loaders to completeness instead of inflating pageSize
Seven call sites (rerun-collections, multi-collections, library/browse) requested
pageSize far above each endpoint's server-side MaxPageSize=100 clamp and took the
single response page as the whole list, so rows past 100 silently vanished with no
error or truncation indicator.

Extract the loadAllRerunCollections pattern from SchedulesScreen (#634) into a
shared, generic web/src/api/paging.ts::loadAllPages helper that pages against
totalCount with an empty-page defensive break, and refactor SchedulesScreen plus
the seven over-cap call sites in RerunCollectionsScreen, MultiCollectionsScreen,
PlaylistsScreen, and FillerPresetsScreen to use it. Server caps are unchanged
(api.search-allitems-paging precedent: client pages, server stays bounded).

Document the convention in docs/spa-conventions.md §3b.
2026-07-26 20:32:06 +02:00
timothy bcff7c686a Merge pull request 'feat(440): per-source weight steppers + exclude/add-untagged in the Auto-Tune DetailPanel' (#589) from feat/440-autotune-weights into main
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
2026-07-25 10:06:02 +00:00
timothy 22e89fb9a4 docs(440): correct two misleading text defects found in review
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Text-only follow-up; no behavior change (14/14 tests, lint clean, tsc clean).

- AutoTuneScreen.tsx: the addSource comment claimed picking a hit that is
  already a base member makes "the existing row read as customised". It does
  not — patchSource(id, {}) materializes a DEFAULT draft, sourceCustomized is
  false for it, and sourcesRequest omits it, so the pick is a payload no-op
  whose only visible effect is the query clearing. Comment now states that.
- spa-conventions.md §11: said WEIGHT_MIN/WEIGHT_MAX are "the same const pair
  the multi-collection editor uses". Same VALUES, separate screen-local consts
  — there is no shared module. The old wording invited a future reader to
  assume a shared seam that does not exist.

Both were nits in the independent review of d3c89d87 (verdict
MERGEABLE-WITH-NITS). Fixed rather than deferred because a comment that states
the opposite of the code, and a doc that implies a nonexistent shared const,
are exactly the kind of thing the next session reads and trusts.

Remaining review nits deferred to a follow-up issue: exporting compile.ts's
Lucene escaper instead of duplicating it, an exclude-all warning, and >50-source
axis handling.
2026-07-25 11:30:36 +02:00
timothy d3c89d87aa feat(440): per-source weight steppers + exclude/add-untagged in Auto-Tune DetailPanel
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m48s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Wires the Auto-Tune DetailPanel's Content-sources pane to #425's backend: each
member row gains a 1..1000 weight stepper and an include/exclude toggle, and a
library typeahead adds a source that isn't in the axis's base set. Edits
accumulate in the screen's per-channel draft (the existing §8/§11 guard covers
them) and are flushed as the create request's `sources` array.

- Only genuinely customised rows are sent, mirroring the server's own
  `customized` predicate — an all-default array is a backend no-op, so the field
  is omitted entirely and the channel keeps the cheaper fair-share shape.
- Weights are clamped to 1..1000 on blur and again at save, so an out-of-range
  value never reaches the server as a raw 400 (spa-conventions §4a).
- The add-untagged picker compiles typed text to `title:*…*` rather than
  forwarding raw Lucene: the index's default field does not match bare title
  words, so a raw forward would silently find nothing.
- Removes the read-only #425 hint.

Docs: spa-conventions.md §11 records the per-source correction-row convention.

fixes #440
2026-07-25 11:15:20 +02:00
timothy 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
2026-07-25 11:01:35 +02:00
timothy be1070de51 Merge pull request 'feat(437): inline RuleBuilder smart-query authoring in Channel Builder' (#579) from feat/437-rulebuilder-channelbuilder into main
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 15m56s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 16m3s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20m47s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m29s
2026-07-23 21:17:41 +00:00
timothyandtimothy 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>
2026-07-23 20:45:19 +00:00
timothyandClaude Opus 4.8 5fba6187ce feat(437): inline RuleBuilder smart-query authoring in Channel Builder
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m0s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m38s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adopt the reusable RuleBuilder (#176) for inline query authoring in the Channel
Builder (/app/new-channel). Extract CollectionsScreen's smart-collection dialog
into a shared, self-contained component (SmartCollectionDialog) and consume it in
both screens; the Channel Builder's Collections source gains a "New smart query"
action that persists the authored query as a real SmartCollection and adds it to
the lineup by smartCollectionId. Pure frontend — no REST/MCP surface change (the
MCP already exposes ersatztv_create_smart_collection).

The Auto-Tune half of #437 is a different primitive (group-by, not single-query
filtering) and a backend epic; it is designed separately in
docs/superpowers/specs/2026-07-23-auto-tune-arbitrary-field-design.md and filed as
its own issue rather than wired here.

Verification: web typecheck + lint clean; full vitest suite green (981, incl. a new
inline-smart-query test); cold-context review clean; live-E2E on a real instance
(query authored in the SPA persisted as SmartCollection "Action Picks" and added to
the lineup, 0 console errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:08:41 +02:00
timothyandClaude Opus 4.8 c2fb62dc88 docs(434,435,438): decisions records, spa-conventions §12, api-conventions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:44:15 +02:00
timothyandClaude Opus 4.8 f8ae4d62ab fix(552): mint a short-lived JWT so the SPA reaches /iptv/* under JWT auth
Under a JWT-enabled deployment (JWT:IssuerSigningKey set), /iptv/* is gated by
ConditionalIptvAuthorizeFilter and the "jwt" scheme does not accept the SPA's
ctv-session cookie, and nothing minted a JWT for the browser. So the #60 channel
preview was declared Unavailable and could not run at all.

Add GET /api/v1/auth/iptv-token (session-gated, on the [IgnoreApi] AuthController):
mints a short-lived global token via JwtHelper.GenerateBrowserToken (60 min default,
JWT:BrowserTokenLifetimeMinutes override), 204 when JWT is disabled. The SPA's new
withIptvToken(url) helper appends it as ?access_token= to the manifest URL (a no-op
when JWT is off), used by the channel-preview panel and the troubleshooting screen.
Mapper.GetPreview drops its iptvJwtEnabled -> Unavailable guard; preview is now
JWT-agnostic.

Live-E2E under JWT: /iptv manifest 401s without a token and passes with a valid one
(garbage token -> 401); token endpoint 401s anonymous, mints with a session.

Honest finding: the issue's point 2 (troubleshooting screen broken under JWT) does
not reproduce -- its live.m3u8 is static-served (UseStaticFiles at /iptv/session),
outside the JWT filter, so it was never gated. The withIptvToken call there is a
harmless defensive no-op.

Docs: security.iptv-browser-token (api-auth-security.md), amended
api.channel-preview-capability, spa-conventions §5b. No OpenAPI change (IgnoreApi +
unchanged Preview schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:21:05 +02:00
timothy 6154aaebb2 fix(60): onPlaying reflects real playback, panel is styled, availability is type-safe
- HlsPlayer: drive onPlaying from the <video> element's own `playing` event on
  BOTH the hls.js and Safari-native paths instead of MANIFEST_PARSED, which
  fires before any media has decoded (an HttpLiveStreamingDirect manifest
  always parses, even over a black video). MANIFEST_PARSED now only kicks
  play(). Restore `void video.play().catch(...)` at both call sites and stub
  HTMLMediaElement.prototype.play in setupTests.ts instead, so the `?.` that
  existed only to survive jsdom is gone from production code.
- HlsPlayer.test.tsx: assert the auto-recovery guard against hls.js's own
  startLoad()/recoverMediaError(), not just loadSource's call count.
- ChannelPreviewPanel: reuse existing ctv-* classes (ctv-channels-error,
  ctv-settings-warn-callout, ctv-detail-actions, ctv-detail-infogrid) instead
  of five undefined ctv-preview-* classes; add the two genuinely new rules
  (ctv-preview-video max-width, spacing tweaks) to shell.css.
- Add an exported ChannelPreviewAvailability union (web/src/api/channels.ts)
  so a typo like 'ForcedHLSOnly' fails to compile instead of silently
  disabling a branch forever; use it in ChannelPreviewPanel's prop type and
  at the ChannelsScreen comparison sites.
2026-07-21 23:13:27 +02:00
timothyandClaude Opus 4.8 4c0f80cfe1 fix(chicorytv): test render-phase channel-switch reset; add HlsPlayer onPlaying
Two review findings on the channel preview panel (#60):
- ChannelPreviewPanel's synchronous render-phase reset (started/state/error/playToken
  on channel.id change) was reachable in prod (the channels screen keeps one panel
  mounted and swaps the channel prop) but untested. Added tests proving no auto-start
  switching into a ForcedHlsOnly channel, error clearing on switch between Available
  channels, and no playToken leak across the switch.
- PlaybackState included 'playing' but nothing ever set it. Added HlsPlayer onPlaying,
  fired from Hls.Events.MANIFEST_PARSED and the native-HLS <video> 'playing' event,
  mirroring onError's optional/stable-callback contract; ChannelPreviewPanel now wires
  it to reach 'playing'. Also fixed a latent bug hit while exercising this path:
  video.play().catch(...) assumed a Promise, but jsdom's play() returns undefined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:13:08 +02:00
timothy e848fb30b9 feat(60): report fatal HLS errors from HlsPlayer 2026-07-21 23:13:08 +02:00
timothyandClaude Opus 4.8 928784ba48 fix(135): from-lineup advanced overrides can express "clear to none"
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m44s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
CreateChannelFromLineupHandler resolved every advanced override with
advanced.X ?? template.X, so null always meant INHERIT and a channel could
not drop a template-set watermark / filler / preferred language. Add an
optional typed `clear` enum list to CreateChannelFromLineupAdvancedOptions:
omitted/null still inherits (byte-stable for existing clients), a field named
in `clear` is forced to none. Set+clear of the same field is a 422.

The enum (CreateChannelFromLineupClearField) lives in ErsatzTV.Core so the
OpenAPI string-enum scan renders it as a string enum, matching every sibling
advanced-options enum. Handler resolves clearable fields once via
ResolveClearable and validates set/clear conflicts via ValidateClear;
reference validation skips existence checks for cleared (null) refs.

SPA: the shared advancedOptions model re-adds a real "None" option to the five
id selects (watermark + fillers) in both the Channel Builder and the Auto-Tune
DetailPanel, routed through a CLEAR overrides sentinel that applyOverridesToRequest
folds into advanced.clear (never leaking onto the wire as a field value). The
backend enum also covers the preferred audio/subtitle language strings for
machine clients; the SPA text inputs keep "empty = inherit" (tri-state deferred).

Docs: api-conventions.md §2, spa-conventions.md §11, decisions.md record
api.from-lineup-clear-to-none; v1.json + generated TS regenerated.

fixes #135

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:25:58 +02:00
timothyandClaude Opus 4.8 d639946b5c feat(404): weighted-distribution SPA — per-source weight inputs + WeightedShuffle order
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m39s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 24m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The UI half of #70 (backend + API shipped in PR #402). Pure SPA + docs — no new
endpoint/DTO (`weight` was already on MultiCollectionItemRequest/Response, and
`WeightedShuffle` already in the PlaybackOrder enum).

- Multi-collection editor (`/app/multi-collections`): per-source weight input
  (1..1000, mirroring the API validator), a computed % share (3:1 shows 75/25),
  and a "Reset to fair share" action (fair-share = all weights 1, decisions.md
  2026-07-17 — a reset, not a separate mode). Weight round-trips through the
  draft (read in itemsFromMultiCollection, written in toItemRequest) so the
  replace-all PUT never silently resets it.
- Classic schedule editor: `WeightedShuffle` offered as a Playback Order ONLY
  for MultiCollection sources (itemRules `MULTI_COLLECTION_ORDERS`) — it needs
  per-source weights and the write path rejects it elsewhere. Excluded from
  fillWithGroup like ShuffleInOrder (PlayoutBuilder schedules fill-groups
  per-group, incompatible with whole-collection weighted share).
- `Input` gained min/max/inputMode/onBlur passthroughs for bounded numeric
  fields (reusable by #425's weight UI); weight held as a string for smooth
  editing, clamped on blur and at save so an out-of-range value never 400s.
- Docs: domain-model + spa-conventions (replace-all round-trip trap, bounded
  numeric input pattern) + decisions.md entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:28:12 +02:00