Commit Graph
160 Commits
Author SHA1 Message Date
timothy 1c86a1c1fc fix(650): enforce single-flight in useLibraryBrowse; close scanner false negatives
Third cold cross-family review (BLOCKED) found the append/page-0-refresh
races were being fixed one interleaving at a time — round 1 fixed
page-0-settles-first, round 2's compare-and-set rollback fixed the
duplicate-append case but introduced a permanently-skipped page, and the
reviewer found the exact mirror of round 1's fix (page-1-settles-first,
erasing page 1 with no cursor reset). Direction from the review: stop
enumerating orderings, make the overlap structurally impossible.

SINGLE-FLIGHT (web/src/builder/libraryBrowse.ts): a new `busyRef` guard is
true from the moment ANY fetch (a page-0 refresh OR an append) for the
current query generation is issued until it settles. `loadMore` checks it
SYNCHRONOUSLY and returns immediately (ignored, not queued) if a fetch is
already in flight — including a page-0 refresh, not just a prior append, so
a "Load more" click that lands while a query change is still resolving is a
no-op rather than starting a second, overlapping request. With overlapping
fetches eliminated by construction, the append-failure rollback no longer
needs the round-2 compare-and-set: single-flight guarantees nothing else
could have moved `pageRef` since a given fetch started, so it now always
rolls back and retries the exact page that failed, unconditionally.

Visual feedback (the button showing loading/disabled during a page-0
refresh, not just an append) is set via `queueMicrotask(() => setLoadingMore
(true))` rather than a bare synchronous call in the generation-change effect
— `react-hooks/set-state-in-effect` flags the latter; a microtask-deferred
call resolves before any human-perceptible input, satisfies the lint rule
(the same reason `.then()` callbacks elsewhwere in this hook aren't flagged),
and keeps the actual correctness guarantee (the ref check) perfectly
synchronous regardless.

TESTS REWRITTEN, not just added — the round-2 "HIGH-2" hook test explicitly
asserted the NEXT request after a failed page 1 (following an overlapping
page 2 success) should be page 3, i.e. it blessed page 1's permanent loss.
Replaced with two hook-level tests: single-flight ignores a synchronous
double `loadMore()` call (only one fetch issued), and a failed page is
retried as the SAME page number. Replaced the round-2 component-level
"HIGH-1" test (which drove the now-impossible overlap through the DOM) with
one asserting the click during a pending page-0 refresh is ignored, and that
once free, the correct page-1-then-page-2 sequence completes with both
pages' rows present. Verified all three new/rewritten tests against the
prior committed hook (7b1ae48b0): the two single-flight-specific tests fail
as expected (`[0, 1]` requested when only `[0]` should have been); the
retry-semantics test happens to pass against 7b1ae48b0 too (compare-and-set
and unconditional rollback coincide in the non-overlapping case) but is kept
because it is the correct "retry as page 1, not page 3" pin the review asked
for, replacing the one that asserted the wrong thing.

SCANNER (pageSizeScan.ts) — closed three documented false-negative classes:
- Transparent TS wrappers around the initializer (`pageSize: 100 as const`,
  `100 satisfies number`, parenthesized) are now unwrapped before the
  NumericLiteral/Identifier check.
- Non-Identifier property names: a quoted string key (`'pageSize': 100`) or
  a statically-resolvable computed key (`['pageSize']: 100`) are now
  accepted; a computed key that isn't a literal correctly stays unresolved.
- `.mts`/`.cts` are no longer silently excluded from the guard's file
  discovery glob (tsconfig.app.json's `include` covers all of `src`; no such
  files exist in the repo today, but the glob shouldn't hide one if it ever
  does).
10 new fixture tests in pageSizeScan.test.ts pin each case (plus a rejection
test confirming a forwarded call wrapped in `as` still doesn't match, and
one confirming an unresolvable computed key stays unmatched).

TEST LABELLING: relabeled the URL/M-3 and `??`/M-4 fixtures as CONTRACT
fixtures rather than regression pins — a round-3 review found round 1's
plain literal regex already handled those two exact inputs correctly on its
own; only the combined multi-case fixture (and the string-contains-text,
template-interpolation, same-line-identity, JSX, and destructuring fixtures)
actually fail against round 1. Labeled the guard test's 4 tests as BASELINE
assertions (they all pass on clean b90f8a3b) rather than implying they prove
this round's specific fixes — pageSizeScan.test.ts's fixtures are what
actually regression-pin the scanner.

No server-side/C# change. Full local gate: lint clean, tsc clean, full
vitest run 110 files / 1063 tests passed (re-run twice, stable).
2026-07-27 01:29:15 +02:00
timothy ca99bedb1a fix(650): rewrite the pageSize guard on the TS compiler API; fix two append-ownership races
Second cold cross-family (Codex, BLOCKED) re-review of b90f8a3b found the
regex/bracket-tracking guard scanner still defeated in five ways, and two new
High-severity races introduced by the F3/F4 fixes. Addressed as a further
follow-up (b90f8a3b left untouched).

GUARD REWRITE (per the review's explicit direction — stop patching the regex,
use the compiler):

- New `web/src/api/pageSizeScan.ts`: `scanPageSizeSites` parses each file with
  `ts.createSourceFile` and walks the real AST for `pageSize`
  PropertyAssignment/ShorthandPropertyAssignment nodes inside an
  ObjectLiteralExpression. This eliminates categorically (not case-by-case):
    - M-3: comments and string/template CONTENTS are never revisited as code,
      so a `'https://...'` string can't be misread as an unterminated string
      that swallows the rest of the file.
    - M-4: an object literal nested in a ternary, `??`, or JSX expression
      container is still found — the walk visits every descendant node
      regardless of the syntactic context above the ObjectLiteralExpression.
    - M-5: template-literal interpolations are real AST children, not opaque
      text.
    - L-7: a type literal (`type P = { pageSize: 100 }`), an interface
      PropertySignature, and a destructuring ObjectBindingPattern (parameter
      or nested) are structurally different node kinds from
      ObjectLiteralExpression — excluded by kind, not by a
      preceding-character heuristic a stray `{`/`(`/`,` could fool.
  `getLineAndCharacterOfPosition` gives exact line+column (fixes M-6 identity
  granularity) instead of the prior line-only identity.
- `pageSizeCallSites.guard.test.ts` now imports the shared scanner; identity
  is `file:line:column:kind:value`, compared as a MULTISET (count, not
  membership) in both directions.
- Both directions (unregistered / stale) are computed and folded into ONE
  thrown Error so a failure always shows the complete picture in one run,
  addressing the line-churn "second direction never renders" concern.
- New `pageSizeScan.test.ts`: a FIXTURE test (inline source strings, no repo
  scan) pinning the exact discovered set for every case the review named —
  comment-in-string, string containing the literal text `pageSize: 100`,
  template interpolation, ternary, `??`, JSX container, same-line duplicates,
  parameter/nested destructuring, a type literal, an interface property, a
  forwarded call expression, a React dependency array. This is what actually
  protects the scanner going forward — the guard test alone only ever proved
  today's snapshot of real call sites, never the scanner's handling of input
  classes it hadn't happened to encounter yet.
- Re-verified both original plants (a duplicate at-cap call in an
  already-registered file, and a new file with both a literal and a
  shorthand site) against the rewritten scanner; both still fail with the
  new combined-direction message. Also verified a run with BOTH directions
  simultaneously non-empty renders both in one report.

HIGH-1 (ChannelBuilder.tsx useLibraryBrowse, now web/src/builder/libraryBrowse.ts):
`reqId` identifies a query GENERATION, not an individual fetch — a page-0
refresh and a "Load more" append can be outstanding simultaneously under the
same reqId (query changes while an append is in flight for the new
generation). Whichever settled first used to clear `loadingMore`, letting a
second click fire an out-of-order/duplicate page fetch. Fixed with a
per-fetch `fetchId` plus a `loadingFetchIdRef`/`loadingFetchReqIdRef` pair:
only the fetch that OWNS the currently-displayed spinner can clear it; a
same-generation page-0 refresh leaves a same-generation append's spinner
alone, while a page-0 refresh for a NEW generation still retires an
abandoned OLDER-generation append's spinner (preserving the original #650 F3
fix). Reproduced the exact interleaving from the review in a new test
(gate B's page-0 and page-1 fetches independently, click "Load more" while
B's page-0 is still in flight) and confirmed it fails without the fix
(button re-enables while the append is still pending).

HIGH-2 (same file): the append-failure rollback mutated whatever
`pageRef.current` currently held, rather than the specific page THIS fetch
requested — under an overlapping-append race, a later page's success
followed by an earlier page's failure could roll the cursor back past
already-appended progress, corrupting a retry into refetching a duplicate.
Fixed with a compare-and-set guard (`if (pageRef.current === pageNum)`) so
the rollback only fires when nothing has advanced the cursor since. Since
this overlap is UI-unreachable once HIGH-1's single-flight disabling is
wired up (verified empirically: two synchronous fireEvent.click calls in RTL
only produce one request, since act() flushes the disabling render between
them), the regression test drives `useLibraryBrowse` directly via
`renderHook` (now exported) to force the exact interleaving and confirms it
fails without the fix (page 2 gets duplicated, page 3 never requested).

Extracted `useLibraryBrowse` (plus `loadCollections`/`loadLibraryItems`/
`BrowseState`/the media-type const arrays) into a new non-JSX module
`web/src/builder/libraryBrowse.ts` — exporting a hook from a .tsx file
tripped `react-refresh/only-export-components`; this also makes the hook
importable by `renderHook` without pulling in the whole screen component.

No server-side/C# change. Full local gate: lint clean, tsc clean, full
vitest run 110 files / 1051 tests passed (one LibrariesScreen.test.tsx
flake reproduced under full-suite parallel load, confirmed pre-existing and
unrelated — passes in isolation, never touched that file).
2026-07-27 01:29:15 +02:00
timothy 2d049e9a28 fix(650): follow-up — per-occurrence guard identity, shorthand pageSize detection, and three UI defects
Cold cross-family (Codex) review of 9763fdca found real defects; addressed as a
follow-up rather than amending that commit.

MUST FIX, addressed:

- F5: pageSizeCallSites.guard.test.ts collapsed call-site identity to
  `file:value`, so a SECOND at-cap call in an already-registered file was
  invisible (verified: appending a duplicate `getLibraryBrowseItems({
  mediaType: 'Movie', pageSize: 100 })` to ChannelBuilder.tsx passed all 4
  guard tests before this fix). Identity is now `file:line:kind:value` — a
  bracket/quote-tracked scan resolves each occurrence's exact line, so a
  duplicate on a new line is a new, unregistered identity.

- F6: the guard now also detects the ES6 shorthand property form (`{ ...,
  pageSize }`), not just `pageSize: <value>`. Implemented as a bracket-stack
  scan that distinguishes an object-literal `{` (real risk) from a
  block-statement `{` or an array `[` (false positives from things like
  `useCallback` dependency arrays and `const pageSize = 100;` inside a
  function body) by inspecting the token immediately preceding each `{`.
  Six real shorthand sites are now registered (the two inside loadAllPages
  itself, ChannelBuilder's two per-kind fan-outs, and two genuine
  user-adjustable pagers in LogsScreen/BlockPlayoutTroubleshootingScreen).
  Object SPREAD and positional-argument pageSize (api/search.ts's
  api.search-allitems-paging precedent) remain a documented residual gap,
  written down in the test file's own header comment, not silently absent.

- F2 (TraktListsScreen.tsx): an incomplete load with zero accumulated rows
  rendered BOTH "List may be incomplete" and the unsupported "No Trakt lists
  yet." claim. The zero-row empty state now branches on `incomplete` first.

- F3 (ChannelBuilder.tsx useLibraryBrowse): changing the query/library while
  a "Load more" append was in flight stranded the button in its
  loading/disabled state forever (the stale append's own `finally` no longer
  matched the current request id, and the superseding fresh fetch never
  cleared `loadingMore` either). `finally` now clears `loadingMore` whenever
  the settling request is still the CURRENT one, regardless of whether that
  particular request was an append.

- F4 (ChannelBuilder.tsx useLibraryBrowse): one rejected per-kind request in
  an append's `Promise.all` wiped every already-loaded row via
  `items: []` with no way back. Append failures now preserve state, surface
  the error inline next to a still-present "Load more" button, and roll the
  page cursor back so a retry re-requests the same page instead of skipping
  it.

Both new UI fixes are pre-existing defects in the 'library' source that
9763fdca's loadCollections fix newly made reachable from 'collections' too.

Verified all four fixes against negative controls: reverted each in turn and
confirmed its dedicated test fails with the expected message, then restored.

DO NOT FIX (filed as timothy/ersatztv#665 instead, bug+frontend+priority:low):
- F1: loadCollections/loadLibraryItems sort each fetched page independently,
  so appended pages are only locally sorted, not globally sorted across the
  accumulated list.
- F7: an overclaiming totalCount can leave "Load more" clickable after every
  kind is actually exhausted (no auto-loop; a user click is still required
  each time).

Not touched (reviewer confirmed correct as-is): Trakt sequence/abort/unmount
handling, the Class-A vs Class-B incomplete-copy distinction, and the
`lists.length` footer count.
2026-07-27 01:29:15 +02:00
timothy ad4ac6c7e0 fix(650): page Trakt lists to completeness and report real totals in loadCollections
Two SPA list loads requested EXACTLY the server's pageSize cap (100), truncating
identically to #634/#644's over-cap defect but invisible to that fix's manual
"pageSize above the cap" grep:

- TraktListsScreen requested pageSize:100 and rendered BOTH the truncated page
  AND the real totalCount, so 101 lists showed as "101 lists" over a 100-row
  table. Trakt lists are bounded-by-construction (Class A), so this now pages
  to completeness via the shared loadAllPages helper, surfaces an "incomplete"
  badge if a page ever comes back short of totalCount, and passes an
  AbortSignal from the effect cleanup.

- ChannelBuilder's loadCollections (fanning out per collection kind) reported
  the truncated merged.length as totalCount, so canLoadMore's
  `items.length < totalCount` comparison was permanently false and "load more"
  could never fire. It now sums the real per-kind totalCount, mirroring the
  existing loadLibraryItems pattern in the same file, and canLoadMore is no
  longer gated to the 'library' source only.

Also found and fixed a third at-cap site not named in #650: ChannelBuilder's
SeasonsDialog (TelevisionSeason browse scoped to one show) reads pageSize:100
but never read the response's totalCount. No real show has 100+ seasons, so
this stays a single bounded page (Class B) rather than paging to completeness,
but now surfaces a "Showing the first N of M seasons" hint instead of silently
truncating if a show somehow exceeds the cap.

Codifies the missing completeness guard as an enumerating allow-list vitest
test (web/src/api/pageSizeCallSites.guard.test.ts): scans every `pageSize:`
call site in the SPA and diffs it against a hand-reviewed registry in both
directions (unregistered site = new defect risk, stale entry = registry rot),
with anti-vacuity floors on files-scanned and sites-discovered. Verified the
guard actually fails on a planted defect and a planted stale entry before
finalizing it.

No server-side change: the client pages, the server stays bounded
(api.search-allitems-paging precedent).
2026-07-27 01:29:15 +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
timothyandClaude Opus 5 8d35a2792f fix(616): document paging as 0-based, expose channelId on playout detail
#616 filed three MCP/API paging traps. Two were real; one was not, and one was
already half-fixed on main. Verified each against the code before changing it.

REAL — pageNum documented as 1-based. `ToolCatalog.Page()` described pageNum as
"1-based page number" while every paged controller defaults it to 0, floors it
with `Math.Max(0, pageNum)`, and skips `PageNum * PageSize`. A caller that
trusted the description started at page 1 and silently lost the first page: no
error, just a short set that reads as data loss rather than an off-by-one (it
cost #487 a verification pass). Fixed in the description rather than by making
the MCP layer 1-based: /api/v1 is additive-only post-freeze, 0-based is
load-bearing in a dozen controllers and the SPA, and a 1-based wrapper over a
0-based API would make the same parameter name mean two different things on two
surfaces a reader reads together.

NOT REAL — "pageSize caps the page but the offset honors the requested value".
Not reproducible on any endpoint. Every controller clamps before passing, every
handler skips by the clamped size, and GetCollectionItemsHandler re-clamps
defensively. The reported observation (pageSize=500&pageNum=2 on a 204-item
collection returning 4 items) is exactly correct 0-based behaviour at the
clamped width of 100 — page 2 is items 201-204. The issue's own trap-1 table
states this. Pinned by test rather than "fixed".

ALREADY FIXED — playout LIST rows gained channelId in #297 (2026-07-22), three
days before #616 was filed; the report was measured against prod, which runs an
older :prod image. The DETAIL response (PlayoutResponseModel) genuinely still
lacked it, so channelId is added there (additive) and the reset_channel_playout
argument now names the trap: the id spaces overlap numerically, so passing a
playout id silently resets a different channel and returns a plausible 202.

Tests, both mutation-verified (each fails when its fix is reverted):
- ToolCatalogTests pins "0-based" on EVERY paged tool's pageNum description,
  with a non-empty guard so it can't pass vacuously over an empty tool set.
- GetCollectionItemsHandlerTests pins 0-based page boundaries and proves the
  offset derives from the clamped pageSize (page 1 at pageSize=500 returns
  items 101-150; the mutation that honors 500 returns an empty page).

Docs: new decision record api.paging-zero-based (catalog regenerated), the
api-conventions paging bullet, and a Paging section in docs/mcp.md. OpenAPI
v1.json + web/src/api/generated/v1.d.ts regenerated for the added field.

fixes #616

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:16:00 +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
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 13bfcebd04 feat(434): facet-value typeahead combobox for text fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:27 +02:00
timothyandClaude Opus 4.8 b4ae0bde18 feat(434): distinct-values search endpoint (text fields, Lucene term enumeration)
Adds GET /api/v1/search/fields/{name}/values?q=&limit= — the backend slice of the
visual rule builder's facet-value typeahead (#434). Enumerates distinct Lucene term
values for a text field via MultiFields.GetTerms + TermsEnum, filtered by a
case-insensitive prefix, limit clamped to [1,50]. 404s when the field is absent from
SearchFieldCatalog or is not type "text". ElasticSearchIndex (the optional external
backend) throws NotSupportedException for this method — its text fields are analyzed,
not keyword-mapped, so a terms aggregation isn't safe to guess at without verifying
against a live cluster.

Regenerated OpenAPI trio (v1.json, v1.d.ts, endpoint-index.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:27 +02:00
timothyandtimothy 8b9a7ed541 feat(414): stamp immutable Channel.Origin (auto-tuned vs user-created) and surface it (#575)
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 6m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m1s
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-23 18:12:25 +00:00
timothy 9ea2750cbf feat(392): expose ProgramSchedule.padToNearestMinute on the REST API 2026-07-23 08:03:31 +02:00
timothy b139921691 feat(74): channel Branding-tab On Now/Next overlay toggle 2026-07-22 22:11:46 +02:00
timothy 7757814766 feat(74): channel graphicsElementIds + graphics builtIn; regen OpenAPI 2026-07-22 22:11:46 +02:00
timothyandClaude Opus 4.8 1a3c8e277f feat(297): add channelId to PlayoutListItemResponseModel; SPA reset keys directly
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 20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m37s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m43s
Added ChannelId to PlayoutNameViewModel and all 6 construction sites
(Mapper, GetPlayoutByIdHandler, and the Update{,Scripted,ExternalJson,Sequential}
PlayoutHandler commands), plus the list DTO PlayoutListItemResponseModel and the
PlayoutController list projection. Regenerated OpenAPI (v1.json) and the TS client
(v1.d.ts); endpoint-index.md unchanged (no endpoint/operation delta). Simplified
PlayoutsScreen resetSelectedChannel to key directly on selectedSummary.channelId
instead of resolving via channelStates. Updated controller + SPA tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:57:54 +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
timothy f1de436ca7 feat(60): expose channel preview capability on GET /api/v1/channels 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
timothy 9cbd1fdb5c chore(498): regenerate OpenAPI + SPA types for QsvPreferNativeDecoder 2026-07-20 21:54:13 +02:00
timothyandClaude Opus 4.8 2176b73e23 fix(67): cover the bug-preview geometry path, preserve a custom logo preset across toggling
- Add ChannelEditScreen tests exercising the geometry-fetch/preview path (previously
  untested because the fixture's blank logo path kept BugPreview from ever rendering):
  asserts the fetched watermark geometry lands on the preview image with asymmetric
  values, and that an external logo URL suppresses the preview.
- Fix a toggle-off/toggle-on data loss: re-enabling "use logo as on-screen bug" used to
  always rebind to the default 'Channel Bug' preset, discarding a channel's own
  per-geometry ChannelLogo preset. Remember the last referenced logo-driven watermark id
  in a ref and prefer it, falling back to the shared default.
- Strip the geometry cache's `id` key before it reaches BugPreview's props (was leaking
  via a spread).
- Drop the "(see #502)" issue reference from the visible help text; keep it in the code
  comment.
- Constrain findLogoBugWatermark's imageSource param to the generated
  ChannelWatermarkImageSource union instead of `string`, and add unit tests for its
  preference/fallback/none-found behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:02:16 +02:00
timothy d5da504651 feat(67): add Use logo as on-screen bug toggle with preview to the channel editor 2026-07-20 20:51:58 +02:00
timothy ebaadc0656 feat(67): add imageSource to the watermark picker DTO (additive)
WatermarkResponseModel gains ImageSource so a client can identify
logo-driven presets generically instead of matching a user-editable name.
Additive under the frozen-additive /api/v1 contract (#286).

Adding a positional record parameter is source-breaking for existing
constructor call sites, so the two test files that built the DTO
positionally are updated. WatermarkHandlerTests now seeds its two rows with
DIFFERENT image sources so the round-trip assertion proves the field is
actually carried through the mapper rather than matching a constant on both.

Regenerated v1.json, endpoint-index.md and v1.d.ts; check:api clean.
Stripped the inherited UTF-8 BOM from Mapper.cs (#311 fix-as-you-touch).

Refs #67
2026-07-20 20:39:12 +02:00
timothyandtimothy 2cf90fb44f feat(489): support Jellyfin mixed-content libraries (#493)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has started running
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
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
Jellyfin libraries typed `mixed` were dropped by JellyfinApiClient.Project's
`_ => None` with no log line, so music and standup content could not be
ingested without a local-library workaround that bypassed Jellyfin entirely.

Adds LibraryMediaKind.Mixed, maps "mixed"/absent/blank CollectionType onto it,
and gives SynchronizeJellyfinLibraryByIdHandler a Mixed arm composing the three
existing per-kind scanners. Jellyfin classifies items server-side via
includeItemTypes, so the passes see disjoint sets; reconciliation is type-scoped
and cannot cross-delete. No new scanner and no DB migration -- MediaItem is TPT
keyed on LibraryPathId, so heterogeneous contents were already legal.

Segregation falls out of the model: a library is a place (one path <-> one
Jellyfin library <-> one ErsatzTV library), so music/standup cannot leak into
Movies or TV Shows.

Also removes the silent-success `_ => Unit.Default` from both scanner
dispatchers, which returned Right for an unhandled kind and stamped LastScan as
though a scan had run, and rejects Mixed for local libraries at the API.

Deliberately Jellyfin-only: local scanners share one video extension list and
would claim each other's files, and LibraryFolder etags are keyed by
LibraryPathId with no notion of kind.

Verified by live E2E against a real Jellyfin, including the interaction with
#494's reconciliation sweep. Four cold review rounds, all MERGEABLE.

fixes #489

Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-20 16:34:51 +00:00
timothyandClaude Opus 4.8 be25df670e feat(431): TTL-cache health-check results; ?refresh=true forces a fresh run
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m16s
Build ErsatzTV Image / decisions.md append-only (pull_request) Failing after 12m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Failing after 14m6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
GET /api/v1/health re-ran all 14 health checks on every request, 4 of
which shell out to ffmpeg/ffprobe via CliWrap — so each poll spawned ~4
subprocesses. The existing HealthCheckSummary cache was write-only.

Cache the full result list for 30s inside HealthCheckService keyed on a
new "healthcheck.results" entry; a non-forced call returns it on a hit,
skipping the checks and the (subscriber-less) summary publish. Add a
`bool forceRefresh` first parameter to IHealthCheckService.PerformHealthChecks:
the API poll path reads the cache, while startup (RunHealthChecksService)
and the troubleshooting support bundle force a fresh run.

Refresh surface: GET /api/v1/health gains an optional `[FromQuery] bool
refresh` (additive, follows the ?deep= exemplar); the SPA "Refresh health"
button calls /api/v1/health?refresh=true, the initial/poll load does not.

Tests: HealthCheckService cache-hit vs force-bypass (mutually opposing,
non-vacuous), handler+controller refresh-flag threading, SPA refresh URL.
Docs: decisions.md 2026-07-19 (#431), api-conventions §2; regenerated v1.json.

fixes #431

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 14:41:12 +02:00
timothyandClaude Opus 4.8 1aa1f0cf5b fix(464): render real channel logos in guide grid + channels list
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m33s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 36m5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
The guide/EPG grid (/app/guide) and the channels list (/app/channels) always
drew the generated initials "bug" because the browse DTOs never carried a logo
URL — GuideScreen/ChannelsScreen rendered <ChannelLogo> with no src. The logo
data existed (it round-trips through the channel editor) but never reached these
views.

Add a rooted, directly-usable Logo URL to ChannelGuideChannelResponseModel and
ChannelResponseModel, populated by a single Channels.Mapper.GetLogoUrl helper
(#181 artwork convention): /iptv/logos/{file} for an uploaded logo, the absolute
URL passed through for an external one, null when unset so the SPA keeps its
generated-initials fallback. The guide query now includes Channel.Artwork.

Regenerated OpenAPI + v1.d.ts; updated api-conventions.md + domain-model.md.

fixes #464

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:28:00 +02:00
timothyandClaude Opus 4.8 ac7965dee4 feat(293): paginate GET /api/v1/search/all-items to cap DoS exposure
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m10s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m39s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14m31s
The all-items endpoint fired ten index searches with limit:0 (every hit), so a
broad authenticated query materialized the whole index into one response. Add
optional pageNum/pageSize (clamped 1..1000; pageNum 0..2_000_000 so skip can't
overflow int) and an additive per-kind Totals on the response; the SPA add-all
flow now pages to completeness instead of a single unbounded fetch.

- SearchController.SearchAllItems: clamp params (Logs §1 precedent), map Totals
- QuerySearchIndexAllItemsHandler: skip=pageNum*pageSize, limit=pageSize, read
  SearchResult.TotalCount per kind
- SearchResultAllItemsResponseModel: additive Totals (frozen-v1-safe)
- web/src/api/search.ts: getSearchAllItems paging params + getAllSearchItemIds
  (pages until each kind hits its total; empty-page safety break)
- tests: controller clamp/thread/totals, handler skip/limit/totals, SPA paging
- docs: decisions.md 2026-07-18 (#293), api-conventions.md §5; regenerated OpenAPI

Design: issue option (a) full pagination, operator-confirmed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:09:06 +02:00
timothyandClaude Opus 4.8 e364b338e6 feat(425): per-source rotation weights + query corrections for auto-tune channels
Auto-tune channels can now carry per-content-source rotation weights (weighted
round-robin, e.g. 3x Show A / 1x Show B) and query corrections (exclude /
add-untagged), supplied at bulk-create time via an optional
`sources: [{sourceId, weight, excluded}]` on each AutoTunedChannelRequest.

Design (Option A, reuse #70): when a source is customized the channel is backed
by a system-owned MultiCollection of per-source SmartCollections carrying the
weights, with PlaybackOrder.WeightedShuffle -- the exact path
WeightedShuffleCollectionEnumerator already consumes. All-default weights keep
the #69 single-SmartCollection fair-share shape.

- Discriminators: TV -> live show_title:"X" (episodes carry no parent-show id in
  the index); movies -> stable id:{mediaItemId}.
- Materialization is axis-dependent: TV materializes every base show individually
  (un-weighted shows keep per-show fair-share) + a live remainder at weight 1;
  MovieGenre materializes only touched movies + one count-weighted remainder.
- Remainder = (base) AND NOT (materialized union excluded) -- a partition.
- New nullable OwnedByChannelId on SmartCollection + MultiCollection
  (dual-provider migration); owned rows are hidden from the collection lists and
  cascade-cleaned on channel delete.

Tests: AutoTuneAxisMap query/partition units; DB-backed weighted-path handler
tests (TV materialize-all, movie count-remainder, exclusion, no-customization
fallback); delete-cleanup. Docs: decisions.md, domain-model.md, api-conventions.md;
OpenAPI trio regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:52:06 +02:00
timothy 30a8f67e8e feat(176): field-catalog api + useSearchFields hook
Task 6: getSearchFields() client fn + SearchField type in api/search.ts,
and the useSearchFields() hook in builder/rules/fieldCatalog.ts that
reshapes the catalog into fieldTypes (name -> FieldType) and byGroup
views for the rule builder (Tasks 7-8).

Generated SearchFieldResponseModel has all-nullable fields (name/label/
type/group/values), unlike the brief's non-null assumption -- adapted by
dropping fields with a null name and defaulting group to 'Other' / type
to 'text' rather than indexing under undefined.
2026-07-18 02:41:50 +02:00
timothy ae22107d98 feat(176): GET /api/v1/search/fields endpoint + regenerated api artifacts 2026-07-18 02:41:50 +02:00
timothy f44eee85c5 feat(386): Auto-Tune per-channel DetailPanel slide-over (SPA)
Adds a right-hand "Configure" slide-over to each Auto-Tune Preview row, making
a proposed channel editable before bulk-create — against the shipped #384/#385
backend only, so no control lacks a wire target.

- New reusable SlideOver primitive (components/overlay.tsx), sharing a
  useOverlayBehavior hook (focus/scroll-lock/Escape/scrim) with Dialog.
- Extract the Channel Builder's advanced-options model to builder/advancedOptions.tsx
  (enum catalogs, ADVANCED_KEYS, effectiveValue, INHERIT/omit useAdvancedOverrides
  hook); ChannelBuilder imports it unchanged (its tests pass byte-for-byte). The
  DetailPanel writes its own field JSX over the same hook — shared logic, per-screen
  layout.
- Panes: identity (name/number + logo upload), Playback (Shuffle/Always-playing →
  advanced.playbackOrder/playoutMode), per-channel template picker, Advanced
  disclosure, lean read-only Query&size, read-only Content-sources via GET /members.
- getAutoTuneChannelMembers API client (#384 read endpoint) + tests.
- Screen-scoped §8 unsaved-changes guard + "Edited" row badge.
- Dropped as backend-less decoration: MiniEpg, bug-initials generator, query text.
  Deferred to #425 with an in-pane hint: per-source weight steppers + corrections.
- Docs: spa-conventions §11 (SlideOver + shared advanced-options), decisions.md.

Refs #386
2026-07-18 01:31:40 +02:00
timothyandClaude Opus 4.8 ed6c43065f feat(164): guided remediation for health checks (server-declared {Kind, Target})
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m48s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m49s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m35s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13m23s
Make the ~14 health checks actionable: each check that has a fix now declares
where to go, and the SPA acts on it.

Backend:
- Widen domain HealthCheckLink (string Link) -> (string Target, HealthCheckLinkKind
  Kind) with ExternalDoc|AppRoute + factories; only the 4 link-building checks and
  the API mapper touched .Link.
- Evolve HealthCheckResponseModel additively (/api/v1 frozen-additive): keep
  deprecated string? Link (still populated), add Brief (the BriefMessage the mapper
  was silently dropping) and nested Remediation {Kind, Target}. Kind is a mapped
  string, not a wire enum.
- Make Mapper.GetStatus total: NotApplicable no longer throws (defensive; handler
  still filters it). InternalsVisibleTo(ErsatzTV.Tests) added to unit-test totality.
- Fix 2 stale Blazor route links (media/trash -> /app/trash, search?query ->
  /app/search); add AppRoute remediation to actionable checks that had none
  (libraries / schedules / ffmpeg-profiles / settings).

SPA:
- DashboardScreen health panel renders remediation: AppRoute -> client-side nav
  button, ExternalDoc -> new-tab anchor; detail text truncates with title-hover.
- Remove the dead "Open Classic UI" -> /system/health row from SettingsScreen
  (a #91b leftover that just 302'd to /app); update its regression test.

Docs: decisions.md (#164), api-conventions.md (deprecate-in-place DTO evolution),
blazor-route-parity.md (Section 4 correction); v1.json/v1.d.ts/endpoint-index
regenerated.

fixes #164

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 00:51:40 +02:00
timothyandClaude Opus 4.8 8f61ad6530 feat(385): per-channel overrides in auto-tune bulk-create
Auto-Tune DetailPanel backend (#385), additive half. The create request
`AutoTunedChannelRequest` gains three optional per-channel fields, all
backward-compatible (omit = PR1 behavior):

- `templateId` — overrides the batch template per channel
- `advanced` — reuses the manual Channel Builder's
  `CreateChannelFromLineupAdvancedOptionsRequest` verbatim (24-field override
  set, `advanced.X ?? template.X` stamp contract). Axis default fills
  `PlaybackOrder` only when the caller leaves it null.
- `logo` — uploaded channel image, `Sanitized()` at the request boundary
  (#283 stored-XSS defense), forwarded to `CreateChannelFromLineup.Logo`

Resolved per channel inside `CreateAutoTunedChannelsHandler.CreateOne`, so one
channel's bad override still yields a per-channel Failed/Skipped without
aborting the batch.

Per-source rotation weights + query corrections are split out to #425 (they
need a MultiCollection-of-per-source-SmartCollections redesign — #70's
WeightedShuffle reads weights only off MultiCollection join rows, and an
auto-tuned channel is one SmartCollection). Bug-initials/colour generated logo
also deferred (needs persisted Channel state + FFmpeg-pipeline wiring).

Tests: handler override-threading (per-channel wins, axis default preserved,
no-override baseline) + request `ToCommand()` logo sanitization. OpenAPI trio
regenerated. Docs: decisions.md, api-conventions.md, domain-model.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:33:10 +02:00
ef9bba8a52 feat(70): accept per-source weight on the multi-collection API
Without this the weight is only reachable by editing the database, so the
enumerator has nothing to distribute by.

Weight is threaded through create and update (all four handler branches: add and
update, plain and smart) and defaults to 1, so it is optional on the wire and
/api/v1 stays additive under the freeze.

It is returned on the read path too, which is load-bearing rather than symmetry:
the update replaces the item list, so a client that GETs, edits a name, and PUTs
back would silently reset every weight to the default if the GET didn't carry it.

Weight edits ride the existing MultiCollection Version token, so If-Match/412
concurrency needs no new design.

Regenerated v1.json + v1.d.ts + endpoint-index via update-openapi.sh and
generate:api (never hand-edited). The spec picks up weight on both request and
response models and WeightedShuffle in the PlaybackOrder enum; weight is emitted
optional.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
timothy 1a40b4a8e6 fix(72): report a real per-channel playout count on the channels API
The channels API could not answer "will this channel play?", which #72 needs
to flag a broken channel in the lineup at a glance.

Two defects, one root cause each:

1. `ChannelRepository.GetChannel` never included `Playouts`. The read is
   AsNoTracking with no lazy-loading proxies, so the navigation came back
   empty and `GetChannelByIdForApiHandler`'s `channel.Playouts?.Count ?? 0`
   could only ever evaluate to 0 — `GET /api/v1/channels/{id}` reported
   `playoutCount: 0` for every channel on the system. That silently disabled
   the channel editor's playout-source guard (ChannelEditScreen:820, gated on
   `playoutCount > 0`), so the "Cannot be changed once a generated channel has
   a playout" control was always live. The server still enforces the invariant
   (UpdateChannelHandler coerces Mirror back to Generated), so nothing was
   corrupted — but the user's change was silently discarded. That silent
   coercion is filed separately as #401.

2. The detail path counted only the channel's own playouts, never the mirror
   source's, so a working Mirror channel would read as "no playout" even once
   the include landed.

Both call sites now share `Mapper.GetPlayoutsCount` (previously private to
GetAllChannelsHandler), which handles the Mirror case. `ChannelResponseModel`
gains `PlayoutCount` so the list — #72's actual surface — can render it; the
count is free there, since `GetAll` already includes `Playouts` and
`MirrorSourceChannel.Playouts` and simply discarded them.

Tests run the real repository against a real context on purpose: a handler
test with a substituted IChannelRepository populates `Playouts` itself, so it
passes whether or not the query includes them. Proven non-vacuous — removing
the include again turns the 2-playout and mirror cases red (0 CS errors, so
no stale-dll false pass).

Refs #72
2026-07-17 17:51:26 +02:00
timothy a5ac63dbd9 Merge pull request 'feat(69): Auto-Tune SPA wizard (Configure → Preview → Create) — PR2' (#389) from feat/69-auto-tune-spa into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
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 / Functional E2E (curl contracts) (push) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m51s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m47s
2026-07-16 22:30:52 +00:00
timothyandClaude Opus 4.8 33ba3a0492 feat(69): Auto-Tune SPA wizard — Configure → Preview → Create (PR2)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m37s
New `/app/auto-tune` screen driving the PR1 endpoints: pick metadata axes
(TV Shows / TV Genres / Movie Genres) + defaults, preview proposed channels
grouped by axis with per-row/per-group selection and already-exists dedup,
then bulk-create with a Created/Skipped/Failed summary. Additive/non-destructive.

The per-channel DetailPanel from the design iteration is deferred to #383
(new endpoints backlogged) — this ships the 3-step wizard only.

- web/src/api/autoTune.ts (+test), web/src/screens/AutoTuneScreen.tsx (+test)
- route/nav (routes.tsx, ScreenContent.tsx), api barrel, App nav-smoke test
- docs: domain-model (route), blazor-route-parity (net-new SPA screen)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:04:42 +02:00
timothyandClaude Opus 4.8 7a01ad62bd feat(71): SPA per-playout Reshuffle button + client fn
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:22:33 +02:00
timothyandClaude Opus 4.8 238937fa5b chore(71): regenerate OpenAPI + client for reshuffle endpoint + seed field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:19:46 +02:00
timothy 0320735f47 docs(69): auto-tune OpenAPI regen + api/domain/decisions docs
Refs #69
2026-07-16 22:18:52 +02:00
timothyandClaude Opus 4.8 3bc8192d3b feat(iptv): add configurable advertised base URL for M3U/XMLTV (fixes #340)
ErsatzTV built every absolute M3U/XMLTV URL from the incoming request's
Scheme/Host/PathBase, so a client fetching via a host that downstream
consumers can't resolve (e.g. Dispatcharr over Docker DNS → Kodi) baked
that internal host into programme-image/stream URLs.

Add an optional advertised IPTV base URL, backed by the existing
ConfigElement key/value store (key `iptv.base_url`, no EF migration):

- Central pure Core helper `AdvertisedBaseUrl` (TryParse/Resolve):
  validates absolute http(s), no credentials/query/fragment, preserves
  port + path prefix, normalizes trailing slash. Blank/invalid falls
  back to the request-derived values, so unset output is byte-identical.
- Resolved inside `GetChannelPlaylistHandler` (M3U guide/logo/stream) and
  `GetChannelGuideHandler` (both XMLTV {RequestBase} sites) — controllers
  stay thin, golden tests untouched.
- New `iptv` settings group: GET/PUT /api/v1/settings/iptv (blank clears,
  malformed → 422) + a new IPTV section on the SPA Settings screen.
- Scoped to M3U + XMLTV; HDHomeRun deliberately out of scope. Distinct
  from ETV_BASE_URL (which only sets ASP.NET PathBase).

Tests: AdvertisedBaseUrl unit tests (override/fallback/port/path/invalid),
handler override tests for both generators, settings controller + handler
tests, SPA client + screen tests. Docs: m3u-xmltv, decisions, domain-model,
regenerated OpenAPI v1.json + v1.d.ts + endpoint-index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:08:42 +02:00
timothyandClaude Opus 4.8 ef2bd65c27 feat(api): #286 — mount the whole /api surface at /api/v1
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.

Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.

Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).

Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.

Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.

fixes #286
refs #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:30:20 +02:00
timothyandClaude Opus 4.8 105c0ec88a fix(web): #316 review — playback phase promotion, engine-neutral download test fixtures, 401 signal, AuthSession optional fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:42 +02:00
timothyandClaude Opus 4.8 3c1e694905 fix(web): #295 align MachineKey wire field to server ({ apiKey }, not { key })
The server returns MachineKeyResponse(string ApiKey) -> JSON { apiKey }, but the
hand-written SPA MachineKey type declared { key } and ApiKeyScreen read result.key,
which would be undefined at runtime (blank key + empty copy). Mocked unit tests
passed against the wrong shape. Align the type, the screen, and both test mocks to
the real { apiKey } contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 de9fc7e8cc feat(web): #295 PR2 SPA consumers — troubleshoot POST/blob downloads + machine-key screen + password change
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 9348a9938a feat(web): #295 PR2 SPA session-auth foundation (boot gate, CSRF header, login/setup/logout)
Replace the API-key SPA model with the session-cookie auth from PR1:

- client.ts: stop sending X-Api-Key; attach X-Csrf on mutating verbs (POST/PUT/
  PATCH/DELETE); add suppressUnauthorizedSignal to skip the global 401 signal on
  expected wrong-credentials 401s (login / change-password).
- api/auth.ts: hand-written wire types (AuthConfig/AuthSession/MachineKey — the
  auth surface is IgnoreApi, deliberately not in generated types) + endpoint fns
  (getAuthConfig/getAuthSession/login/setup/logout/changePassword/getMachineKey);
  keep the notifyUnauthorized/subscribeUnauthorized 401 signal; add
  clearLegacyStoredApiKey. Legacy get/set/clearStoredApiKey retained ONLY so the
  still-shipping ApiKeyScreen (machine-key slice) compiles without a cross-slice
  conflict — the client no longer reads them.
- AuthGate.tsx: boot gate wrapping <App/> outside the shell (mints no URL, deep
  links survive login). checking -> setup | login | ready | error, with a safe
  default AuthContext so App.test.tsx renders without a provider; config-fetch
  failure lands on an explicit error+Retry, never a blank screen.
- LoginScreen / SetupScreen: shell-less centered cards; inline 401 / 409 handling;
  SSO button + local-form gating from AuthConfig.
- UnauthorizedBanner: rewritten to prompt re-login (passive; consults the
  unsaved-changes guard before flipping the gate).
- UserMenu: TopBar sign-out (guard -> logout -> signOut), mounted next to
  ConnectMenu.
- main.tsx: wrap <AuthGate><App/></AuthGate> inside StrictMode.

Tests: client/auth/AuthGate/LoginScreen/SetupScreen/UnauthorizedBanner/UserMenu
(723 pass). Lint + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 88d82266b6 feat(api): #301 POST-ify side-effecting troubleshoot GETs + GET /api/auth/machine-key
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00
timothyandClaude Opus 4.8 4112413ca5 fix(spa): #271 disable all family rows on click (family-global lock parity)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m29s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m8s
Cold-review nit fixes on PR #298:
- useCollectionsScan.scan() now guards on the whole family being busy (active OR
  any pending key of that family), not just the exact key — a sibling source of a
  family with a scan in flight no longer fires a redundant (benign-409) POST.
- LibrariesScreen ExternalCollectionsSection disables every row of a family that
  has a pending or active scan (derives pendingFamilies from pendingKeys), matching
  Blazor's instant all-rows-disabled behavior instead of waiting a poll RTT.
- Rewrite the promote test to actually observe the optimistic-pending window via a
  deferred POST (was only asserting the promoted end state), and add a test proving
  a sibling-source click fires no second POST while the family is pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:34:05 +02:00
timothyandClaude Opus 4.8 6d31758cca feat(api): #271 collections scan-status REST surface + authoritative SPA reconcile
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Add GET /api/media-sources/collections-scan-status (MediaSourcesController →
GetCollectionsScanStatus handler) reporting which media-source families
(plex/jellyfin/emby) currently hold their external-collections scan lock,
reading IEntityLocker.Are{X}CollectionsLocked(). The lock is family-global
(no source id) and boolean (no percent), so the DTO carries just {family} and
returns only active families — the counterpart to GET /api/libraries/scan-status.

SPA: useCollectionsScan now polls this endpoint and reconciles optimistic
pending against the active-family set (seeding on mount so an in-progress scan
disables buttons immediately), using the same grace-tick helper as library
scans (now generic over the pending key type). Drops COLLECTIONS_PENDING_TIMEOUT_MS
— a long deep scan no longer re-enables the button early, and a fast scan no
longer wedges it disabled for the full timeout. A row shows Scanning when its
family is active or it has an in-grace optimistic pending key.

Tests: handler (3), controller route+delegation (2), SPA api fn + hook reconcile
(mount-seed / 202-promote / 409-keeps-disabled / 404-error). OpenAPI + TS types
regenerated. Docs: api-conventions §3b, blazor-route-parity §5, decisions.md.

Unblocks #91b (arc item 4): Libraries.razor's collections-scan affordance now
has full authoritative parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:23:58 +02:00
timothyandClaude Opus 4.8 c40e78d840 fix(api): #197 Bundle C review nits — order-independent operationIds + nullable MediaSources fields
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m18s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m21s
Refs #287 #288 #197

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