Compare commits

..
Author SHA1 Message Date
timothyandClaude Opus 4.8 10d891ba63 fix(401): null Mirror-only fields for non-Mirror channels (repair dead else-branch)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
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 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m53s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m34s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Closes review finding: stray MirrorSourceChannelId/PlayoutOffset could persist onto a Generated channel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:57:24 +02:00
timothyandClaude Opus 4.8 a3080ca42b fix(401): reject Mirror channel with playout instead of silently coercing to Generated
fixes #401

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:50:32 +02:00
timothy 1cd06395aa Merge pull request 'feat(350): instrument HLS cold-start latency (phase split + feature flags)' (#446) from feat/350-coldstart-instrumentation into main
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 / 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 5m8s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m46s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m2s
2026-07-18 14:16:38 +00:00
timothy c4695b1095 Merge pull request 'test(363): functional-E2E harness — deterministic scan-lock + collections-lock 409 flows' (#443) from feat/363-e2e-deferred-flows into main
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 / 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 / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-18 14:05:24 +00:00
timothyandClaude Opus 4.8 a1b75c1f2c docs(363): sweep the scan-flow drift the re-review caught
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m12s
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 / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m18s
The fix commit softened the script's scan-409 wording + dropped ?deep=true but
left the docs describing the old behavior. Match them:
- docs/e2e-local.md: POST .../scan (no ?deep; note local scans always ForceScan)
  + "409, deterministic bar a tiny residual TOCTOU gap" (was "guaranteed 409")
- docs/ci-cd.md: same "guaranteed 409" -> "409 (deterministic bar ...)"

Docs-only; harness behavior unchanged (still 38/38 green, deterministic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:44:47 +02:00
timothyandClaude Opus 4.8 4345180a56 review(363): robustness + wording fixes from cold review
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- check seed_library_path succeeded (print lastrowid) so a silent seeding
  failure surfaces as a FAIL instead of degrading Flow A to an advisory skip
  with no diagnostic (Medium)
- anchor the scan-status match to '"libraryId":2,' so it can't substring-match
  "libraryId":20/23 if the suite ever creates more libraries (Low)
- drop the no-op ?deep=true (local scans always ForceScan; deep only affects
  Plex/Jellyfin/Emby) + note why (Low)
- soften "guaranteed 409" for the scan flow to note the tiny residual TOCTOU
  gap the multi-second scan covers; Flow B stays race-free by construction (Low)
- correct the "WAL tolerates a second writer" wording to the real reason (the
  busy-timeout retry serializes the writer) in the script + both docs (Nit)
- use TEST-NET-1 192.0.2.1 (RFC 5737) instead of RFC1918 10.255.255.1 for the
  non-routable Jellyfin address (Nit)

Re-verified: fresh-instance harness runs green (38/38), lock section
deterministic. Functional E2E CI job already green on the prior head.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:33:39 +02:00
timothyandClaude Opus 4.8 6c8c7feeba feat(350): instrument HLS cold-start latency (phase split + feature flags)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m10s
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 13m24s
Adds one Information-level structured log per HLS tune-in cold-start so the
real driver breakdown can be measured on prod before optimizing the transcode
pipeline (measure-before-optimize). Log-only; no transcode behavior change.

- WaitForPlaylistSegments returns a PlaylistSegmentsResult: Phase A (process
  startup -> playlist exists) vs Phase B (segment fill), segments reached,
  deadline-expired.
- StartFFmpegSessionHandler emits one summary: total = setup + startup + fill,
  plus cleanly-detectable feature flags (subtitle burn-in, hwaccel family).
- ColdStartFeatures: pure, unit-tested args->features helper (14 cases).

Watermark / HDR->SDR / image-subtitle burn-in are deliberately not flagged
(all reduce to overlay= in the args, indistinguishable); the full ffmpeg
arguments remain available at Debug.

Refs #350 (instrumentation slice; optimization deferred pending real data).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:30:56 +02:00
timothyandClaude Opus 4.8 8a85f9ddb5 test(363): functional-E2E harness — add deterministic scan-lock + collections-lock 409 flows
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 28s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m45s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Extends scripts/e2e-functional.sh with the two IEntityLocker 409 contracts the
first cut (ersatztv#299) deferred as "racy", made DETERMINISTIC by firing the
racing request only once the lock is provably held (no sleep-and-hope):

- library-scan "already scanning" 409: seed ~60 tiny ffmpeg clips into the
  built-in Shows library so the scanner subprocess runs a few seconds, poll
  GET /libraries/scan-status until the library is active (that window is a
  strict subset of the scan lock's held window — StartScan after LockLibrary,
  EndScan before UnlockLibrary), then a second POST .../scan is a guaranteed
  409. Self-skips (advisory) when ffmpeg is absent.
- external-collections "already scanning" 409: seed a Jellyfin media-source row
  pointing at a non-routable address so the background sync hangs and the
  per-family lock stays held; the lock is taken synchronously before the 202,
  so the 202 proves it held. collections-scan-status corroborates; unknown
  source 404.

Seeding uses python3's stdlib sqlite3 (already a harness dep) to insert rows the
API can't create (LibraryPath, media-source); WAL mode tolerates the second
writer. No new CI step/dependency — ffmpeg ships in the toolchain image.
Verified: 4/4 fresh-instance runs green (38/38), lock section deterministic.

Still deferred to #363 follow-ups: the playout-build lock 409 + isLocked
projection (#215) and the UI-interactive Playwright flows.

Docs updated same PR: docs/e2e-local.md, docs/ci-cd.md, the functional-e2e
job comment in .gitea/workflows/docker-build.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:17:21 +02:00
timothy 551366aa72 Merge pull request 'feat(293): paginate GET /api/v1/search/all-items to cap DoS exposure' (#442) from feat/293-search-allitems-cap into main
Build ErsatzTV Image / CI image pin matches docker/ci (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 / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m46s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 17m35s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m25s
2026-07-18 12:17:20 +00:00
timothyandClaude Opus 4.8 c57fbf9826 docs(293): note the pageNum upper clamp (MaxAllItemsPageNum) in the decision + api-conventions
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m32s
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 12m24s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13m20s
Fix delta re-review flagged the decision entry + §5 note still described the
pre-fix pageNum = Math.Max(0, pageNum); the shipped code clamps the upper bound
too (0..2_000_000) to stop pageNum*pageSize overflowing int to a 500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:40:20 +02:00
timothy 8a808e6205 Merge pull request 'fix(308): idempotent concurrent Add*ToCollection instead of a composite-PK 500' (#441) from feat/308-idempotent-collection-add into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m55s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m29s
2026-07-18 11:29:01 +00: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 2281f2e764 fix(308): idempotent concurrent Add*ToCollection instead of a composite-PK 500
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 40s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7m25s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m3s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Two concurrent adds of the same item both membership-check it absent, both
insert the CollectionItem composite key, and the loser's
SaveChangesForcingVersion threw an uncaught DbUpdateException (SQLite 19 /
MySQL 1062) -> 500. Now the loser is an idempotent no-op.

- ConcurrencyExtensions.TrySaveChangesForcingVersion: bool-returning sibling
  that catches only a classified unique/PK violation and returns false.
- 10 single-item Add*ToCollection handlers: return Unit.Default (no-op, skip
  fan-out) on false — the racing winner already inserted + rotated + rebuilt.
- Bulk AddItemsToCollection: retry on a fresh context against recomputed
  membership so a partial-overlap collision doesn't drop the non-colliding
  items (bounded loop; common no-collision path runs once).
- Provider detection via a TvContext.IsUniqueConstraintViolation static
  delegate (matches the existing IsSqlite/LastInsertedRowId provider seam),
  wired from Startup to SqliteErrorClassifier / MySqlErrorClassifier.
- Add*ToPlaylist is NOT affected (PlaylistItem has its own identity PK; a
  playlist may legitimately contain the same item more than once).

Tests: a negative-control anchor proves the race genuinely throws a classified
exception; end-to-end handler tests reproduce a real cross-connection race via
a shared-cache SQLite harness + a SavingChanges interceptor (the single-conn
in-memory fixture cannot). Every fix-dependent test verified to fail with the
catch disabled.

Docs: api-conventions.md §7a (idempotent insert under concurrency) +
decisions/optimistic-concurrency.md.

fixes #308

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:02:14 +02:00
timothy 3a463db36a Merge pull request 'feat(425): per-source rotation weights + query corrections for auto-tune channels' (#439) from feat/425-autotune-weighted-sources into main
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 / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m25s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m45s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m8s
2026-07-18 02:29:16 +00:00
timothyandClaude Opus 4.8 3bca000e27 fix(425): address adversarial review — hide owned artifacts from schedule picker
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m34s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent review found a blocker + two mediums:
- BLOCKER: SearchSmartCollections/SearchMultiCollections (the scheduling
  collection picker) didn't filter OwnedByChannelId, so a user could select a
  system-owned auto-tune artifact into their own schedule item — a later channel
  delete would then cascade-delete that schedule item (ProgramScheduleItem->
  MultiCollection is OnDelete(Cascade)). Both handlers now exclude owned rows;
  regression test added (CollectionPickerHidesOwnedTests).
- MEDIUM: TryStampOwnership now runs on CancellationToken.None (post-commit
  convention #254) and stamps the MC + member SmartCollections in one
  transaction, so a late cancel or mid-way failure can't leave a permanent
  orphan / list leak.
- Documented the movie-remainder weight clamp bound (>1000 un-touched movies
  can't express the exact ratio — the 1..1000 weight-column bound from #70).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:52: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 0a418a7c53 Merge pull request 'feat(176): visual rule builder for the SmartCollection editor' (#433) from feat/176-smartcollection-rule-builder into main
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 / 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 5m4s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 14m44s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 16s
2026-07-18 01:30:21 +00:00
timothy 3b5a78a08f fix(176): compile date values to index yyyyMMdd format; guard Advanced toggle
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 / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17m57s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 20m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 25m57s
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 44m33s
2026-07-18 02:43:54 +02:00
timothy c91c34f113 docs(176): decisions + spa-conventions for the SmartCollection rule builder 2026-07-18 02:43:54 +02:00
timothy ea6b509016 fix(176): guard Builder toggle for out-of-subset queries; resync query on reseed 2026-07-18 02:41:50 +02:00
timothy 8483af41fc docs(176): plan Task 8 — guard Builder toggle + resync query (matches shipped fix) 2026-07-18 02:41:50 +02:00
timothy 60e8655a00 feat(176): Builder|Advanced toggle in the SmartCollection dialog 2026-07-18 02:41:50 +02:00
timothy 2763f65b35 test(176): cover RuleBuilder field-change operator/value reset 2026-07-18 02:41:50 +02:00
timothy ceb9069bae feat(176): RuleBuilder component 2026-07-18 02:41:50 +02:00
timothy 7d553ef00c refactor(176): expose clean non-null RuleField from useSearchFields 2026-07-18 02:41:50 +02:00
timothy 279c0d6ce5 docs(176): plan — hook exposes clean non-null RuleField (Core DTOs are nullable-disabled) 2026-07-18 02:41:50 +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 03646a2ed9 test(176): compile/parse round-trip property test 2026-07-18 02:41:50 +02:00
timothy 5186bed910 docs(176): plan — broaden round-trip generator to special chars; escaping now total 2026-07-18 02:41:50 +02:00
timothy 218e1810cb fix(176): total escaping so wildcard/quoted values round-trip (parse review) 2026-07-18 02:41:50 +02:00
timothy 0b5258533a feat(176): Lucene-subset parser (exact inverse of compiler) 2026-07-18 02:41:50 +02:00
timothy bc524d0b53 fix(176): split fulltext matches/notMatches from text contains 2026-07-18 02:41:50 +02:00
timothy 7f7062729d docs(176): fix plan operator model — split fulltext matches/notMatches from text contains (Task 3 review Critical) 2026-07-18 02:41:50 +02:00
timothy fd786da649 feat(176): rule model + Lucene-subset compiler 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 41df582032 feat(176): search field catalog query + curated list 2026-07-18 02:41:50 +02:00
timothy aa4127fcef docs(176): fix plan test-project path + parse.ts self-review nits; add SDD ledger 2026-07-18 02:41:50 +02:00
timothyandClaude Opus 4.8 8c7af0e202 docs(176): implementation plan — SmartCollection visual rule builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 02:41:50 +02:00
timothyandClaude Opus 4.8 71159c1a8a docs(176): design spec — SmartCollection visual rule builder (sub-project #1)
Scopes #176 down after recon (Autotune #69 + composite create-channel #63
already shipped): the remaining leg is the visual WYSIWYG rule builder.
Kodi one-level-nested model, compile-only to a closed Lucene subset (no
schema change), backed by a new read-only GET /api/v1/search/fields catalog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 02:41:50 +02:00
timothy 6204e30c7c Merge pull request 'feat(386): Auto-Tune per-channel DetailPanel slide-over (SPA)' (#432) from feat/386-autotune-detailpanel into main
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) Successful in 7m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m19s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Failing after 13m7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Failing after 13m15s
2026-07-17 23:54:38 +00:00
timothy 56fffacfd4 fix(386): address cold-review findings on the DetailPanel
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m16s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 17m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Cold adversarial review (no blockers, 3 real Mediums):
- Query&size "Order" row ignored the Shuffle toggle (dead ?? fallback showing
  the static axis order); now reflects shuffleOn, consistent with the subtitle.
- Clearing Name/Number to '' flagged the row Edited + armed the unsaved-changes
  guard, but the payload reverted to the proposal default — the two "edited"
  derivations disagreed. overrideEdited now ignores an empty (inherited) value.
- runPreview did not clear per-channel overrides, so edits (incl. pinned numbers)
  silently carried into a re-generated batch → collision risk. Fresh preview now
  resets overrides/detailKey/logo cache.
Tests: empty-name-not-Edited + re-preview-clears-overrides.

Live-E2E (local instance, seeded TV library): preview → Configure → toggle
Shuffle → Create verified end-to-end; the created channel's schedule shows
PlaybackOrder=Shuffle (overridden) vs SeasonEpisode (axis default), confirming
the per-channel override flows UI → SPA → backend → playout.

Refs #386
2026-07-18 01:31:40 +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
timothy 0a34d1a585 Merge pull request 'feat(164): guided remediation for health checks' (#430) from feat/164-health-checks-ux into main
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 / 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 / Build & test (.NET) (push) Successful in 7m1s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m5s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m0s
2026-07-17 23:12:19 +00: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
timothy 1abc5df18c Merge pull request 'feat(385): per-channel overrides in auto-tune bulk-create' (#428) from feat/385-autotune-overrides into main
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 / 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 5m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m47s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 17m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m51s
2026-07-17 22:03:53 +00:00
timothyandClaude Opus 4.8 b9ab2d79f3 test(385): cover override-driven per-channel failure isolation
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m33s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m4s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m57s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11m58s
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
Adds the one coverage case the cold review flagged (LOW): a two-channel batch
where the first channel's advanced override is rejected downstream and the
second, override-free channel is still Created — directly exercising the
partial-success / batch-independence guarantee for the new override path.
Test-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:33:10 +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
timothy ba707f2e23 Merge pull request 'ci: pre-push guard against pushing an uncommitted working-tree change (H13)' (#424) from ci/dirty-worktree-guard into main
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 / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m19s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 19m15s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m41s
2026-07-17 21:25:30 +00:00
timothy 1df08e86bb ci: pre-push guard against pushing an uncommitted working-tree change (H13)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 33s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
A file in the pushed diff that still has uncommitted working-tree/index changes
means the pushed commit doesn't match what you built/reviewed — the #416
index/worktree trap, where a --no-renames review fix lived only in the working
tree and shipped without being committed (CI, the push, and the reviewer each
saw a different tree). New fail-open pre-push hook blocks that precisely (only
files in the branch's diff vs origin/main), after the H11 rebase check. Escape:
ETV_ALLOW_DIRTY_PUSH=1. Documented in decisions.md.

Refs #416
2026-07-17 23:05:30 +02:00
timothy 5e0c53c339 Merge pull request 'docs(lore): run local gate + cold review BEFORE pushing to Gitea CI' (#426) from docs/review-before-ci 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 / Build & test (.NET) (push) Successful in 8s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20s
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 & push image (amd64) (push) Successful in 7s
2026-07-17 20:59:34 +00:00
timothy eb1aba99c1 docs(lore): run local gate + cold review BEFORE pushing to Gitea CI
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
The handoff PR-routine and HARD-CONSTRAINTS placed the cold adversarial review
AFTER push/PR-open. New standing rule (Timothy, 2026-07-17): local gate + cold
review run BEFORE the push; fold pre-push fixes into the branch; CI only
confirms. Runners should see only already-validated work — a Gitea run can't be
cancelled, so a run spent on a lint/review-caught issue is pure waste. Also notes
docs-only PRs are seconds now (ersatztv#416).
2026-07-17 22:39:42 +02:00
timothy 4818b07668 Merge pull request 'fix(416): docs-only skip never fired — detect against FETCH_HEAD (shallow-checkout safe)' (#429) from fix/416-shallow-detect into main
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) Successful in 7m12s
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 6m2s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-17 20:39:04 +00:00
timothy 082124f706 docs(416): note shallow-checkout FETCH_HEAD/two-dot fix (ci-cd + decisions)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #416
2026-07-17 22:02:06 +02:00
timothy 74005cc952 fix(416): detect against FETCH_HEAD with a two-dot diff (shallow-checkout safe)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
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 / Build & test (.NET) (pull_request) Successful in 7m39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m54s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The test/migrations jobs check out fetch-depth:1. A shallow clone has NO
origin/<base> tracking ref and no merge-base, so the three-dot
origin/main...HEAD errored -> empty diff -> docs_only=false -> EVERY docs-only PR
silently ran the full matrix (safe but the skip never fired). git fetch always
writes FETCH_HEAD, which resolves in a shallow clone; diff against it with a
two-dot tree diff (no merge-base). Confirmed in a real shallow file:// clone:
origin/main did NOT resolve and three-dot errored, while FETCH_HEAD two-dot
correctly returned the docs file. api-docs/format were unaffected only because
they use fetch-depth:0.

Refs #416
2026-07-17 21:33:53 +02:00
timothy 1b355660c5 Merge pull request 'fix(320): break troubleshoot segment-wait loop on ffmpeg failure' (#423) from fix/320-troubleshoot-segment-wait-isfailed into main
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 / 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 / Build & test (.NET) (push) Failing after 24m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m13s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m34s
2026-07-17 18:43:07 +00:00
timothy 9c72aebab0 Merge pull request 'ci(416): skip heavy jobs on docs-only changes without bricking the merge gate' (#422) from ci/416-docs-only-skip into main
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 / EF migration integrity (SQLite + MySql) (push) Successful in 5m59s
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 5m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Reviewed-on: #422
2026-07-17 18:10:24 +00:00
timothyandClaude Opus 4.8 de63603aab test(320): don't blanket-delete foreign *.ts in the shared troubleshooting folder
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m30s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m45s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review nit: the regression test deleted every *.ts in the machine-global
TranscodeTroubleshootingFolder, which could nuke a live troubleshooting session's
segments on a dev machine (reaping files it didn't create). Drop the sweep and
keep only Directory.CreateDirectory — the folder-exists guarantee is what closes
the false-pass hole; NUnit runs serially and no test leaves >= 2 stray .ts, so
determinism is unaffected (negative control re-verified: still fails in ~10s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:03:10 +02:00
timothyandClaude Opus 4.8 5f8525eed7 fix(320): address cold-review nits — drop redundant cancel term, harden test
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m49s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Cold review (PR #423) found two LOW nits:
- The segment-wait break also checked cancellationToken.IsCancellationRequested,
  which could exit into the Ok gate and return a spurious 200 for a cancelled
  request with no segments. The term is redundant (Task.Delay already throws on
  cancel -> caught -> 404), so break on notifier.IsFailed only.
- The regression test's non-vacuousness was filesystem-state dependent (absent
  troubleshooting folder -> Directory.GetFiles throws -> false pass pre-fix).
  Seed an empty folder like the sibling success test so the spin path is
  deterministic regardless of NUnit run order.

Negative control re-verified: removing the IsFailed break fails the test in ~10s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:57:55 +02:00
timothyandClaude Opus 4.8 b2059bd2a6 fix(320): break troubleshoot segment-wait loop on ffmpeg failure
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m48s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 51s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m26s
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 12m43s
The second (segment-readiness) poll in POST /api/v1/troubleshoot/playback/start
only checked !hasSegments. If ffmpeg died after writing the HLS playlist but
before the first segments appeared, the loop spun until the client cancelled —
tying up the request thread and holding the troubleshooting lock (worse since
#301 moved the manifest fetch server-side per #320's writeup). Mirror the first
(playlist) wait loop's exit check so a dead ffmpeg or client cancellation breaks
the wait; the existing !IsFailed guard then falls through to the terminal
NotFoundProblem, releasing the thread + lock.

Adds a non-vacuous regression test (verified it fails on the pre-fix spinning
loop via a bounded cancellation deadline).

fixes #320

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:50:54 +02:00
timothy f7b97adce8 ci(416): harden docs-only detection with --no-renames (review finding)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m6s
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 15m9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The changed-set git diff had rename detection on by default, so a code->docs
rename (Foo.cs -> docs/Foo.md) showed only the destination and was misclassified
as docs-only, skipping required tests on a code change. --no-renames surfaces the
source deletion -> full matrix. Empirically verified. This is the cold-review
MEDIUM; it was applied in the working tree but never committed before the first
push (index/worktree mismatch) — committing it now.

Refs #416
2026-07-17 19:44:25 +02:00
timothy 92bb63b6da docs(416): document docs-only CI skip
ci-cd.md gains a 'Docs-only skip' section + triggers-table note; decisions.md
records the decision. Cross-refs the separate PR-vs-main rerun redundancy (#420)
and the within-run triple build (#398).

Refs #416
2026-07-17 19:44:25 +02:00
timothy 5ba737bec7 ci(416): skip heavy jobs on docs-only changes
Docs-only changes (docs/** or *.md) ran the full docker-build matrix (~9 min).
Each heavy job (test, migrations, functional-e2e, build) now runs
scripts/ci-detect-docs-only.sh as its first post-checkout step and gates every
real step on docs_only!='true'. The jobs still RUN and report success in
seconds, so the two required contexts keep reporting — a docs-only PR stays
mergeable (never an if:-skipped required job; Gitea 1.25.4 reports if-skip as
'skipped', verified with a throwaway probe PR). build skips its image steps on a
docs-only push to main; tag builds force docs_only=false. Detection uses
--no-renames so a code->docs rename can never be misclassified as docs-only.

Refs #416
2026-07-17 19:44:25 +02:00
timothy ffa8e5a000 Merge pull request 'fix(376): XML-escape access_token value in XMLTV guide output' (#419) from fix/376-xmltv-token-escape into main
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 / 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 5m23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 19m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m19s
2026-07-17 17:34:24 +00:00
timothyandClaude Opus 4.8 bf15677dc6 fix(376): XML-escape access_token value in XMLTV guide output
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m2s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 47s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
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
`GetChannelGuideHandler` interpolated `request.AccessToken` (HTTP-request-
derived, from `?access_token=`) raw into the pre-built XMLTV cache fragments.
A token containing `&`, `<`, `>`, or `"` would emit invalid XML and malform
the entire guide. Escape it with `SecurityElement.Escape`, consistent with
how #340 escaped `{RequestBase}`.

The M3U path (`ChannelPlaylist.ToM3U`) also interpolates the token but M3U is
not XML, so escaping there is neither needed nor correct — left unchanged.

Regression test `Guide_xml_escapes_access_token` drives the real handler with
a token containing all four XML-special chars and asserts the output is
escaped (sibling to the #340 `Guide_xml_escapes_advertised_base_url` test).
Verified non-vacuous: it fails with the escape reverted.

fixes #376

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 19:04:42 +02:00
timothy fa7e8d75c4 Merge pull request 'feat(70): weighted / fair-share content distribution (WeightedShuffle playback order)' (#402) from feat/70-weighted-distribution into main
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 / EF migration integrity (SQLite + MySql) (push) Successful in 5m35s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 4m53s
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 / Build & test (.NET) (push) Successful in 18m26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7m40s
2026-07-17 17:00:46 +00:00
9752111240 docs(70): fix the last stale claim — found by reading every weight comment, not by grepping verbs
Round-4 review returned BLOCKED, and its diagnosis is the sharpest of the four:
my "re-derived by grep" claim was false. The reviewer's grep included `dropped`;
mine had `drop(ped)? every` and missed the bare word — so
MultiCollectionConfiguration.cs:38 ("a 0-weight source is dropped by the
enumerator") never matched. That is the same failure as the previous round, one
level up: I replaced a list of SITES with a list of VERBS I guessed and called it
a class. Fourth consecutive incomplete completeness claim, and again the code twin
of a doc line I had already corrected.

The site is now accurate. The DB default of 1 is still right, but for the reason
decisions.md already gives: a 0 backfill means nothing on a share-of-airtime scale.
The enumerator clamps such a row to the floor, so it rotates rather than vanishing
-- the drop it cited cannot happen since EffectiveWeight replaced the Weight > 0
filter, and A_Non_Positive_Weight_Does_Not_Delete_The_Source pins that.

Method changed, not just the text: instead of guessing which verb a stale claim
might use, enumerate EVERY comment/doc line mentioning weight across the diff and
read them. 41 lines, one false. Both automated passes then produced false
positives on different subjects -- a line-based check flagged EffectiveWeight's
"must not drop..." (the framing verb wraps to the previous line), and a
sentence-aware one flagged ReplacePlaylistItemsHandler's "the item is dropped from
the playlist" (true, and about unknown ORDERS, not weights: PlaylistEnumerator
really has 0 default arms and gates on `enumerator is not null` at :210). Both were
read and cleared rather than counted, which is the only reason this message can say
what it says.

Verification: Build 0 errors; Core.Tests 566; ErsatzTV.Tests 1673; 0 failed; no BOM
on any touched non-generated .cs; decisions-guard exit 0.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
caaae4cd00 docs(70): re-derive the stale-claim fix by grep instead of working the review's list
Round-3 review returned BLOCKED: must-fix (b) was not closed. It was right, and
the root cause it named is the point of this commit — the previous correction
"was scoped to the four sites the reviewer listed rather than re-derived by grep".
Fixing the list is not fixing the class. That is the same failure as B1, where the
gate covered the two writers already in hand and missed CreateChannelFromLineup.

Re-grepped the behavior class instead. Three survivors, two of them missed and one
freshly introduced by the correction itself:

- CreateMultiCollectionHandler.cs — the create twin of a comment whose UPDATE twin
  I corrected and whose create twin I never opened. Present tense, and contradicted
  by two tests in this same PR.
- decisions.md — corrected one line in that file and left its sibling.
- MultiCollectionItemWeight.cs (and its decisions.md mirror) — the ceiling rationale
  still claimed unbounded weights overflow the sum. They cannot: EffectiveWeight
  clamps before every sum and CycleLength widens to long. The earlier pass
  pattern-matched on the word "filtered" and left the identical defect on the
  ceiling. The ceiling's real job is the floor's argument — a billion is not a share
  of airtime any more than 0 is — so it now says that, and credits the clamp with the
  arithmetic safety it actually provides.

Also corrected the writer claim to the right predicate: not "two persisting writers"
(Add*ToPlaylist and Trakt persist it too, hardcoded) but two writers that persist a
CALLER-SUPPLIED order. The full set is now classified persists-caller-value /
persists-hardcoded / in-memory, including Engine/PlaylistHelper, which the previous
"two Preview handlers" phrasing missed. That bullet has been wrong three times in
the same shape; it now says so, since a lesson that keeps being re-learned is worth
recording as a pattern rather than a fact.

The BOM check caught this commit re-adding a BOM to the one file patched with
utf-8-sig — the same trap, an hour after writing it down. Stripped; the mechanical
pre-push check is what makes that survivable.

Core.Tests 566, ErsatzTV.Tests 1673, 0 failed. Format verify exit 0. decisions.md
+90/-0 (append-only guard green).

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
7446ce0293 test(70): classic-weighted golden — weight beats collection size end-to-end
The unit tests pin the enumerator's sequence in isolation; this pins that the real
PlayoutBuilder actually distributes by weight, through the whole chain:
MultiCollection -> MediaCollectionRepository -> CollectionWithItems.Weight ->
ShuffleSourceBuilder -> WeightedShuffleCollectionEnumerator -> PlayoutItems.

The fixture is deliberately lopsided: the HEAVY source (weight 3) is the SMALL
collection (2 items) and the LIGHT source (weight 1) is the LARGE one (4). Over
the pinned 2-day window that yields 81 : 27 = exactly 3.00 : 1 — the smaller
collection taking the larger share, which is the entire point of the feature and
is not reachable by any existing order: Shuffle is size-proportional (~2:4 here)
and ShuffleInOrder plays every item exactly once per cycle. Longest consecutive
same-source run is 3, so it interleaves smoothly rather than draining blocks.

Same determinism contract as Classic_shuffle: pinned Playout.Seed + Continue mode
(Reset randomizes the seed). Channel number/GUID 5 — every golden fixture shares
one in-memory DB, so those must be globally unique.

The net is armed, not decorative: with the golden absent the test fails (missing
golden is a hard failure by design), and regenerating added only classic-weighted
— the three existing goldens are byte-identical, so nothing was silently
re-baselined.

Core.Tests 566 passed, 0 failed.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
0295a8a9f6 style(70): strip UTF-8 BOM from the .cs files this PR touches
CI's Formatting job failed: 19 touched files carried a BOM, which .editorconfig
forbids (charset=utf-8). Pure encoding change — one byte per file, no semantic
diff (verified: every hunk is `-namespace` -> `+namespace`).

Self-inflicted. The patches that edited these legacy files wrote them back as
utf-8-sig to "preserve the existing style", but the #311 fix-as-you-touch gate
requires a file to be normalized when you touch it — that is the whole point of
scoping the gate to changed files instead of reformatting the ~2500 legacy BOM
files at once. dotnet format leaves the EF-generated Designer/snapshot files
alone as generated code, and its verify skips them the same way, so they stay as
ef emitted them.

Two corrections to what I believed going in:
- `dotnet format --include` does NOT no-op here. It reported `error CHARSET` for
  each file and exit 2, reproducing CI exactly, and fixed them in place. The note
  claiming otherwise is wrong for this invocation.
- My first BOM check reported all files clean. The od pattern was wrong; reading
  the first three bytes directly found 19. A detector that can only say "ok" is
  worse than no detector.

Core.Tests 565, ErsatzTV.Tests 1673, Architecture.Tests 5 — all passed. API
artifacts still in sync.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
ccef0ca88a fix(70): test the weight gate; correct rationale my own clamp made false
Re-review of the fix commit returned MERGEABLE-WITH-NITS. It verified the gate is
now complete by enumerating the writers itself (no fourth persisting writer) and
proved B2's fix works by writing throwaway handler tests — which was also its
point: the fix shipped with none.

B2 was create and update silently DISAGREEING on the same input, and the fix
re-established agreement with nothing pinning it. Both paths are now driven from
one shared case list, plus an explicit test that create and update agree on every
case — the per-path tests would both have passed while the two diverged, which is
how the bug existed in the first place. Non-vacuity proven: inverting only the
update path's validation fails 10 of 20 on a clean build (0 errors, so not a
stale-dll pass), and the agreement test is among the failures.

The rest is my own prose contradicting my own code. The commit that added
EffectiveWeight removed the weight filter, then left four statements asserting a
0-weight source "is filtered out" — two of them authored by that same commit,
including the stated justification for Minimum=1 in MultiCollectionItemWeight.
A future agent could have read that and deleted the clamp or the floor as
redundant; they are belt-and-braces and neither is. Corrected to describe what the
code now does: the gate refuses input that means nothing on a share-of-airtime
scale, the clamp protects rows predating the gate.

Also corrected the writer count in the very bullet whose lesson is "grep every
writer of the field": ReplaceBlockItems writes BlockItem.PlaybackOrder, not
PlaylistItem.PlaybackOrder. There are TWO persisting writers of PlaylistItem's,
and the correction itself had miscounted by conflating the two fields — so the
lesson now says to grep each field separately.

Core.Tests 565 passed, ErsatzTV.Tests 1673 passed, 0 failed.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
c0da414a4c fix(70): close the review blockers — third playlist writer, weight bounds, overflow
Adversarial review of PR #402 returned BLOCKED. It could not break the WRR math or
the stateless-restore claim (it probed restore across wraps at indices 12/13/20/37
— all held, and the clamp preserves a 1000:1 ratio exactly). What it broke was the
perimeter.

B1 — the validation gate had a hole, so the silent-drop bug shipped.
CreateChannelFromLineup is a THIRD writer of PlaylistItem.PlaybackOrder; its own
guard only covered MultiCollection entries, so a 2+ entry lineup of plain
collections persisted WeightedShuffle straight through to PlaylistEnumerator's
null-drop. My decisions.md claim that "the silent sites never see it" was false as
written — corrected in place, with the lesson recorded: grep every writer of the
field, the non-obvious composite handler is the one that gets missed. The
Add*ToPlaylist handlers are safe only because they hardcode their order.

B2 — Weight had no validation at all, and create/update disagreed on the same
input. EF's HasDefaultValue(1) substitutes 1 for a 0 on INSERT (0 reads as "not
set") but an UPDATE writes the 0 through — and a 0-weight source was filtered out
of the rotation, deleting it from the channel silently. Exactly the failure this
order is careful to avoid everywhere else. Now bounded 1..1000 by a shared
MultiCollectionItemWeight used by both paths so they cannot drift, and clamped
again in the enumerator for rows that predate the gate.

B3 — Sum(weights) is checked arithmetic, so two int.MaxValue weights threw
OverflowException from inside a playout build. Reachable through the API precisely
because of B2. The ceiling fixes both; the sum also widens to long.

M1 the lineup mirror now allows WeightedShuffle for multi collections, matching the
PlayoutModeMustBeValid change it claims to mirror. M3 ScheduleAsGroup is documented
as deliberately unread by this order. L1 MinimumDuration is computed over every
source instead of the current rotation — under the clamp a rotation is a strict
subset and is rebuilt each wrap, so caching over it went stale. L2 the retry guard
keys off the rotation, not the raw collection count.

N1 the tautological default test is gone: it built entities in C#, so it asserted
the property initializer, not the migration — it could not have failed. Replaced
with clamp, overflow, and cross-wrap restore cases (the property the review proved
but found unpinned).

H1 the two follow-ups the PR body claimed were "filed" did not exist. Now filed:
#403 (silent dispatch-fallback hardening) and #404 (SPA weight UI, blocked-by #388).

Core.Tests 565 passed, ErsatzTV.Tests 1643 passed, 0 failed.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
953481c177 docs(70): record the WeightedShuffle design and correct the ShuffleInOrder misreading
decisions.md entry covers why one enum value rather than two or a separate
setting, why the weight lives on both multi-collection join entities with a DB
default of 1, why it can only be applied on the ShuffleInOrder-shaped path (source
identity is destroyed on the Shuffle path), why it is stateless, and why
cross-engine exposure is closed by write-path validation instead of by changing
five shipped fallbacks.

The load-bearing part is the ShuffleInOrder clarification. Its balanced-shuffle
padding reads as equalization, but the spacers emit nothing — one cycle plays
every item once, so airtime stays proportional to collection size. It is
anti-clumping, not fair-share. That was misread during this issue's own design
pass and would have collapsed #70 to "already shipped"; the distinction is the
whole justification for the feature, so it is now written down in both
decisions.md and the domain-model glossary rather than left to be rediscovered.

domain-model.md gains a Playback order row contrasting Shuffle / ShuffleInOrder /
WeightedShuffle, and notes on the multi-collection row that the two join entities
are mirrors.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00: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
dd7c155b39 test(70): prove per-source Weight reaches the scheduler for both member kinds
A multi collection holds plain collections and smart collections through two
separate join entities that build CollectionWithItems at separate call sites.
Only the plain one is obvious, and forgetting the smart mirror un-weights
smart-collection members with nothing reporting it — so it gets a test rather
than a comment.

Three tests: both weights survive the round trip; an unweighted row reads back as
1, not 0 (a 0-weight source is filtered out by the enumerator, so a 0 default
would silently drop every pre-existing member from a weighted rotation); and the
real MediaCollectionRepository hands the weight to the scheduler — the mapping
tests alone would pass even if the repo dropped it on the floor.

ErsatzTV.Tests: 1641 passed, 0 failed.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
f394c721a8 feat(70): refuse WeightedShuffle where it isn't implemented, report where it can't be
WeightedShuffle is implemented for classic schedule items only, and every other
engine mishandles an order it doesn't know *silently*: PlaylistEnumerator has no
default arm so the item is dropped from the playlist; BlockPlayoutBuilder filters
block items against an allow-list and `continue`s past the rest; YAML and Scripted
return None, which their callers' foreach reads as "no content". A weighted order
degrading to unweighted random or to nothing is the worst failure mode here,
because the output is supposed to look arbitrary — nobody would notice.

Rather than change those shipped fallbacks (a real defect class, but pre-existing
and wider than this feature — filed separately, non-goal here), this closes the
new exposure at the write path: ReplacePlaylistItems and ReplaceBlockItems reject
WeightedShuffle with an error naming where it is available. If it can't be
persisted where it isn't handled, the silent sites never see it.

Defence in depth for the two engines that address orders by name: YAML and
Scripted now log a warning when a parsed order falls through unhandled, so an
empty schedule explains itself. Enum.Parse accepts "weightedShuffle" the moment
the value exists, so the gate above can't cover them. EnumeratorForContent becomes
an instance method to reach the logger.

ProgramScheduleItemCommandBase lists WeightedShuffle explicitly as valid for multi
collections — it already passed by falling through the switch, and implicit-by-
omission is how this subsystem grew its silent paths.

Core.Tests: 558 passed, 0 failed.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
07cb8c0287 test(70): pin WeightedShuffle semantics; fix unbounded reshuffle retry on wrap
Ten tests pin the distribution contract exactly rather than statistically,
because the sequence IS the product decision: 3:1 emits A A B A (spread, not
drained); equal weights air a 2-item source as often as a 20-item one; ties break
to the earliest source; a defaulted weight behaves as fair-share (guarding the
migration default); restoring at an index equals advancing to it (the stateless
contract that lets CollectionEnumeratorState carry this order).

Fixes a hang found by the non-vacuity control. When a rotation wraps, MoveNext
retried the rebuild to avoid an immediate repeat. ShuffleInOrder can do that
unbounded because its reshuffle randomizes the lead item — but this order's lead
is decided by weight, so the heaviest source always leads, and when it holds a
single item the lead never changes and the retry never terminates. Two single-item
collections with unequal weights would wedge the playout build. The retry is now
bounded: avoiding a back-to-back repeat is a nicety, not terminating is not.
Regression test walks several wraps under a timeout.

Non-vacuity proven per repo lore: inverting the real WRR pick (max -> min, never
if(true), which trips CS0219 under warnings-as-errors and silently serves a stale
dll to --no-build) failed 5 of 10 tests on a clean build (0 errors, so not a
stale-dll false pass). Reverted; 10/10 green.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
68be319403 feat(70): WeightedShuffle playback order — weighted / fair-share distribution
Adds PlaybackOrder.WeightedShuffle = 9 (Classic engine only) plus the
per-source weight it distributes by.

Why a new order rather than making ShuffleInOrder weight-aware: ShuffleInOrder's
balanced shuffle pads sources to equal length with Option.None spacers, and
spacers emit nothing — so one cycle plays every item exactly once and airtime
stays proportional to collection size. It prevents *clumping*, not *domination*.
Retrofitting weights onto it would silently change shipped users' output.

WeightedShuffleCollectionEnumerator picks a source by smooth weighted
round-robin (acc += weight; richest wins; pays the total), then takes that
source's next item. Weights 3:1 emit A A B A; ties break to the earliest source
in list order. Fair-share is the equal-weights default, so one mechanism covers
both behaviors in the issue.

One rotation is sized so the source needing the most picks works through all its
items at its share; smaller sources loop within it — that looping is what makes
equal weights mean equal airtime regardless of library size. The sequence stays a
pure function of (Seed, Index), so it restores by replay like its siblings and
needs no per-source persisted counters.

It consumes ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder unchanged —
the schedule-entity-free entry point #380 reserved for this issue. Source
identity is destroyed on the Shuffle path (MultiCollectionGrouper collapses to
GroupedMediaItem + Distinct), so the weight is only reachable via
CollectionWithItems on the ShuffleInOrder-shaped path.

Weight is persisted on MultiCollectionItem AND MultiCollectionSmartItem — the
mirror is mandatory; omitting the smart item silently un-weights smart-collection
members. Both carry a DB default of 1: without it existing rows would migrate to
0, and a 0-weight source is dropped by the enumerator.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00
timothy 30415d12f9 Merge pull request 'docs(lore): batching, no "main checkout", trust the queue, diagnosing CI reds' (#417) from docs/lore-batching-rules into main
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 / 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 / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (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
docs(lore): batching, no "main checkout", trust the queue, diagnosing CI reds

Standing-lore corrections from the #72 session. Docs-only.
2026-07-17 16:43:33 +00:00
timothy fe6e2722f8 docs(lore): batching, no "main checkout", trust the queue, killed≠failed
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m1s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m5s
Operator-requested after PR #405 pushed 5 times, orphaning live runs the
operator had to cancel by hand. Batches every standing-lore correction this
session produced into one commit (per the batching rule it adds).

New HARD CONSTRAINTS:
- BATCH PUSHES. Cancellation is impossible from the agent side on Gitea 1.25.4
  — REST .../runs/{id}/cancel and MCP cancel_run both 404, and the web-UI route
  needs a session+CSRF that doesn't script. Only the operator can cancel, so an
  orphaned run holds a runner slot until it finishes. If you must supersede a
  live run, SAY SO.
- TRUST THE GITEA BUILD QUEUE. Do not gate/throttle a push on host health; the
  runners were retuned for stability. Batch because you can't cancel what you
  orphan, not to protect the host.
- BOM-CHECK touched .cs before pushing. The #311 gate is fix-as-you-touch, and
  it bit two sessions the same day (PR #405 ×6; #70/PR #402 ×19 via Python
  utf-8-sig writing BOMs back). Verify your detector — an od-based grep reported
  all-clean while 19 files were dirty.
  Corrects a claim I nearly published: `dotnet format --include` DOES work here.
  The apparent no-op was the SHELL — CI's mapfile is bash-only, zsh has no
  mapfile → empty array → zero files → exit 0. Run it under bash -c.

THERE IS NO "main checkout" — the biggest correction here.
/Users/timothy/ersatztv is a shared mutable working tree whose HEAD is whatever
the last session left there. Its name lies, and it bit TWO sessions on
2026-07-17, both doing the obvious thing: one assumed main and committed onto
the #604/CI session's branch 24s after that session's own commit; another read
git log there and concluded main was "4 behind origin" — a phantom. Framed as a
design flaw, not a discipline failure: "check git status first" appears to
confirm the false assumption and then goes stale (it WAS on main at 12:46 and
wasn't by 14:17). Worktree discipline itself is healthy — 10 feature worktrees.

Diagnosing CI reds — three ways to misread one, all hit this session:
- A KILLED job reports conclusion=failure, not cancelled. The tell is a log that
  stops mid-step with NO error and NO failure marker. A runner retune killed run
  1006's migration + E2E on a BOM-only diff that couldn't break them. Log
  timestamps are UTC, host is UTC+2 — convert before correlating.
- cancelled ≠ failure: a cancel is NO verdict, and a run marked failure may hold
  a genuine job failure from BEFORE the cancel. Monitors must count FAILED and
  CANCELLED separately.
- "Unable to pull refs/heads/v4" is act refreshing its action cache and is
  followed by "Cloned …" — noise, not a cause. Grep the failure marker, not
  the word "error". An infra-shaped red (setup/cache step, before your code
  compiles) is not a code failure; don't file a CI bug off one sample.

Also: the cheap selector's failure modes are wider than deps+priority — it also
misses in-progress claim state and umbrella-vs-child.

Docs-only.
2026-07-17 18:15:32 +02:00
timothy ac7c978824 Merge pull request 'fix(72): report a real per-channel playout count and flag channels that will never play' (#405) from feat/72-164-channel-health into main
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 / 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 / Build & test (.NET) (push) Successful in 17m30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m17s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Fixes #72

GET /api/v1/channels/{id} reported playoutCount: 0 for every channel on the
system — ChannelRepository.GetChannel never included Playouts, and the read is
AsNoTracking with no lazy proxies, so the count could only ever evaluate to 0.
That silently disabled the channel editor's playout-source guard. Both call
sites now share Mapper.GetPlayoutsCount (Mirror-aware), and the lean list DTO
gains PlayoutCount so the channels list flags a channel that will never play.

Deferred deliberately, now tracked: #414 (origin marker, blocked-by #383) and
#415 (empty-schedule + broken-source attribution). Related: #401 (silent
Mirror→Generated coercion).

Review-verdict: MERGEABLE @ 1e35248edf
2026-07-17 16:13:51 +00:00
timothy 1e35248edf style(72): de-BOM the six touched .cs files (#311 formatting gate)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 4s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m36s
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 36m51s
CI's "Formatting (changed .cs conform to .editorconfig)" job failed with
`error CHARSET: Fix file encoding` on all six pre-existing BOM'd files this PR
touches. The #311 gate is fix-as-you-touch: any .cs a PR touches must conform to
.editorconfig (charset=utf-8), and these carried BOMs inherited from upstream.

BOM removal only — six files, one line each, zero content change. The three
files already without a BOM (GetChannelByIdForApiHandler + both new/changed
Tests files) needed nothing.

Note for the next person: this cannot be validated locally on this Mac —
`dotnet format --include` silently no-ops here, so the gate is only observable
in CI. Check `head -c3 <file> | xxd -p` for `efbbbf` on every touched .cs before
pushing instead of trusting a local format run.

Refs #72
2026-07-17 17:51:43 +02:00
timothy d92e717ebb docs(72): fix the stale "Problems" filter name in domain-model (re-review)
Re-review, Low: domain-model.md still documented a "Problems" filter — a line
added by 50005b12 earlier in this same PR, so the PR contradicted itself once
38483681 renamed the filter to "No playout".

Ironic in exactly the way the rename is about: the next person implementing the
deferred fault classes would read the doc, see "Problems" documented, and
restore the over-promising label that ChannelsScreen's own comment asks them
not to. Stale docs re-introduce the bug the code comment defends against.

decisions.md needed no change — it only ever named the badge, never the filter.

Refs #72
2026-07-17 17:51:43 +02:00
timothy 7e9c52b035 fix(72): name the filter for the one fault it can prove (review)
Adversarial review, Medium: the "Problems" filter over-promised. The badge is
honestly named ("No playout"), but the filter claimed a taxonomy that does not
exist — decisions.md enumerates three fault classes this deliberately does NOT
compute (empty schedule behind a playout, broken source, origin). A user whose
Classic schedule is empty would read "Problems 0" as "lineup healthy" and ship
a dead channel. A false all-clear is worse than no affordance.

Renamed the filter to "No playout" so badge and filter both name exactly the
one fault the API can prove, leaving "Problems" free for when the taxonomy
behind it actually exists. Rationale recorded at the predicate so the next
person doesn't "improve" the label back.

Also from the review:
- The zero-playout test's comment claimed include coverage it does not provide
  (it passes with or without the include — 0 == 0). Re-stated as what it is: a
  mapper boundary check. Its two siblings are the include coverage.
- UpdateChannelHandler:179 is a fourth call site the "shared by all three"
  framing excluded. Harmless (the controller discards the view model and
  re-projects through GetChannelByIdForApi), but a trap: its query lacks the
  MirrorSourceChannel include, so swapping in the shared helper would report 0
  for a working mirror. Documented that the fix there is a QUERY change.
- Mirrored the rename into the design-system prototype.

The rename collided the badge and filter labels, so the screen tests now scope
the badge assertion to the table (spa-conventions §6). This also fixed a real
weakness: the mirror test's unscoped queryByText would have matched the filter
button and asserted nothing.

Review note: the reviewer's strongest hypothesis — that PUT and GET could
disagree on a mirror channel's count — was investigated and does NOT hold.

Refs #72
2026-07-17 17:51:43 +02:00
timothy 9b3121f0c2 docs(72): record the channel-health API shape + mirror the lineup fault state
Same-PR docs obligations for #72:

- decisions.md — why health is the raw `PlayoutCount` fact on the lean list DTO
  rather than a derived status enum or a new endpoint, and, more importantly,
  why empty-schedule / broken-source / auto-tuned-origin are deliberately NOT
  computed. The empty-schedule one is the trap worth writing down: it only
  understands Classic ProgramSchedule playouts, so a badge driven off it would
  silently lie on the other four schedule kinds.
- domain-model.md — a glossary row for channel health, drawing the line against
  /channels/state's OnAir (runtime liveness, not "would play if tuned").
- design-system — mirror the "No playout" badge + Problems filter into the
  Channels prototype so design-system/ stays a faithful mirror of prod
  (design-sync #388). Nature Docs carries playouts: 0 so the fault state is
  visible in the canvas.

Refs #72
2026-07-17 17:51:43 +02:00
timothy 6cd5471500 feat(72): flag channels that will never play in the lineup
Surfaces the per-channel playout count the previous commit put on the API as
the fault state #72 calls "the important one": a channel with no playout can
never play, and until now the lineup gave no sign of it.

- "No playout" renders as a Badge with visible text rather than another
  single-letter marker like D/H. D/H flag states the user chose deliberately;
  this is a fault they need to notice and act on, and a tooltip-only glyph is
  invisible on touch and easy to miss in a long lineup.
- A "Problems" segmented filter answers the issue's actual goal — seeing the
  health of a LARGE lineup at a glance — by collapsing it to just the faults.
- `willNeverPlay` is one predicate shared by the badge, the count and the
  filter, so the three cannot disagree.

Mirror channels relay their source's playouts and the server folds those into
playoutCount, so a working mirror is not false-flagged (covered by a test).

Deliberately not approximated here, per the design pass: an empty schedule
behind an existing playout (EmptyScheduleHealthCheck only understands Classic
ProgramSchedule playouts — a badge driven by it would silently lie on Block,
Sequential, Scripted and ExternalJson channels) and broken/missing sources
(FileNotFound/Unavailable are server-wide counts with no channel attribution).
Both stay open on #72 rather than shipping a signal that is wrong for four of
the five schedule kinds.

The test fixture now sends playoutCount, matching the API, so the fault state
is opt-in per test instead of depending on an absent field.

Refs #72
2026-07-17 17:51:26 +02: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 2c7330f273 Merge pull request 'chore: PreToolUse guard that blocks a commit/push with a BOM'd touched .cs' (#413) from chore/bom-guard-hook into main
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 / 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 / Build & test (.NET) (push) Successful in 7m14s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m43s
2026-07-17 15:50:01 +00:00
6052626316 chore: widen the BOM-guard remedy to staged + dirty files (review nit)
Cold review (MERGEABLE-WITH-NITS) flagged that the printed fix snippet and the
docs by-hand check used `git diff origin/main...HEAD`, which excludes
staged-but-uncommitted files. A brand-new staged .cs is exactly what fires the
pre-commit deny yet is absent from that diff, so the copy-paste remedy would strip
nothing → deny again → loop. (The deny still names the file, so manual stripping
always worked; this just makes the printed remedy match the guard's own detection
set: branch diff ∪ staged ∪ dirty.)

Verified on a fresh fixture: a brand-new staged BOM file is denied, and the
widened snippet strips it.

Left as accepted (fail-open, Medium, CI backstops; shared with sibling hooks):
`git -C <path>` isn't parsed for the target tree (only `cd` is — matches the
`cd <wt> && git` usage here), and the heredoc-body false-deny vector that
bash-guard/worktree-guard also carry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:50:01 +00:00
139bdaff5a chore: PreToolUse guard that blocks a commit/push with a BOM'd touched .cs
The #311 fix-as-you-touch rule is already enforced by a pre-commit hook and a
blocking CI job, and both missed the same mistake twice on 2026-07-17: PR #405
shipped 6 BOM'd files, PR #402 shipped 19. The gap is that the pre-commit hook is
routinely skipped — worktree hook friction makes `git commit --no-verify` the
normal path here — which leaves CI, a ~10-minute round trip, as the first thing
that tells you.

This guard fires on Bash before git runs, so --no-verify can't skip it. It denies
`git commit`/`git push` when a .cs the branch touches still carries a BOM, names
the files, and gives the strip command.

Why a hook rather than a note: a memory describing this exact trap did not prevent
either failure — the PR #402 session re-added a BOM an hour after writing that
memory down, because the usual ways of touching a legacy file re-add it silently
(Python io.open(..., encoding='utf-8-sig') WRITES a BOM back; sed/perl round-trips
keep it). A check that runs beats one you have to remember.

Follows the existing gate pattern: a fail-open script in .claude/hooks/ registered
on the PreToolUse/Bash matcher alongside bash-guard and worktree-guard. Any parse
or lookup trouble → allow; this must never be the reason a commit can't happen, and
CI remains the backstop. Generated *.Designer.cs / TvContextModelSnapshot.cs are
exempt, matching what dotnet format itself skips. Scoped to this repo, and it
resolves the target tree from an in-command `cd` because commits here run as
`cd <worktree> && git ...` and the harness resets the shell cwd between calls.

Verified against a throwaway fixture: denies commit and push for a BOM'd touched
file (naming it); allows a clean tree, a BOM in generated files only, `git status`
with a BOM present, and `echo 'run git push later'` (no false-trip on the words).
Proved live via a sentinel — it fires on the Bash matcher — sentinel removed.

docs/contributing.md updated: three enforcement layers, why the third exists, the
utf-8-sig re-add trap, a by-hand check, and the bash-not-zsh caveat for
`dotnet format --include` (mapfile is bash-only; under zsh the file list is empty
and the tool looks like it silently did nothing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:50:01 +00:00
timothy b3dbbd0528 Merge pull request 'ci(406): disable persistent compiler servers, cap the services: mysql, report peak RSS' (#411) from ci/406-roslyn-mysql-caps into main
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 / EF migration integrity (SQLite + MySql) (push) Successful in 5m43s
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 5m10s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 17m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m48s
2026-07-17 15:17:54 +00:00
timothyandClaude Opus 4.8 469d725559 ci(406): apply the memory-swap rule to our own two sites; stop leaning on the peak reading [decisions-edit]
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 8m13s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of the fix commits came back MERGEABLE with three findings worth acting on.

MEDIUM -- the PR documented a standing rule ("--memory without --memory-swap silently
grants 2x in swap") and then didn't apply it to the two sites this repo owns:
docker-build.yml's smoke container and scripts/migration-smoke.sh, both `--memory 2g` with
no --memory-swap. Pre-existing rather than a regression, but a rule you don't follow in
your own repo isn't a rule. The migration-smoke one matters most: it runs on the PROD host
in the release path, so a runaway migration should die against its cap rather than quietly
swap out the box serving media.

LOW -- and this is the important one: the docs leaned "peak 8305 MiB is probably mostly
reclaimable cache". An independent probe (full solution build, same CI image, shared
compilation off) measured peak 9457 MiB / anon 7134 MiB / file 421 MiB. ANON DOMINATED.
Having verified the *mechanism* (peak overstates because it counts page cache), I guessed
the *magnitude* in the direction I preferred -- the exact failure this entry criticises,
committed inside the entry criticising it. Corrected in ci-cd.md, decisions.md and on
server-management#604 (where the previous comment could have led to an unsafe 6g cap).

Consequences now recorded honestly: a 6g cap looks UNSAFE, #570's "6g proved too tight" is
the rule not an outlier, and #406's premise ("if this brings peak RSS well under 6 GiB the
whole budget loosens") is looking DEAD -- the 7134 MiB anon was measured with shared
compilation already off. The switches remain right; the looser budget they were meant to
buy does not follow.

NIT -- dropped the unverified claim that this also disables the Razor build server. The
UseRazorBuildServer -> UseSharedCompilation fallback is .NET 5-era; Razor has been an
in-process source generator since .NET 6, so there is likely no separate server to disable
on .NET 10. Unverified, zero impact, so it has no business in a doc arguing for
measurement over assumption.

[decisions-edit]: the touched docs/decisions.md lines were added by this PR's own earlier
commits, not settled entries on main -- net vs origin/main remains a pure insertion (0
deletions, verified). Also the sanctioned reason: the entry was factually wrong (see LOW).

Verified: both workflows parse; migration-smoke.sh passes bash -n; the parsed mysql option
string is `--memory=2g --memory-swap=2g --cpus=2`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:13:39 +02:00
timothyandClaude Opus 4.8 7859510c78 ci(406): report anon/file split — memory.peak counts page cache, not just RSS [decisions-edit]
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
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 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m35s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The peak-memory step from the previous commit worked on its first real CI run and
immediately produced a number that would have led #604 to the WRONG decision:

  peak container memory: 8305 MiB (8708448256 bytes, from /sys/fs/cgroup/memory.peak)

Read naively that says "the test job needs 8.1 GiB, so the 10g cap must stay and #406's
'the budget loosens' premise is dead". That reading is probably wrong, because
`memory.peak` is the high-water mark of `memory.current`, which charges PAGE CACHE to the
cgroup as well as anonymous memory. It is not peak RSS, and a build job does heavy
NuGet/npm/obj/bin/coverage I/O.

Proven on bumblebee -- a container with ZERO anonymous memory that merely reads an 800MB
file:

  memory.peak : 826 MiB
  anon        :   0 MiB
  file        : 800 MiB

This inverts the decision: page cache is RECLAIMED under a tighter cap, not OOM-killed, so
a large peak that is mostly `file` is NOT evidence that the cap must stay high. `anon` is
what forces an OOM. So the step now prints the anon/file split and says, in the log and
the docs, to size caps on anon rather than peak.

Honest about what this still isn't: the split is read at end-of-job, so it is the
composition then, not at the peak instant -- a job peaking mid-`dotnet test` and freeing
after would under-report anon. Sampling a true peak-anon is ersatztv#412, filed with the
options. Also recorded there and in ci-cd.md: there is NO pre-change baseline from this
instrument (7.8 GB VBCSCompiler was measured host-wide across concurrent jobs, not inside
one job container), so #406's premise is neither confirmed nor killed yet. What IS
established: no persistent compiler server survives a build, and migrations is green with
mysql capped at 2g with swap disabled.

The irony is the point, and it is now written into decisions.md: this repo's CI perf work
keeps stating numbers from plausibility rather than measurement (#390's "2-4min"
apt-ffmpeg estimate; real 110s). Measuring the wrong quantity precisely is the same
failure wearing a lab coat -- and it bit this very change, whose whole purpose was to
replace an inherited number with a measured one.

Verified: shellcheck clean; the real step body run inside a --memory=2g container under
`bash -e -o pipefail` reads memory.peak, prints the split, writes the step summary, exits
0; and it degrades to a skip + exit 0 where no cgroup file exists.

[decisions-edit]: the modified docs/decisions.md lines are ones THIS PR added in its own
earlier commits, not a settled entry on main -- net vs origin/main is still a pure
insertion (0 deletions, verified). It is also the sanctioned reason: the entry as written
was factually wrong about what memory.peak measures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:53:24 +02:00
timothyandClaude Opus 4.8 04e3a5249d ci(406): disable swap on the capped mysql; fix peak-step placement and failure mode [decisions-edit]
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 13m43s
Review fixes from the cold adversarial pass on PR #411. Verdict was MERGEABLE, but one
HIGH is a genuine defect worth the round-trip.

[decisions-edit] rationale: the 12 "modified" lines in docs/decisions.md are lines THIS PR
added in its own previous commit (e33205e8), not a settled entry on main. The hook diffs
against HEAD; net against origin/main this file is still a pure insertion (65 added, 0
deleted -- verified, and CI's decisions-guard diffs against the PR base). It is also the
sanctioned reason regardless: the entry as first written contained a factual error (see
HIGH below).

HIGH -- `--memory=2g` alone silently grants 2g of SWAP as well. Docker defaults an unset
--memory-swap to 2x --memory. Verified live on bumblebee: `--memory=2g` alone gives
memory.max=2147483648 AND memory.swap.max=2147483648; adding `--memory-swap=2g` gives
memory.swap.max=0. Capping RAM while permitting swap is close to the worst outcome on the
one host whose swap thrash is the entire reason for the cap -- and a swapping mysqld
mid-DDL is exactly the pathology behind the known `Command Timeout expired` migrations
flake, so the naive cap could have made that flake worse rather than better. The comment
promising "if this ever OOM-kills the service" described a failure mode that would not
have happened first: it would have swapped, silently. Now --memory-swap=2g (swap off);
prefer a loud OOM over silent degradation.

The same 2x applies to the runners' 10g job slots (10 GiB RAM + 10 GiB swap each), which
is a plausible direct mechanism for the incident's 21 GiB swapped. That is #604's
boundary -- reporting it there.

MEDIUM -- `if: always()` does not make a step advisory. It controls whether the step RUNS,
not whether its failure fails the job, and defaults.run.shell: bash means -e is on, so a
failed cat/redirect in an informational step could redden a green test job. Use
continue-on-error: true, matching the neighbouring Coverage summary step.

MEDIUM -- the peak step must run LAST. memory.peak read at step N reports the peak only up
to N, so sitting before Coverage summary excluded reportgenerator's memory -- under-
reporting the very number #604 sizes caps on. Moved after it.

LOW -- dependency-scan.yml gets the env too; workflow env does not cross workflow files,
and that one runs unattended on a cron against the prod media host.

NIT -- Dockerfile ENV moved below the openapi-generator wget: an ENV invalidates every
layer under it, and that ~30MB download is deliberately ordered early to stay cached
(#190). Nothing between it and restore compiles, so this placement is free.

NIT -- docs no longer imply this shrinks `format`. dotnet format loads Roslyn in-process
via MSBuildWorkspace and never spawns csc, so its 3.95 GiB is untouched; sizing the small
lane on the assumption it shrank would be a trap.

Also record honestly that 543 MiB is init+idle rather than the 787-migration replay (so 2g
is a measured floor + headroom, not a measured ceiling -- the migrations job going green
is what validates it) and that --cpus=2 is judgement with no measurement behind it. The
entry criticises dressing estimates as measurements; it should not do that itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:36:18 +02:00
timothyandClaude Opus 4.8 e33205e8eb ci(406): disable persistent compiler servers, cap the mysql service, report peak RSS
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m22s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #406, server-management#604. Items 1 and 2 of #406 (item 3 landed in PR #407).

Root cause of the 2026-07-17 bumblebee incident (load 340, 21 GiB swapped, prod media
down until reboot) is CI-induced memory pressure, and the largest single consumer was
Roslyn's persistent VBCSCompiler at 7.8 GB RSS. It is a compiler *server*: it outlives
the build that started it to keep its heap warm for the next one. In CI there is no next
build -- each job container is torn down at the end of the run -- so it is pure cost, and
it is the actual reason each job needed a 10 GiB cap.

1. Disable the compiler servers via the workflow's top-level env (UseSharedCompilation,
   DOTNET_CLI_USE_MSBUILD_SERVER, MSBUILDDISABLENODEREUSE). MSBuild surfaces env vars as
   properties and only defaults UseSharedCompilation when empty, so the env var wins and
   every dotnet call is covered without touching each call site.

   Also set as ENV in the Dockerfile's SDK stage: the workflow env reaches runner-side
   jobs only, and the `build` job compiles inside `docker build` where it does not
   propagate -- that is the job server-management#570 measured pegging 5.999/6 GiB, so it
   is the one that most needs this. Build stage only; the final image is FROM
   runtime-base, so nothing lands in the shipped image.

   Verified locally: a default build leaves 1 VBCSCompiler alive, the same build under
   these vars leaves 0, and ErsatzTV.sln still builds clean (0 errors). Trade-off: csc
   runs per project instead of via the server, costing some build time.

2. Cap the mysql service (--memory=2g --cpus=2). A runner's container.options applies to
   the job container ONLY: a live migrations job reported HostConfig.Memory=10737418240
   for the job and mem=0 nanocpus=0 for its mysql service, i.e. unbounded. 2g is sized on
   measurement -- mysql:8.4 with this exact env peaked at 543 MiB during init, 481 MiB
   idle (probed on bumblebee) -- not on inheritance.

Also add a `Report peak container memory` step to the test job (cgroup memory.peak,
always(), never fails a build). server-management#604 sizes both runners' caps on that
number and it was inherited rather than measured; now every run reports it.

Docs: ci-cd.md gains the compiler-server + services-cap sections and the lane table is
corrected (PR #407 reverted the #390 lane move without updating it). decisions.md records
all three, incl. the standing rule that a services: container never inherits a cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:22:31 +02:00
timothy 76c710dfa5 Merge pull request 'ci: move api-docs and format back to ubuntu-latest (refs #406)' (#407) from ci/604-lane-rebalance into main
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m35s
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 / 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) Successful in 14m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m7s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m54s
2026-07-17 13:09:10 +00:00
timothy 5ac49e553e Merge pull request 'fix(264): record library-level LastScan after a successful local scan' (#408) from fix/264-library-lastscan into main
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 / 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 / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-17 13:00:58 +00:00
timothy 71708633f5 test(264): pin the path-scan-error log; correct testing.md accuracy
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
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 38s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 10m49s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 15m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of the previous fix commit (986ccfaf, verdict MERGEABLE)
raised one fair hit and one claim that did not survive checking.

Fair hit — that commit's message asserted "LogError is an extension
method that NSubstitute can't cleanly verify" as the reason for adding
no test. That is FALSE, and this repo disproves it:
ErsatzTV.Tests' ReleaseMemoryHandlerTests.ShouldHaveLogged asserts on
ILogger via ReceivedCalls(), which works precisely because the LogError
extension dispatches to the substituted ILogger.Log. Reusing that idiom
here costs 4 lines, so pin the log the previous commit added. Verified
non-vacuous: asserting a message the handler never logs fails the test.

Not applied — the same review called the row's "1471 tests" misleading
on the grounds that TranscodingTests is [Explicit] and contributes most
of that count. TranscodingTests is indeed [Explicit], but filtering it
out yields exactly 1471, so 1471 is already the runnable count and the
row was accurate. Kept the number; documented TranscodingTests as
[Explicit]/opt-in instead, since it was a genuine omission from a table
that claims to be authoritative.

Also rewrap the verification-gate paragraph the previous commit left
over-long, and name the two always-run projects instead of "both".

Deferred (filed separately): a canceled local scan now logs at ERROR
per path via this log. It mirrors the remote handlers exactly, so
diverging here would be the inconsistency, not the fix.
2026-07-17 14:35:09 +02:00
timothy 986ccfaf6c fix(264): log local path scan errors (review finding) + map Scanner.Tests
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
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 47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m28s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review (MERGEABLE, no blockers) raised two items worth
folding in rather than deferring:

Medium — ScanLocalLibraryHandler silently swallowed path scan errors,
while the three remote scanners it mirrors all log result.LeftToSeq().
That mattered less when a failed path only skipped the path-level
LastScan, but the previous commit makes a failed path suppress the
library-level scan time too — so the user would see exactly the #264
symptom ("Never scanned") with nothing in the log explaining why. That
is a diagnosis dead-end of the same class as the bug being fixed, so
log it here rather than file a follow-up. No test: the sibling
Synchronize*LibraryByIdHandlerTests don't assert on logging either, and
LogError is an extension method that NSubstitute can't cleanly verify.

Low — docs/testing.md bills itself as the authoritative map of what each
test project covers but omitted ErsatzTV.Scanner.Tests entirely (1471
pre-existing tests). This PR adds a file to that project, so add the row
and include it in the per-PR verification gate.
2026-07-17 14:27:50 +02:00
timothy 5c8dd64acf fix(264): record library-level LastScan after a successful local scan
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
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 40s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m10s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Local libraries permanently showed "Never scanned" in the SPA libraries
hub regardless of successful scans, even as item counts updated.

Root cause: ScanLocalLibraryHandler wrote only the path-level
LibraryPath.LastScan (which gates the per-path refresh interval) and
never the library-level Library.LastScan. The read API
(GetAllMediaSourcesForApiHandler) populates the hub's scan-time badge
from Library.LastScan, so that value stayed null forever. The three
remote scanners (Jellyfin/Emby/Plex) already set the library-level
value; only the local scanner did not. Both sides predate #202 — the
SPA hub merely made the missing value visible.

Mirror the remote scanners' semantics: record the library-level scan
time only when the scan actually ran and every path that ran succeeded,
so a skipped (unforced, interval not elapsed) or partially-failed scan
does not claim a successful scan time.

Also de-BOM the touched handler per the fix-as-you-touch charset gate
(#311).

fixes #264
2026-07-17 14:18:14 +02:00
200 changed files with 40164 additions and 582 deletions
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# H13 (ersatztv#416 session) — refuse to push when a file in the pushed diff still has UNCOMMITTED
# changes in the working tree or index. That is the "I left part of my intended change behind"
# failure: a fix edited into the working file but never committed (e.g. after a `git reset --soft`
# that re-staged a stale index) gets pushed WITHOUT the fix — while local tests and a working-tree
# review both see the fix that never shipped. This bit the #416 session: a `--no-renames` review fix
# lived only in the working tree, so the pushed commit, CI, and the first re-review each saw a
# different tree, and a PR went out still carrying the bug the review had "confirmed" fixed.
#
# Scope is deliberately PRECISE to keep false positives near zero: it blocks only when a dirty
# tracked file is ALSO part of this branch's diff vs origin/main. Unrelated uncommitted scratch in a
# file the push doesn't touch is fine; untracked files are ignored.
#
# Fail-OPEN on anything we can't decide (a git pre-push hook has no "ask"): not a git repo, offline /
# no origin/main, HEAD unresolved -> allow. Deliberate escape: ETV_ALLOW_DIRTY_PUSH=1.
set -uo pipefail
[ "${ETV_ALLOW_DIRTY_PUSH:-}" = "1" ] && exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
# Files with uncommitted changes vs HEAD — unstaged AND staged-but-uncommitted, tracked only.
dirty="$( { git diff --name-only; git diff --cached --name-only; } 2>/dev/null | sort -u )"
[ -z "$dirty" ] && exit 0 # clean tree -> nothing to guard
# The set of files this branch introduces vs origin/main (the "pushed diff"). Best-effort fetch;
# if origin/main is unavailable we cannot scope precisely -> fail open rather than over-block.
git fetch origin main --quiet 2>/dev/null || exit 0
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
pushed="$( git diff --name-only "origin/main...HEAD" 2>/dev/null | sort -u )"
[ -z "$pushed" ] && exit 0
# Intersection: dirty files that are part of the pushed diff.
both="$( comm -12 <(printf '%s\n' "$dirty") <(printf '%s\n' "$pushed") )"
[ -z "$both" ] && exit 0
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
echo "husky - push blocked (H13): '$branch' has UNCOMMITTED changes to file(s) that are part of"
echo " what you're pushing — the pushed commit does NOT match your working tree, so a local fix"
echo " or review may be shipping without its change (the #416 index/worktree trap):"
printf '%s\n' "$both" | sed 's/^/ /'
echo " Commit them (or 'git checkout --' to discard), then push. If the difference is intentional"
echo " and unrelated, bypass with: ETV_ALLOW_DIRTY_PUSH=1 git push"
exit 1
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# PreToolUse / Bash — deny `git commit` / `git push` when a .cs file this branch touches carries a
# UTF-8 BOM. `.editorconfig` sets charset=utf-8 (no BOM), and the #311 fix-as-you-touch gate
# ("Formatting (changed .cs conform to .editorconfig)") FAILS THE PR for any touched file that has one.
#
# Why a hook and not a note: the ~2500 legacy .cs files carry a BOM, so it becomes *your* problem the
# moment you touch one — and the usual ways of touching them re-add it silently. Python
# `io.open(..., encoding='utf-8-sig')` WRITES a BOM back; perl/sed round-trips preserve it. On
# 2026-07-17 this cost two separate sessions a red CI job on the same day (PR #405 x6 files;
# #70/PR #402 x19), and a memory describing the trap did not prevent either — the second session
# re-added a BOM an hour after writing that memory down. A check that runs is worth more than one you
# have to remember.
#
# Generated files are excluded: dotnet format skips *.Designer.cs and TvContextModelSnapshot.cs as
# generated code, and so does the CI verify, so `dotnet ef` may leave its BOM there.
#
# Fail-open by design: any parse/lookup trouble → allow (exit 0, no output). This gate must never be
# the reason a commit can't happen; CI is still the backstop.
set -uo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
[ -n "$cmd" ] || exit 0
# Only gate real `git commit` / `git push` invocations (allowing global flags like `git -c x=y commit`).
# Matched in command position so the words inside a commit message or an echo never false-trip.
printf '%s' "$cmd" \
| grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+(commit|push)([[:space:]]|$)' \
|| exit 0
# Which tree does this act on? Commits here are typically `cd <worktree>` followed by git, and the
# harness resets the shell cwd between calls, so an in-command `cd` is the most reliable signal.
# Fall back to the payload cwd, then the project dir.
dir=$(printf '%s' "$cmd" \
| grep -oE '(^|[;&|(]|&&|\|\|)[[:space:]]*cd[[:space:]]+[^;&|)]+' \
| tail -1 | sed -E 's/.*cd[[:space:]]+//; s/[[:space:]]+$//' | tr -d "\"'" || true)
if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then
dir=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)
fi
if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then
dir="${CLAUDE_PROJECT_DIR:-$PWD}"
fi
root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
# Scoped to this repo — the .editorconfig rule it enforces is ours.
case "$root" in
*ersatztv*) ;;
*) exit 0 ;;
esac
# The touched set: what this branch changes vs origin/main, plus anything staged or dirty right now
# (a commit can introduce a BOM that isn't in the pushed diff yet).
base=$(git -C "$root" rev-parse --verify --quiet origin/main 2>/dev/null || true)
{
[ -n "$base" ] && git -C "$root" diff --name-only --diff-filter=ACM "$base"...HEAD -- '*.cs' 2>/dev/null
git -C "$root" diff --name-only --diff-filter=ACM --cached -- '*.cs' 2>/dev/null
git -C "$root" diff --name-only --diff-filter=ACM -- '*.cs' 2>/dev/null
} | sort -u > /tmp/.bom-guard-files.$$ 2>/dev/null || { rm -f /tmp/.bom-guard-files.$$; exit 0; }
bad=""
while IFS= read -r f; do
[ -n "$f" ] || continue
case "$f" in
*.Designer.cs|*TvContextModelSnapshot.cs) continue ;;
esac
p="$root/$f"
[ -f "$p" ] || continue
if [ "$(head -c3 "$p" 2>/dev/null | xxd -p 2>/dev/null)" = "efbbbf" ]; then
bad="${bad} ${f}"$'\n'
fi
done < /tmp/.bom-guard-files.$$
rm -f /tmp/.bom-guard-files.$$
[ -n "$bad" ] || exit 0
reason="Blocked: these .cs files carry a UTF-8 BOM, which .editorconfig forbids (charset=utf-8). The #311 Formatting CI job fails the PR for any file this branch touches that has one:
${bad}
Strip it, then re-run this command:
python3 - <<'EOF'
import subprocess
def g(*a): return subprocess.run(['git','diff','--name-only',*a,'--','*.cs'],
capture_output=True, text=True).stdout.split()
# same detection set as the guard: branch diff + staged + dirty (a brand-new staged
# file is exactly what fires the deny and is absent from origin/main...HEAD)
fs = set(g('origin/main...HEAD')) | set(g('--cached')) | set(g())
for f in sorted(fs):
try: b = open(f,'rb').read()
except OSError: continue
if b[:3] == b'\xef\xbb\xbf':
open(f,'wb').write(b[3:]); print('stripped', f)
EOF
Usual cause: an edit that rewrote a legacy file preserved its BOM — Python io.open(..., encoding='utf-8-sig') WRITES one back; sed/perl round-trips keep it. Touching a legacy file makes its inherited BOM yours to remove (docs/contributing.md; ersatztv#311). Generated *.Designer.cs / TvContextModelSnapshot.cs are exempt and not listed here."
jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
exit 0
+5
View File
@@ -14,6 +14,11 @@
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-worktree-guard.sh\"",
"timeout": 10
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-bom-guard.sh\"",
"timeout": 10
}
]
},
+11
View File
@@ -22,6 +22,17 @@ concurrency:
group: ersatztv-depscan
cancel-in-progress: true
# No persistent MSBuild/Roslyn servers (ersatztv#406). Workflow `env:` does not cross workflow
# files, so docker-build.yml's copy of these does not apply here and this has to be repeated.
# Smaller stakes than the build pipeline — `dotnet restore` + `dotnet list` are MSBuild-driven and
# never invoke csc, so this is lingering worker nodes (hundreds of MiB), not a 7.8 GB VBCSCompiler.
# Worth setting anyway: this runs unattended on a Monday 06:00 cron against the same host that runs
# prod media, and node reuse keeps workers alive ~15 min after the job.
env:
UseSharedCompilation: "false"
DOTNET_CLI_USE_MSBUILD_SERVER: "0"
MSBUILDDISABLENODEREUSE: "1"
jobs:
scan:
name: NuGet vulnerable packages
+200 -11
View File
@@ -26,6 +26,16 @@ name: Build ErsatzTV Image
# "CI toolchain image" for the two-step procedure.
#
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
#
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
# `scripts/ci-detect-docs-only.sh` as their first post-checkout step (id: detect) and gate every
# real step on `steps.detect.outputs.docs_only != 'true'`. Crucially they STILL RUN and STILL
# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration
# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
on:
workflow_dispatch:
@@ -60,6 +70,26 @@ env:
REGISTRY: 192.168.1.95:3000
IMAGE: 192.168.1.95:3000/timothy/ersatztv
# --- CI build memory (ersatztv#406, server-management#604) ---
# Roslyn's `VBCSCompiler` is a *persistent* compiler server: it outlives the `dotnet build` that
# started it and keeps its managed heap warm for the next one. Locally that is a real speedup.
# In CI it buys nothing — each job container is torn down at the end of the run, so there is
# never a "next build" to warm — while costing a lot: 7.8 GB RSS was measured live on bumblebee,
# the single largest consumer on a 25 GiB host that also runs prod media. Several of those, one
# per concurrent job container, is what drove the host to load 340 with 21 GiB swapped.
#
# These are MSBuild properties/switches, set here as environment variables so they apply to every
# dotnet invocation in every job (restore/build/test/format/api-docs) without touching each call
# site. MSBuild surfaces environment variables as properties, and `UseSharedCompilation` is only
# defaulted to true when empty, so setting it here wins.
#
# NOTE: this reaches the *runner-side* dotnet jobs only. The `build` job compiles inside
# `docker build`, where these do not propagate — the same switches are set as ENV in the
# Dockerfile's SDK stage (docker/Dockerfile) to cover it.
UseSharedCompilation: "false" # no persistent VBCSCompiler; csc runs per-project and exits
DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process
MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering
jobs:
test:
name: Build & test (.NET)
@@ -77,7 +107,15 @@ jobs:
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -85,11 +123,13 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
# the SPA's package downloads are project deps, so they stay cached per lockfile.
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -97,36 +137,45 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm ci
- name: Check generated SPA API client
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run check:api
- name: Lint SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run lint
- name: Typecheck SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run typecheck
- name: Test SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm test -- --run
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
if: steps.detect.outputs.docs_only != 'true'
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build --configuration Release --no-restore
- name: Test
if: steps.detect.outputs.docs_only != 'true'
run: >-
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
@@ -137,6 +186,7 @@ jobs:
# floor later"), so this step is purely informational — continue-on-error keeps a missing
# report or a transient tool-install failure from ever blocking a build.
- name: Coverage summary
if: steps.detect.outputs.docs_only != 'true'
continue-on-error: true
run: |
set -euo pipefail
@@ -160,6 +210,72 @@ jobs:
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
fi
# Memory of THIS job container, reported every run (ersatztv#406, server-management#604).
# #604 sizes the runners' per-job caps on these numbers, and until now they were inherited
# rather than measured: the 10g cap traces back to server-management#570 observing the image
# build peg 5.999/6 GiB, which is a different job entirely.
#
# ⚠️ READ THE BREAKDOWN, NOT JUST THE PEAK. `memory.peak` is the high-water mark of
# `memory.current`, which charges **page cache** to the cgroup as well as anonymous memory —
# it is NOT "peak RSS", and for a build job (NuGet/npm/obj/bin/coverage I/O) the cache
# dominates. Demonstrated on bumblebee: a container with anon=0 that merely reads an 800 MB
# file reports memory.peak=826 MiB, of which file=800 MiB. This matters because the naive
# reading inverts the decision: page cache is **reclaimed** under a tighter cap, not
# OOM-killed, so a large peak that is mostly `file` is NOT evidence that the cap must stay
# high. `anon` (+ a little kernel/sock) is the part that actually forces an OOM.
#
# The split below is read at end-of-job, so it is the *current* composition rather than the
# composition at the peak instant — indicative, not exact. Sizing a cap off one run is still
# wrong; take a few runs, and treat anon as the floor and peak as the (cache-inflated)
# ceiling. Refining this into a true peak-anon sample is ersatztv#412.
#
# Runs LAST on purpose: memory.peak read at step N reports the peak only up to N, so this
# sits after Coverage summary to include reportgenerator, the job's last real workload.
# cgroup v2 first, v1 fallback.
#
# Skipped on docs-only runs (ersatztv#416): nothing ran, so there is nothing to measure.
- name: Report peak container memory
# `always()` controls whether this step RUNS, not whether its failure fails the job — and
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
# the same guarantee the Coverage summary step above uses.
if: ${{ always() && steps.detect.outputs.docs_only != 'true' }}
continue-on-error: true
run: |
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
peak=""; src=""
for f in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory/memory.max_usage_in_bytes; do
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
done
if [ -z "$peak" ]; then
echo "No cgroup peak-memory file readable in this container -- skipping."
exit 0
fi
anon=""; file=""
if [ -r /sys/fs/cgroup/memory.stat ]; then
anon=$(awk '/^anon /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
file=$(awk '/^file /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
fi
echo "::group::Container memory (ersatztv#406 / server-management#604)"
printf 'peak (incl. page cache): %s MiB [%s bytes, %s]\n' "$(mib "$peak")" "$peak" "$src"
if [ -n "$anon" ]; then
printf 'end-of-job anon (the part that OOMs): %s MiB\n' "$(mib "$anon")"
printf 'end-of-job file (page cache, reclaimable): %s MiB\n' "$(mib "${file:-0}")"
echo 'NOTE: peak counts reclaimable page cache. Size caps on anon, not on peak.'
else
echo 'NOTE: no memory.stat breakdown available; peak includes reclaimable page cache.'
fi
echo "::endgroup::"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
printf '**Container memory (test job):** peak %s MiB *(incl. reclaimable page cache)*' \
"$(mib "$peak")"
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' \
"$(mib "$anon")" "$(mib "${file:-0}")"
printf '\n'
} >> "$GITHUB_STEP_SUMMARY" || true
fi
migrations:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
@@ -180,7 +296,42 @@ jobs:
# No host-port binding: the job reaches this service as mysql:3306 on the shared
# runner network. Publishing 3306 made concurrent runs collide ("port is already
# allocated") whenever two migrations jobs overlapped.
#
# `--memory`/`--cpus` here because the runner's `container.options` (`--memory=10g`)
# applies to the JOB container ONLY, not to `services:` — verified by inspecting a live
# migrations job: the job container reported HostConfig.Memory=10737418240, its mysql
# service reported `mem=0 nanocpus=0`, i.e. unbounded. So every migrations run was adding
# an uncapped MySQL to an already-tight host (ersatztv#406, server-management#604).
#
# NOTE (ersatztv#416): a `services:` container starts whenever the JOB starts, regardless
# of step `if:`. So a docs-only migrations run still spins this mysql (capped, seconds) even
# though the DDL-replay steps below are skipped. Fully skipping the service would require an
# `if:`-skipped job, which we deliberately do NOT do for a required context — the heavy cost
# (the 787-migration replay) is what the step gating removes.
#
# `--memory-swap=2g` is NOT redundant with `--memory=2g` — it is the point. Docker defaults
# an unset `--memory-swap` to *twice* `--memory`, so `--memory=2g` alone would grant 2g RAM
# **plus 2g of swap** (verified on bumblebee: `--memory=2g` alone → memory.max=2147483648
# AND memory.swap.max=2147483648; with `--memory-swap=2g` → memory.swap.max=0). Setting it
# equal to --memory disables swap for this container. That matters more here than anywhere:
# swap thrash on this host is the whole reason this cap exists, and a swapping mysqld mid-DDL
# is precisely the pathology behind the known `Command Timeout expired` migrations flake. We
# want a loud OOM over silent swapping — an OOM is a clear signal to raise the cap.
#
# 2g is sized on measurement rather than inheritance, but honestly: a mysql:8.4 container
# with this exact env peaked at 543 MiB during init and settled at 481 MiB idle (probed on
# bumblebee 2026-07-17). That is init+idle, NOT the 787-migration replay, which grows caches
# idle never touches — so treat 2g as a measured floor with headroom, not a measured
# ceiling. The migrations job going green is what validates it. If this OOM-kills the
# service, raise it deliberately — do not remove the cap, and do not re-enable swap.
#
# `--cpus=2` is a ceiling, not a reservation, and is the one number here with no measurement
# behind it: 787 sequential DDL statements on one connection are ~1-core-bound, so 2 is
# judgement. Revisit if the apply step's tail latency grows.
options: >-
--memory=2g
--memory-swap=2g
--cpus=2
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
--health-interval=5s
--health-timeout=5s
@@ -191,7 +342,14 @@ jobs:
# default fetch-depth: 1 -- this job never runs git describe/log, only
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -199,9 +357,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
- name: Build
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build --configuration Release --no-restore
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
@@ -209,6 +369,7 @@ jobs:
# SQLite is the prod provider; both checks validated locally.
- name: SQLite — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true'
run: |
set -euo pipefail
echo "::group::SQLite model drift (has-pending-model-changes)"
@@ -224,6 +385,7 @@ jobs:
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
- name: MySql — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true'
env:
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
@@ -261,12 +423,13 @@ jobs:
name: Functional E2E (curl contracts)
runs-on: ubuntu-latest
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
# the unit-test gate — promote it to a required check / build dependency once it's proven
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
# Runs on PRs and on main (regression net); skipped for v* tag builds.
# flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
# If-Match/412, and since ersatztv#363 two lock-contention 409s) that sessions have been
# re-running by hand. Deliberately NOT a `needs:` of `build` and not (yet) a required check, so a
# functional-E2E flake can't block image builds or the unit-test gate — promote it to a required
# check / build dependency once it's proven reliable (same rollout the `migrations` job used).
# SQLite default provider -> no DB service. Runs on PRs and on main (regression net); skipped for
# v* tag builds.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
@@ -279,7 +442,13 @@ jobs:
with:
fetch-depth: 1
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -287,9 +456,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -297,23 +468,28 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm ci
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run build
- name: Build (Release)
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
# the toolchain image ships the same ffmpeg build prod runs, so the binary is already here.
# That step also cost 110s of every run to install a dependency this harness never uses —
# it boots the app (whose only ffmpeg touch at startup is a LogWarning from
# FFmpegLocatorService) and drives curl-only contracts that never transcode.
# That step also cost 110s of every run. The harness never *transcodes*, but since ersatztv#363
# it does use ffmpeg to synthesize ~60 tiny testsrc clips to seed the scan-lock 409 flow (and
# python3's stdlib sqlite3 to seed the DB rows the API can't create) — both already present in
# the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent.
- name: Boot instance and run functional-E2E harness
if: steps.detect.outputs.docs_only != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
@@ -342,8 +518,16 @@ jobs:
with:
fetch-depth: 0
# ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image),
# so skip the build/push/smoke steps — the job still reports success. Tag builds force
# docs_only=false in the script, so a release is never skipped.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Compute version and tags
id: meta
if: steps.detect.outputs.docs_only != 'true'
run: |
SHORT=$(git rev-parse --short HEAD)
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
@@ -366,6 +550,7 @@ jobs:
printf 'tag: %s\n' "${TAGS[@]}"
- name: Set up Docker Buildx
if: steps.detect.outputs.docs_only != 'true'
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
@@ -373,6 +558,7 @@ jobs:
http = true
- name: Login to Gitea registry
if: steps.detect.outputs.docs_only != 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -380,6 +566,7 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
if: steps.detect.outputs.docs_only != 'true'
uses: docker/build-push-action@v6
with:
context: .
@@ -395,14 +582,16 @@ jobs:
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
- name: Smoke + IPTV E2E (assert key endpoints)
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
run: |
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
NAME="etv-smoke-${{ github.run_id }}"
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
echo "Pulling ${IMG}"
docker pull "$IMG"
docker run -d --name "$NAME" --memory 2g \
# --memory-swap equal to --memory disables swap. Without it Docker defaults --memory-swap
# to 2x --memory, so `--memory 2g` alone silently grants 2g RAM + 2g swap (ersatztv#406).
docker run -d --name "$NAME" --memory 2g --memory-swap 2g \
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
"$IMG"
+6
View File
@@ -15,6 +15,12 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
./.claude/hooks/prepush-rebase-check.sh || exit 1
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
# index/worktree trap: a review fix left in the working tree shipped without being committed).
# Runs before the slow CI-parity checks so it fails fast. Fail-open; escape ETV_ALLOW_DIRTY_PUSH=1.
./.claude/hooks/prepush-clean-worktree-check.sh || exit 1
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
@@ -38,6 +38,58 @@ public static class AutoTuneAxisMap
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// Per-source member query for a weighted auto-tune channel (#425). The discriminator identifies ONE
// content source within the channel's axis:
// * TV axes -> the show title. Episodes carry no parent-show id in the search index (only show_title
// is denormalized onto them), so show_title is the only field that selects a show's episodes. It is
// the same discriminator the TvShow axis already uses, so this introduces no new fragility class;
// a post-create show rename empties the member (items fall through to the remainder) until re-tuned.
// * MovieGenre -> the movie's media-item id (the stable, rename-proof `id` field; a movie IS the
// played item, so its own id selects it exactly).
// Deliberately discriminator-ONLY (no genre clause): membership is decided when the channel is tuned,
// so a materialized show airs all its episodes and the remainder subtracts the whole source (below).
public static string GenerateSourceQuery(AutoTuneAxis axis, string discriminator) =>
axis switch
{
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
$"type:episode AND show_title:\"{EscapeLuceneValue(discriminator)}\"",
AutoTuneAxis.MovieGenre => $"type:movie AND id:{discriminator}",
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// The bare clause used to subtract a materialized/excluded source from the remainder query (below).
// Mirrors GenerateSourceQuery's discriminator field, minus the type prefix.
public static string SourceDiscriminatorClause(AutoTuneAxis axis, string discriminator) =>
axis switch
{
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
$"show_title:\"{EscapeLuceneValue(discriminator)}\"",
AutoTuneAxis.MovieGenre => $"id:{discriminator}",
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// The catch-all remainder query: the base axis query minus every materialized/excluded source, so the
// base set is partitioned across (member sources + remainder) with no item counted twice and none
// dropped. Returns the plain base query when there is nothing to subtract. Emitted as valid classic
// Lucene — `(base) AND NOT (d1 OR d2 ...)` — because a ParseException silently escapes the whole query
// into a literal (SearchQueryParser.ParseQuery fallback).
public static string GenerateRemainderQuery(
AutoTuneAxis axis,
string value,
IReadOnlyCollection<string> subtractedDiscriminators)
{
string baseQuery = GenerateQuery(axis, value);
if (subtractedDiscriminators is null || subtractedDiscriminators.Count == 0)
{
return baseQuery;
}
string negated = string.Join(
" OR ",
subtractedDiscriminators.Select(d => SourceDiscriminatorClause(axis, d)));
return $"({baseQuery}) AND NOT ({negated})";
}
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
public static string EscapeLuceneValue(string value) =>
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
@@ -42,6 +42,16 @@ public class BulkDeleteChannelsHandler(
dbContext.Channels.RemoveRange(channels);
await dbContext.SaveChangesAsync(cancellationToken);
// Clean up the system-owned weighted-auto-tune artifacts these channels created (#425), inside the
// same transaction — see DeleteChannelHandler for the cascade rationale.
await dbContext.MultiCollections
.Where(mc => mc.OwnedByChannelId != null && channelIds.Contains(mc.OwnedByChannelId.Value))
.ExecuteDeleteAsync(cancellationToken);
await dbContext.SmartCollections
.Where(sc => sc.OwnedByChannelId != null && channelIds.Contains(sc.OwnedByChannelId.Value))
.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
@@ -197,13 +197,25 @@ public class CreateChannelFromLineupHandler(
bool multiItem = normalized.Count >= 2;
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
// MultiCollection entries only support Shuffle / ShuffleInOrder / WeightedShuffle
// (mirrors PlayoutModeMustBeValid -- keep the two lists in step).
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder
or PlaybackOrder.WeightedShuffle))
{
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
}
// A lineup of 2+ entries is persisted as a Playlist, and PlaylistEnumerator has no default arm: an
// order it doesn't know leaves the enumerator null and the items are dropped from the playlist with
// nothing reported. This is the second (and less obvious) persisting writer of
// PlaylistItem.PlaybackOrder, alongside ReplacePlaylistItems (#70; the silent fallbacks are #403).
if (multiItem && playbackOrder is PlaybackOrder.WeightedShuffle)
{
return BaseError.New(
$"Playback order '{playbackOrder}' is not supported for a multi-item lineup; it is available on classic schedule items");
}
if (multiItem)
{
// The generated playlist cannot express rerun collections or nested playlists
@@ -57,9 +57,22 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
_fileSystem.File.Delete(cacheFile);
}
int channelId = channel.Id;
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync(cancellationToken);
// Clean up the system-owned weighted-auto-tune artifacts this channel created (#425): the
// MultiCollection (its cascade removes the now-dangling flood schedule item) and its per-source
// SmartCollections (cascade removes their join rows). Null OwnedByChannelId = a user collection, left
// untouched. Non-weighted (#69 single-SmartCollection) auto-tune channels set no ownership, so their
// pre-existing orphan-on-delete behavior is unchanged.
await dbContext.MultiCollections
.Where(mc => mc.OwnedByChannelId == channelId)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.SmartCollections
.Where(sc => sc.OwnedByChannelId == channelId)
.ExecuteDeleteAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
@@ -52,16 +52,6 @@ public class UpdateChannelHandler(
UpdateChannel update,
CancellationToken cancellationToken)
{
// don't save mirror when playout exists
if (c.Playouts.Count > 0)
{
update = update with
{
PlayoutSource = ChannelPlayoutSource.Generated,
MirrorSourceChannelId = null
};
}
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name;
@@ -140,6 +130,8 @@ public class UpdateChannelHandler(
c.PlayoutMode = ChannelPlayoutMode.Continuous;
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
}
else
{
@@ -147,8 +139,6 @@ public class UpdateChannelHandler(
c.PlayoutOffset = null;
}
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
@@ -176,6 +166,13 @@ public class UpdateChannelHandler(
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
}
// Deliberately NOT Mapper.GetPlayoutsCount: this handler's query (see Handle) doesn't include
// MirrorSourceChannel, so the shared helper would read that navigation as null and return the
// same own-playouts-only count anyway — with a false air of Mirror-awareness. Harmless today
// because ChannelController discards this view model and re-projects through
// GetChannelByIdForApi, so this count never reaches the wire. If you ever return it directly,
// fix the QUERY first (add the MirrorSourceChannel ThenInclude) — swapping in the helper alone
// would report 0 playouts for a working mirror channel.
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
}
@@ -187,7 +184,7 @@ public class UpdateChannelHandler(
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((_, _, _, _, _) => channel);
@@ -262,6 +259,7 @@ public class UpdateChannelHandler(
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
Channel channel,
CancellationToken cancellationToken)
{
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
@@ -269,6 +267,18 @@ public class UpdateChannelHandler(
return Unit.Default;
}
// a channel with its own playout already built (Generated mode) cannot become a Mirror —
// Mirror channels relay another channel's playout and never build one of their own, so
// switching this transition on would strand the existing playout. This used to be
// silently coerced back to Generated (issue #401); reject the transition instead so the
// caller sees why the requested Mirror source was not applied. A round-trip that keeps
// PlayoutSource as Generated never reaches this check.
if (channel.Playouts.Count > 0)
{
return BaseError.New(
"Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first.");
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
@@ -1,3 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
using MediatR;
@@ -8,11 +9,27 @@ public record CreateAutoTunedChannels(
string Group,
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
// The batch-level TemplateId is the default; any per-channel field set here overrides it for that one
// channel. Advanced/Logo/TemplateId are all optional so the older positional {axis, value, name, number}
// form (and every existing caller/test) keeps compiling and behaving identically.
public record AutoTuneChannelSelection(
AutoTuneAxis Axis,
string Value,
string Name,
string Number);
string Number,
int? TemplateId = null,
ArtworkContentTypeModel Logo = null,
CreateChannelFromLineupAdvancedOptions Advanced = null,
List<AutoTuneSourceWeight> Sources = null);
// Per-content-source rotation weight + query correction for a weighted auto-tune channel (#425).
// SourceId is the show id (TV axes) or movie media-item id (movie axis) from the members list (#384).
// Weight is the relative share of airtime (weighted round-robin; 1 = fair-share). Excluded drops the
// source entirely. A SourceId that is not in the axis's base set is an "add-untagged" source — materialized
// like any other. When every entry is Weight 1 and not excluded (and adds nothing), the channel keeps the
// single-SmartCollection fair-share shape; otherwise it is built as a MultiCollection of per-source
// SmartCollections carrying the weights.
public record AutoTuneSourceWeight(int SourceId, int Weight = 1, bool Excluded = false);
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
{
@@ -4,17 +4,29 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class CreateAutoTunedChannelsHandler(ISender mediator)
public class CreateAutoTunedChannelsHandler(
ISender mediator,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ISmartCollectionCache smartCollectionCache)
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
{
private const string NumberTakenError = "Channel number must be unique";
private const string DefaultGroup = "Auto-Tuned";
// The members enumeration caps its own search at 10k leaf items, so a channel's distinct source count is
// already bounded (dozens/hundreds). One large page pulls them all.
private const int MaxSources = 10_000;
public async Task<AutoTuneResult> Handle(
CreateAutoTunedChannels request,
CancellationToken cancellationToken)
@@ -42,8 +54,53 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name");
}
// Per-channel template override falls back to the batch template.
int effectiveTemplateId = selection.TemplateId ?? templateId;
// Per-channel uploaded channel image; None = generate the on-the-fly fallback logo at serve time.
ArtworkContentTypeModel logo = selection.Logo ?? ArtworkContentTypeModel.None;
// Per-source rotation weights / query corrections (#425) turn the channel from one fair-share
// SmartCollection into a MultiCollection of per-source SmartCollections carrying the weights. Only
// when the caller actually customized a source (a non-default weight, an exclusion, or an added
// out-of-axis source) — otherwise the single-SmartCollection fair-share shape is kept (cheaper, and
// identical output for TV since the fake-collection path already groups per show).
WeightedPlan plan = await BuildWeightedPlan(selection, cancellationToken);
if (plan is not null)
{
return await CreateWeightedChannel(
effectiveTemplateId, group, name, logo, selection, plan, cancellationToken);
}
return await CreateSingleSmartCollectionChannel(
effectiveTemplateId,
group,
name,
logo,
selection,
cancellationToken);
}
private async Task<AutoTuneChannelOutcome> CreateSingleSmartCollectionChannel(
int effectiveTemplateId,
string group,
string name,
ArtworkContentTypeModel logo,
AutoTuneChannelSelection selection,
CancellationToken cancellationToken)
{
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
// The axis default (SeasonEpisode for a single show, Shuffle for a genre) is the playback order
// unless the DetailPanel set an explicit per-channel override. Any other Advanced field the caller
// set is layered on top of the template by CreateChannelFromLineup's `advanced.X ?? template.X`
// stamp-at-create contract, so we only have to fill in the axis-derived PlaybackOrder default here.
PlaybackOrder axisOrder = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
CreateChannelFromLineupAdvancedOptions advanced =
(selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with
{
PlaybackOrder = selection.Advanced?.PlaybackOrder ?? axisOrder
};
// 1. Create the smart collection that drives this channel.
Either<BaseError, SmartCollectionViewModel> scResult =
@@ -67,11 +124,11 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
selection.Number,
group,
string.Empty,
ArtworkContentTypeModel.None,
logo,
IsEnabled: true,
ShowInEpg: true,
templateId,
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
effectiveTemplateId,
advanced,
[
new CreateChannelFromLineupItem(
LibraryBrowseMediaType.SmartCollection,
@@ -113,4 +170,350 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
}
// A resolved weighting plan: the per-source member queries + their weights, and the catch-all remainder.
// Null when the caller did not actually customize anything (fall back to the single-SmartCollection path).
private sealed record WeightedPlan(List<WeightedMember> Members, WeightedMember Remainder);
private sealed record WeightedMember(string Query, int Weight);
// Resolve the caller's per-source overrides against the channel's live base source set. Returns null when
// no source was customized (all weights 1, nothing excluded, nothing added) so the caller keeps the
// single-SmartCollection fair-share shape.
private async Task<WeightedPlan> BuildWeightedPlan(
AutoTuneChannelSelection selection,
CancellationToken cancellationToken)
{
List<AutoTuneSourceWeight> sources = selection.Sources ?? [];
if (sources.Count == 0)
{
return null;
}
// Enumerate the axis's distinct base sources (parent shows for TV, movies for the movie axis) exactly
// as the DetailPanel members list does, so weight resolution matches what the user saw.
PagedLibraryBrowseItemsResponseModel members = await mediator.Send(
new GetAutoTuneChannelMembers(selection.Axis, selection.Value, 0, MaxSources),
cancellationToken);
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
// Any override touching a non-default weight, an exclusion, or an id outside the base set means the
// channel really is customized; otherwise the plan would be identical to fair-share.
bool customized = sources.Any(s => s.Weight != 1 || s.Excluded || !baseIds.Contains(s.SourceId));
if (!customized)
{
return null;
}
Dictionary<int, AutoTuneSourceWeight> overridesById = sources
.GroupBy(s => s.SourceId)
.ToDictionary(g => g.Key, g => g.Last());
return selection.Axis switch
{
AutoTuneAxis.MovieGenre => BuildMoviePlan(selection, members, overridesById),
_ => await BuildTvPlan(selection, members, overridesById, cancellationToken)
};
}
// TV: every base show becomes its own weighted SmartCollection (discriminator-only `show_title`) so
// un-weighted shows keep per-show fair-share — a single merged remainder would regress them to
// item-proportional (a 200-episode show would swamp a 20-episode one). The remainder is the live
// catch-all for shows/episodes added after tune-in, at weight 1.
private async Task<WeightedPlan> BuildTvPlan(
AutoTuneChannelSelection selection,
PagedLibraryBrowseItemsResponseModel members,
Dictionary<int, AutoTuneSourceWeight> overridesById,
CancellationToken cancellationToken)
{
var weightedMembers = new List<WeightedMember>();
var subtracted = new List<string>();
// Base shows (title is the discriminator; the members list already carries it).
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
foreach (LibraryBrowseItemResponseModel item in members.Page)
{
AutoTuneSourceWeight ov = overridesById.GetValueOrDefault(item.Id);
if (ov is { Excluded: true })
{
subtracted.Add(item.Title);
continue;
}
weightedMembers.Add(new WeightedMember(
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, item.Title),
NormalizeWeight(ov?.Weight ?? 1)));
subtracted.Add(item.Title);
}
// Added (out-of-axis) shows: resolve the title from metadata since the members list won't include them.
List<int> addedIds = overridesById.Keys.Where(id => !baseIds.Contains(id)).ToList();
if (addedIds.Count > 0)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Dictionary<int, string> titles = (await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => addedIds.Contains(sm.ShowId))
.Select(sm => new { sm.ShowId, sm.Title })
.ToListAsync(cancellationToken))
.GroupBy(x => x.ShowId)
.ToDictionary(g => g.Key, g => g.First().Title);
foreach (int id in addedIds)
{
AutoTuneSourceWeight ov = overridesById[id];
if (ov.Excluded || !titles.TryGetValue(id, out string title) || string.IsNullOrWhiteSpace(title))
{
continue;
}
weightedMembers.Add(new WeightedMember(
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, title),
NormalizeWeight(ov.Weight)));
subtracted.Add(title);
}
}
var remainder = new WeightedMember(
AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted),
1);
return new WeightedPlan(weightedMembers, remainder);
}
// Movies: materialize only the touched movies (a non-default weight, or an added out-of-axis movie) as
// individual `id:{n}` SmartCollections; every un-touched base movie stays in ONE remainder whose weight is
// its member count. Because the fake-collection path already pools all movies uniformly, a count-weighted
// remainder is exactly equivalent to materializing each movie individually — without hundreds of rows.
private static WeightedPlan BuildMoviePlan(
AutoTuneChannelSelection selection,
PagedLibraryBrowseItemsResponseModel members,
Dictionary<int, AutoTuneSourceWeight> overridesById)
{
var weightedMembers = new List<WeightedMember>();
var subtracted = new List<string>();
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
var subtractedBase = 0;
foreach ((int id, AutoTuneSourceWeight ov) in overridesById)
{
bool inBase = baseIds.Contains(id);
string idClause = id.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (ov.Excluded)
{
subtracted.Add(idClause);
if (inBase)
{
subtractedBase++;
}
continue;
}
// Materialize weighted base movies and every added (out-of-axis) movie; a base movie left at
// weight 1 is cheaper to leave in the remainder (same airtime either way).
if (ov.Weight != 1 || !inBase)
{
weightedMembers.Add(new WeightedMember(
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, idClause),
NormalizeWeight(ov.Weight)));
subtracted.Add(idClause);
if (inBase)
{
subtractedBase++;
}
}
}
// Remainder weight = the un-touched base movie count, so a weighted movie airs N× *each* remainder
// movie (the fake path already pools movies uniformly, so this is equivalent to materializing each).
// Clamped to MultiCollectionItemWeight.Maximum (1000): a genre with >1000 un-touched movies can't
// express the exact ratio (the weighted movie then airs slightly more than intended) — the same
// 1..1000 bound #70's weight column imposes everywhere. Realistic only at very large scale.
int remainderCount = baseIds.Count - subtractedBase;
var remainder = new WeightedMember(
AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted),
NormalizeWeight(remainderCount));
return new WeightedPlan(weightedMembers, remainder);
}
private static int NormalizeWeight(int weight) =>
Math.Clamp(weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum);
private async Task<AutoTuneChannelOutcome> CreateWeightedChannel(
int effectiveTemplateId,
string group,
string name,
ArtworkContentTypeModel logo,
AutoTuneChannelSelection selection,
WeightedPlan plan,
CancellationToken cancellationToken)
{
// WeightedShuffle is the whole point; it overrides any axis default / caller Advanced.PlaybackOrder.
CreateChannelFromLineupAdvancedOptions advanced =
(selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with
{
PlaybackOrder = PlaybackOrder.WeightedShuffle
};
// Short unique token: the channel id isn't known until CreateChannelFromLineup runs, and both
// SmartCollection.Name and MultiCollection.Name are unique varchar(50).
string token = Guid.NewGuid().ToString("N")[..8];
int multiCollectionId;
List<int> smartCollectionIds;
await using (TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken))
{
var multiCollection = new MultiCollection
{
Name = $"at-mc:{token}",
MultiCollectionItems = [],
MultiCollectionSmartItems = []
};
var index = 0;
foreach (WeightedMember member in plan.Members.Append(plan.Remainder))
{
var smartCollection = new SmartCollection
{
Name = index == plan.Members.Count ? $"at:{token}:rem" : $"at:{token}:{index}",
Query = member.Query
};
dbContext.SmartCollections.Add(smartCollection);
multiCollection.MultiCollectionSmartItems.Add(new MultiCollectionSmartItem
{
MultiCollection = multiCollection,
SmartCollection = smartCollection,
ScheduleAsGroup = false,
PlaybackOrder = PlaybackOrder.Shuffle,
Weight = member.Weight
});
index++;
}
dbContext.MultiCollections.Add(multiCollection);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
return new AutoTuneChannelOutcome(
name, AutoTuneOutcomeStatus.Failed, null, $"Weighted collections: {ex.Message}");
}
multiCollectionId = multiCollection.Id;
smartCollectionIds = multiCollection.MultiCollectionSmartItems
.Select(i => i.SmartCollectionId)
.ToList();
// New smart collections became visible; refresh targets + cache like CreateSmartCollectionHandler
// (post-commit, CancellationToken.None so a late cancel can't abort it after the commit landed).
searchTargets.SearchTargetsChanged();
await smartCollectionCache.Refresh(CancellationToken.None);
}
var command = new CreateChannelFromLineup(
name,
selection.Number,
group,
string.Empty,
logo,
IsEnabled: true,
ShowInEpg: true,
effectiveTemplateId,
advanced,
[
new CreateChannelFromLineupItem(
LibraryBrowseMediaType.MultiCollection,
CollectionType.MultiCollection,
CollectionId: null,
MultiCollectionId: multiCollectionId,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null)
]);
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
await mediator.Send(command, cancellationToken);
foreach (BaseError error in channelResult.LeftToSeq())
{
// Roll back the multi collection + its member smart collections so a retry doesn't collide on
// name uniqueness. Best-effort; the outcome below stands regardless of the cleanup result.
await TryDeleteOwnedArtifacts(multiCollectionId, smartCollectionIds, cancellationToken);
AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal)
? AutoTuneOutcomeStatus.Skipped
: AutoTuneOutcomeStatus.Failed;
return new AutoTuneChannelOutcome(name, status, null, error.Value);
}
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
// Stamp ownership so the artifacts are hidden from user collection lists and cleaned up on channel
// delete. Best-effort: an unstamped artifact is a cosmetic/cleanup issue, never a failed channel.
await TryStampOwnership(multiCollectionId, smartCollectionIds, channelId);
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
}
private async Task TryStampOwnership(
int multiCollectionId,
List<int> smartCollectionIds,
int channelId)
{
try
{
// Post-commit side effect: runs on CancellationToken.None so a late request cancellation can't
// abort it after the channel-create commit landed (#254) — an un-stamped artifact would be a
// permanent orphan (never cleaned on delete, and visible in the user collection lists). The MC +
// its member smart collections are stamped in one transaction so a mid-way failure can't leave the
// MC owned while the smart collections stay orphaned.
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
await using var transaction = await dbContext.Database.BeginTransactionAsync(CancellationToken.None);
await dbContext.MultiCollections
.Where(mc => mc.Id == multiCollectionId)
.ExecuteUpdateAsync(s => s.SetProperty(mc => mc.OwnedByChannelId, channelId), CancellationToken.None);
await dbContext.SmartCollections
.Where(sc => smartCollectionIds.Contains(sc.Id))
.ExecuteUpdateAsync(s => s.SetProperty(sc => sc.OwnedByChannelId, channelId), CancellationToken.None);
await transaction.CommitAsync(CancellationToken.None);
}
catch (Exception)
{
// intentionally ignored; see call site
}
}
private async Task TryDeleteOwnedArtifacts(
int multiCollectionId,
List<int> smartCollectionIds,
CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await dbContext.MultiCollections
.Where(mc => mc.Id == multiCollectionId)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.SmartCollections
.Where(sc => smartCollectionIds.Contains(sc.Id))
.ExecuteDeleteAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
await smartCollectionCache.Refresh(CancellationToken.None);
}
catch (Exception)
{
// intentionally ignored; see call site
}
}
}
+27 -3
View File
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
@@ -6,6 +6,29 @@ namespace ErsatzTV.Application.Channels;
internal static class Mapper
{
/// <summary>
/// A mirror channel has no playouts of its own; it relays the playouts of its mirror source, so both must be
/// counted for the total to answer "can this channel play anything?". Requires <see cref="Channel.Playouts" />
/// and, for mirrors, <see cref="Channel.MirrorSourceChannel" />.<see cref="Channel.Playouts" /> to be included
/// by the query — the repository reads are AsNoTracking, so an un-included navigation silently counts zero.
/// </summary>
internal static int GetPlayoutsCount(Channel channel)
{
var result = 0;
if (channel.Playouts != null)
{
result += channel.Playouts.Count;
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
{
result += channel.MirrorSourceChannel.Playouts.Count;
}
return result;
}
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
new(
channel.Id,
@@ -73,7 +96,7 @@ internal static class Mapper
channel.ShowInEpg);
}
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) =>
new(
channel.Id,
channel.Number,
@@ -85,7 +108,8 @@ internal static class Mapper
channel.PreferredAudioLanguageCode,
GetStreamingMode(channel),
channel.IsEnabled,
channel.ShowInEpg);
channel.ShowInEpg,
playoutCount);
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
@@ -13,6 +13,6 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
return channels.Map(ProjectToResponseModel).ToList();
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
@@ -11,21 +11,4 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
await channelRepository.GetAll(cancellationToken)
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
private static int GetPlayoutsCount(Channel channel)
{
var result = 0;
if (channel.Playouts != null)
{
result += channel.Playouts.Count;
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
{
result += channel.MirrorSourceChannel.Playouts.Count;
}
return result;
}
}
@@ -11,5 +11,5 @@ public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
GetChannelByIdForApi request,
CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0));
.MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel)));
}
@@ -60,7 +60,10 @@ public partial class GetChannelGuideHandler(
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(request.AccessToken))
{
accessTokenUri += $"&amp;access_token={request.AccessToken}";
// The token value is HTTP-request-derived and interpolated raw into the pre-built XMLTV
// cache fragments, so it must be XML-escaped like {RequestBase} above — a token containing
// '&', '<', '>', or '"' would otherwise malform the whole guide. Opaque tokens are a no-op.
accessTokenUri += $"&amp;access_token={SecurityElement.Escape(request.AccessToken)}";
}
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
@@ -1,6 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
@@ -70,6 +71,32 @@ public static class ConcurrencyExtensions
}
}
/// <summary>
/// Like <see cref="SaveChangesForcingVersion" />, but additionally treats a unique / primary-key
/// constraint violation as an idempotent no-op: returns <c>false</c> instead of throwing when the
/// save fails because a concurrent request inserted a row we had membership-checked absent (the
/// composite-PK race on <c>CollectionItem</c> — issue #308). A <c>false</c> means "the desired row
/// already exists because a racing writer won; the winner ran the ETag rotation + fan-out, so skip
/// ours." <c>true</c> means our own change committed. Every other <see cref="DbUpdateException" />
/// (and the genuine deleted-row concurrency conflict rethrown by <see cref="SaveChangesForcingVersion" />)
/// still propagates. The only insert these callers stage is the <c>CollectionItem</c> join row, so the
/// sole unique/PK constraint that can fire here is that composite key.
/// </summary>
public static async Task<bool> TrySaveChangesForcingVersion(
this DbContext dbContext,
CancellationToken cancellationToken)
{
try
{
await dbContext.SaveChangesForcingVersion(cancellationToken);
return true;
}
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
{
return false;
}
}
/// <summary>
/// Persist pending changes, mapping the EF optimistic-concurrency failure to
/// <see cref="PreconditionFailedError" /> (→ 412). When a versioned root carries an
@@ -26,4 +26,10 @@
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ErsatzTV.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
+16 -1
View File
@@ -10,7 +10,11 @@ internal static class Mapper
result.Title,
GetStatus(result.Status),
result.Message,
result.Link.MatchUnsafe(l => l.Link, () => null));
string.IsNullOrWhiteSpace(result.BriefMessage) ? null : result.BriefMessage,
result.Link.MatchUnsafe(l => l.Target, () => (string)null),
result.Link.MatchUnsafe(
l => new HealthCheckRemediationResponseModel(GetLinkKind(l.Kind), l.Target),
() => (HealthCheckRemediationResponseModel)null));
private static string GetStatus(HealthCheckStatus status) =>
status switch
@@ -19,6 +23,17 @@ internal static class Mapper
HealthCheckStatus.Fail => "fail",
HealthCheckStatus.Warning => "warn",
HealthCheckStatus.Info => "info",
// NotApplicable is filtered out before mapping today; map it defensively rather
// than throwing, so a future caller that skips the filter can't 500 the endpoint.
HealthCheckStatus.NotApplicable => "notApplicable",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
private static string GetLinkKind(HealthCheckLinkKind kind) =>
kind switch
{
HealthCheckLinkKind.ExternalDoc => "ExternalDoc",
HealthCheckLinkKind.AppRoute => "AppRoute",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -55,7 +55,13 @@ public class AddArtistToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -57,7 +57,13 @@ public class AddEpisodeToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -54,7 +54,13 @@ public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection,
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -38,23 +38,52 @@ public class AddItemsToCollectionHandler :
_searchChannel = searchChannel;
}
// A duplicate-key race can roll back the whole batch (#308); recompute membership from a fresh
// context and retry with only the still-missing items. Bounded to avoid a livelock — the common
// no-collision path runs the loop body exactly once.
private const int MaxDuplicateRetries = 5;
public async Task<Either<BaseError, Unit>> Handle(
AddItemsToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await maybeCollection.Match(
Some: async collection =>
for (var attempt = 0; ; attempt++)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
// true = terminal (nothing to add, or our batch committed); false = a duplicate-key race
// rolled the batch back, recompute membership and retry.
Either<BaseError, bool> attemptResult = await maybeCollection.Match(
Some: async collection =>
{
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, bool>>(
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
if (attemptResult.IsLeft)
{
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
return attemptResult.Map(_ => Unit.Default);
}
bool committed = attemptResult.Match(Left: _ => false, Right: done => done);
if (committed)
{
return Unit.Default;
}
// A concurrent add inserted one+ of our items first; recompute against fresh membership.
if (attempt >= MaxDuplicateRetries)
{
return BaseError.New(
"Concurrent modification while adding items to the collection; please retry.");
}
}
}
private async Task<Unit> ApplyAddItemsRequest(
private async Task<bool> ApplyAddItemsRequest(
TvContext dbContext,
Collection collection,
AddItemsToCollection request,
@@ -75,10 +104,10 @@ public class AddItemsToCollectionHandler :
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
// No-op when every requested item is already a member: don't rotate the ETag or fan out
// rebuilds for an idempotent re-add — #269.
// rebuilds for an idempotent re-add — #269. Terminal success (no retry).
if (toAddIds.Count == 0)
{
return Unit.Default;
return true;
}
List<MediaItem> toAdd = await dbContext.MediaItems
@@ -91,7 +120,15 @@ public class AddItemsToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a).
collection.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// A concurrent add of an overlapping item won the composite-PK race and rolled back this whole
// batch. Unlike the single-item handlers (idempotent no-op), a bulk add must NOT drop the items
// that did NOT collide — signal the caller to recompute membership and retry the still-missing
// ones. #308
if (!await dbContext.TrySaveChangesForcingVersion(cancellationToken))
{
return false;
}
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
@@ -104,7 +141,7 @@ public class AddItemsToCollectionHandler :
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
return true;
}
private async Task<Validation<BaseError, Collection>> Validate(
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -55,7 +55,13 @@ public class AddMediaItemToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -55,7 +55,13 @@ public class AddMovieToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -57,7 +57,13 @@ public class AddMusicVideoToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -57,7 +57,13 @@ public class AddOtherVideoToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -55,7 +55,13 @@ public class AddSeasonToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -55,7 +55,13 @@ public class AddShowToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -55,7 +55,13 @@ public class AddSongToCollectionHandler :
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
{
// A concurrent add of this same item won the composite-PK race and already inserted the row,
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
return Unit.Default;
}
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]), CancellationToken.None);
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
@@ -7,7 +7,8 @@ public record CreateMultiCollectionItem(
int? CollectionId,
int? SmartCollectionId,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
PlaybackOrder PlaybackOrder,
int Weight = 1);
public record CreateMultiCollection(string Name, List<CreateMultiCollectionItem> Items)
: IRequest<Either<BaseError, MultiCollectionViewModel>>;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
@@ -51,42 +51,56 @@ public class CreateMultiCollectionHandler :
private static Task<Validation<BaseError, MultiCollection>> Validate(
TvContext dbContext,
CreateMultiCollection request) =>
ValidateName(dbContext, request).MapT(name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Bind(i =>
{
if (i.CollectionId.HasValue)
ValidateName(dbContext, request)
.BindT(name => ValidateWeights(request).Map(_ => name))
.MapT(name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Bind(i =>
{
return Some(
new MultiCollectionItem
{
CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
if (i.CollectionId.HasValue)
{
return Some(
new MultiCollectionItem
{
CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder,
Weight = i.Weight
});
}
return Option<MultiCollectionItem>.None;
})
return Option<MultiCollectionItem>.None;
})
.ToList(),
MultiCollectionSmartItems = request.Items.Bind(i =>
{
if (i.SmartCollectionId.HasValue)
MultiCollectionSmartItems = request.Items.Bind(i =>
{
return Some(
new MultiCollectionSmartItem
{
SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
if (i.SmartCollectionId.HasValue)
{
return Some(
new MultiCollectionSmartItem
{
SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder,
Weight = i.Weight
});
}
return Option<MultiCollectionSmartItem>.None;
})
return Option<MultiCollectionSmartItem>.None;
})
.ToList()
});
});
// Bounds are shared with the update path so the two cannot drift -- they silently disagreed before #402:
// EF's HasDefaultValue substitutes 1 for a 0 on INSERT (0 reads as "not set") while an UPDATE writes the 0
// through, so the same input landed differently depending on the verb. The enumerator clamps out-of-range
// weights, so neither a 0 nor a huge value can reach the rotation; this gate refuses input that has no
// meaning on a share-of-airtime scale, and keeps create and update honest with each other. See #70.
private static Validation<BaseError, Unit> ValidateWeights(CreateMultiCollection request) =>
request.Items.All(i => MultiCollectionItemWeight.IsValid(i.Weight))
? Unit.Default
: BaseError.New(MultiCollectionItemWeight.ValidationMessage);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
@@ -73,7 +73,30 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
ReplacePlaylistItems request,
CancellationToken cancellationToken) =>
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist));
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
ReplacePlaylistItems request,
Playlist playlist) =>
request.Items
.Map(item => PlaybackOrderMustBeSupported(item.PlaybackOrder))
.Sequence()
.Map(_ => playlist);
private static Validation<BaseError, Unit> PlaybackOrderMustBeSupported(PlaybackOrder playbackOrder)
{
// WeightedShuffle (#70) is implemented for classic schedule items only. PlaylistEnumerator has no
// default arm, so an order it doesn't know leaves the enumerator null and the item is dropped from the
// playlist silently -- refuse it at the write path instead of scheduling nothing at build time.
if (playbackOrder is PlaybackOrder.WeightedShuffle)
{
return BaseError.New(
$"Playback order '{playbackOrder}' is not supported for playlist items; it is available on classic schedule items");
}
return Unit.Default;
}
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext,
@@ -7,7 +7,8 @@ public record UpdateMultiCollectionItem(
int? CollectionId,
int? SmartCollectionId,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
PlaybackOrder PlaybackOrder,
int Weight = 1);
public record UpdateMultiCollection(
int MultiCollectionId,
@@ -76,7 +76,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
CollectionId = i.CollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
PlaybackOrder = i.PlaybackOrder,
Weight = i.Weight
})
.ToList();
var toRemove = c.MultiCollectionItems
@@ -94,6 +95,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
item.Weight = incoming.Weight;
}
}
@@ -110,7 +112,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
SmartCollectionId = i.SmartCollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
PlaybackOrder = i.PlaybackOrder,
Weight = i.Weight
})
.ToList();
var toRemoveSmart = c.MultiCollectionSmartItems
@@ -128,6 +131,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
item.Weight = incoming.Weight;
}
}
@@ -156,8 +160,20 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
TvContext dbContext,
UpdateMultiCollection request,
CancellationToken cancellationToken) =>
(await MultiCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
(await MultiCollectionMustExist(dbContext, request, cancellationToken),
await ValidateName(dbContext, request),
ValidateWeights(request))
.Apply((collectionToUpdate, _, _) => collectionToUpdate);
// Bounds are shared with the create path so the two cannot drift -- they silently disagreed before #402:
// EF's HasDefaultValue substitutes 1 for a 0 on INSERT (0 reads as "not set"), but an UPDATE writes the 0
// through, so the same input landed differently depending on the verb. The enumerator clamps out-of-range
// weights, so a 0 no longer removes the source; this gate is about refusing input that has no meaning on a
// share-of-airtime scale, and about keeping create and update honest with each other. See #70.
private static Validation<BaseError, Unit> ValidateWeights(UpdateMultiCollection request) =>
request.Items.All(i => MultiCollectionItemWeight.IsValid(i.Weight))
? Unit.Default
: BaseError.New(MultiCollectionItemWeight.ValidationMessage);
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
TvContext dbContext,
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Tree;
using ErsatzTV.Application.Tree;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Domain;
@@ -70,7 +70,8 @@ internal static class Mapper
multiCollectionItem.MultiCollectionId,
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder);
multiCollectionItem.PlaybackOrder,
multiCollectionItem.Weight);
private static MultiCollectionSmartItemViewModel ProjectToViewModel(
MultiCollectionSmartItem multiCollectionSmartItem) =>
@@ -78,7 +79,8 @@ internal static class Mapper
multiCollectionSmartItem.MultiCollectionId,
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder);
multiCollectionSmartItem.PlaybackOrder,
multiCollectionSmartItem.Weight);
internal static TreeViewModel ProjectToViewModel(List<PlaylistGroup> playlistGroups) =>
new(
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
@@ -6,4 +6,5 @@ public record MultiCollectionItemViewModel(
int MultiCollectionId,
MediaCollectionViewModel Collection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
PlaybackOrder PlaybackOrder,
int Weight = 1);
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
@@ -6,4 +6,5 @@ public record MultiCollectionSmartItemViewModel(
int MultiCollectionId,
SmartCollectionViewModel SmartCollection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
PlaybackOrder PlaybackOrder,
int Weight = 1);
@@ -1,4 +1,4 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -17,6 +17,7 @@ public class GetAllMultiCollectionsHandler : IRequestHandler<GetAllMultiCollecti
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.MultiCollections
.Where(mc => mc.OwnedByChannelId == null)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -16,6 +16,7 @@ public class GetAllSmartCollectionsForApiHandler(IDbContextFactory<TvContext> db
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<SmartCollection> ffmpegProfiles = await dbContext.SmartCollections
.AsNoTracking()
.Where(sc => sc.OwnedByChannelId == null)
.ToListAsync(cancellationToken);
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
}
@@ -1,4 +1,4 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -17,6 +17,7 @@ public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollecti
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.SmartCollections
.Where(sc => sc.OwnedByChannelId == null)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -13,9 +13,12 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.MultiCollections.CountAsync(cancellationToken);
int count = await dbContext.MultiCollections
.CountAsync(mc => mc.OwnedByChannelId == null, cancellationToken);
IQueryable<MultiCollection> query = dbContext.MultiCollections.AsNoTracking();
IQueryable<MultiCollection> query = dbContext.MultiCollections
.AsNoTracking()
.Where(mc => mc.OwnedByChannelId == null);
if (!string.IsNullOrWhiteSpace(request.Query))
{
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -13,9 +13,12 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.SmartCollections.CountAsync(cancellationToken);
int count = await dbContext.SmartCollections
.CountAsync(sc => sc.OwnedByChannelId == null, cancellationToken);
IQueryable<SmartCollection> query = dbContext.SmartCollections.AsNoTracking();
IQueryable<SmartCollection> query = dbContext.SmartCollections
.AsNoTracking()
.Where(sc => sc.OwnedByChannelId == null);
if (!string.IsNullOrWhiteSpace(request.Query))
{
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
@@ -63,6 +63,9 @@ public abstract class ProgramScheduleItemCommandBase
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
case PlaybackOrder.Shuffle:
case PlaybackOrder.ShuffleInOrder:
// WeightedShuffle (#70) distributes across a multi collection's sources, so this is its
// intended home. Listed explicitly rather than falling through the switch by omission.
case PlaybackOrder.WeightedShuffle:
break;
}
}
@@ -112,7 +112,30 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
BlockMustExist(dbContext, request.BlockId, cancellationToken)
.BindT(block => MinutesMustBeValid(request, block))
.BindT(block => BlockNameMustBeValid(dbContext, block, request))
.BindT(block => CollectionTypesMustBeValid(request, block));
.BindT(block => CollectionTypesMustBeValid(request, block))
.BindT(block => PlaybackOrdersMustBeSupported(request, block));
private static Validation<BaseError, Block> PlaybackOrdersMustBeSupported(
ReplaceBlockItems request,
Block block) =>
request.Items
.Map(item => PlaybackOrderMustBeSupported(item.PlaybackOrder))
.Sequence()
.Map(_ => block);
private static Validation<BaseError, Unit> PlaybackOrderMustBeSupported(PlaybackOrder playbackOrder)
{
// WeightedShuffle (#70) is implemented for classic schedule items only. BlockPlayoutBuilder filters
// block items against an allow-list of orders and silently `continue`s past anything else, so an
// unsupported order here means the block item never airs and nothing reports why.
if (playbackOrder is PlaybackOrder.WeightedShuffle)
{
return BaseError.New(
$"Playback order '{playbackOrder}' is not supported for block items; it is available on classic schedule items");
}
return Unit.Default;
}
private static Task<Validation<BaseError, Block>> BlockMustExist(
TvContext dbContext,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public record GetSearchFieldCatalog : IRequest<List<SearchFieldResponseModel>>;
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldCatalogHandler : IRequestHandler<GetSearchFieldCatalog, List<SearchFieldResponseModel>>
{
public Task<List<SearchFieldResponseModel>> Handle(
GetSearchFieldCatalog request,
CancellationToken cancellationToken) =>
Task.FromResult(SearchFieldCatalog.Fields);
}
@@ -1,3 +1,4 @@
namespace ErsatzTV.Application.Search;
namespace ErsatzTV.Application.Search;
public record QuerySearchIndexAllItems(string Query) : IRequest<SearchResultAllItemsViewModel>;
public record QuerySearchIndexAllItems(string Query, int PageNum, int PageSize)
: IRequest<SearchResultAllItemsViewModel>;
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
@@ -8,21 +9,69 @@ public class QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex)
{
public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request,
CancellationToken cancellationToken) =>
new(
await GetIds(LuceneSearchIndex.MovieType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.ShowType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.SeasonType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.EpisodeType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.ArtistType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.OtherVideoType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.SongType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.ImageType, request.Query, cancellationToken),
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, cancellationToken));
CancellationToken cancellationToken)
{
int skip = request.PageNum * request.PageSize;
int limit = request.PageSize;
private async Task<List<int>> GetIds(string type, string query, CancellationToken cancellationToken) =>
(await searchIndex.Search($"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
.Map(i => i.Id)
.ToList();
(List<int> Ids, int Total) movies =
await GetIds(LuceneSearchIndex.MovieType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) shows =
await GetIds(LuceneSearchIndex.ShowType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) seasons =
await GetIds(LuceneSearchIndex.SeasonType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) episodes =
await GetIds(LuceneSearchIndex.EpisodeType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) artists =
await GetIds(LuceneSearchIndex.ArtistType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) musicVideos =
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) otherVideos =
await GetIds(LuceneSearchIndex.OtherVideoType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) songs =
await GetIds(LuceneSearchIndex.SongType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) images =
await GetIds(LuceneSearchIndex.ImageType, request.Query, skip, limit, cancellationToken);
(List<int> Ids, int Total) remoteStreams =
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, skip, limit, cancellationToken);
return new SearchResultAllItemsViewModel(
movies.Ids,
shows.Ids,
seasons.Ids,
episodes.Ids,
artists.Ids,
musicVideos.Ids,
otherVideos.Ids,
songs.Ids,
images.Ids,
remoteStreams.Ids,
new SearchResultAllItemsTotals(
movies.Total,
shows.Total,
seasons.Total,
episodes.Total,
artists.Total,
musicVideos.Total,
otherVideos.Total,
songs.Total,
images.Total,
remoteStreams.Total));
}
private async Task<(List<int> Ids, int Total)> GetIds(
string type,
string query,
int skip,
int limit,
CancellationToken cancellationToken)
{
SearchResult result = await searchIndex.Search(
$"type:{type} AND ({query})",
string.Empty,
skip,
limit,
cancellationToken);
return (result.Items.Map(i => i.Id).ToList(), result.TotalCount);
}
}
@@ -15,6 +15,9 @@ public class SearchMultiCollectionsHandler(IDbContextFactory<TvContext> dbContex
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.MultiCollections
.AsNoTracking()
// Hide system-owned auto-tune weighted artifacts (#425) from the scheduling picker: selecting one
// into a user schedule would let a later channel delete cascade away that schedule item.
.Where(mc => mc.OwnedByChannelId == null)
.Where(mc => EF.Functions.Like(mc.Name, $"%{request.Query}%"))
.OrderBy(mc => mc.Name)
.Take(10)
@@ -15,6 +15,9 @@ public class SearchSmartCollectionsHandler(IDbContextFactory<TvContext> dbContex
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.SmartCollections
.AsNoTracking()
// Hide system-owned auto-tune weighted artifacts (#425) from the scheduling picker: selecting one
// into a user schedule would let a later channel delete cascade away that schedule item.
.Where(sc => sc.OwnedByChannelId == null)
.Where(sc => EF.Functions.Like(sc.Name, $"%{request.Query}%"))
.OrderBy(sc => sc.Name)
.Take(10)
@@ -0,0 +1,58 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search;
public static class SearchFieldCatalog
{
private static readonly string[] None = [];
// Allowed values for the `type` enum — mirrors the lowercase tokens the Lucene index stores for
// the `type` field (see LuceneSearchIndex.TypeField and its *Type constants).
private static readonly string[] ItemTypes =
[
"movie", "show", "season", "episode", "artist", "music_video", "other_video", "song", "image",
"remote_stream"
];
public static readonly List<SearchFieldResponseModel> Fields =
[
// General
new("title", "Title", "text", "General", None),
new("genre", "Genre", "text", "General", None),
new("tag", "Tag", "text", "General", None),
new("plot", "Plot", "fulltext", "General", None),
new("content_rating", "Content rating", "text", "General", None),
new("studio", "Studio", "text", "General", None),
new("collection", "Collection", "text", "General", None),
new("state", "State", "text", "General", None),
new("type", "Item type", "enum", "General", ItemTypes),
// TV
new("network", "Network", "text", "TV", None),
new("show_title", "Show title", "text", "TV", None),
new("show_genre", "Show genre", "text", "TV", None),
new("season_number", "Season number", "number", "TV", None),
new("episode_number", "Episode number", "number", "TV", None),
// Movie / People
new("director", "Director", "text", "Movie", None),
new("writer", "Writer", "text", "Movie", None),
new("actor", "Actor", "text", "Movie", None),
// Music
new("artist", "Artist", "text", "Music", None),
new("album", "Album", "text", "Music", None),
new("album_artist", "Album artist", "text", "Music", None),
// Technical
new("minutes", "Duration (min)", "number", "Technical", None),
new("height", "Height (px)", "number", "Technical", None),
new("width", "Width (px)", "number", "Technical", None),
new("video_codec", "Video codec", "text", "Technical", None),
new("video_dynamic_range", "Dynamic range", "text", "Technical", None),
// Dates
new("added_date", "Date added", "date", "Dates", None),
new("release_date", "Release date", "date", "Dates", None)
];
}
@@ -1,4 +1,4 @@
namespace ErsatzTV.Application.Search;
namespace ErsatzTV.Application.Search;
public record SearchResultAllItemsViewModel(
List<int> MovieIds,
@@ -10,4 +10,17 @@ public record SearchResultAllItemsViewModel(
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
List<int> RemoteStreamIds);
List<int> RemoteStreamIds,
SearchResultAllItemsTotals Totals);
public record SearchResultAllItemsTotals(
int MovieCount,
int ShowCount,
int SeasonCount,
int EpisodeCount,
int ArtistCount,
int MusicVideoCount,
int OtherVideoCount,
int SongCount,
int ImageCount,
int RemoteStreamCount);
@@ -1,4 +1,5 @@
using System.IO.Abstractions;
using System.Diagnostics;
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics;
@@ -75,6 +76,10 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
private async Task<Unit> StartProcess(StartFFmpegSession request, CancellationToken cancellationToken)
{
// measures the full client-visible cold-start: this handler only runs when the session
// is not already active, so its whole duration is the tune-in delay the client waits on
var coldStartStopwatch = Stopwatch.StartNew();
Option<TimeSpan> idleTimeout = await _configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken)
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
@@ -114,7 +119,34 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken)
.Map(maybeCount => maybeCount.Match(identity, () => 1));
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
PlaylistSegmentsResult segments = await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
coldStartStopwatch.Stop();
// #350 cold-start instrumentation: one self-describing sample per tune-in so the real
// driver split (process startup vs segment fill, and which heavy features were active)
// can be measured on prod before any transcode-pipeline optimization.
// "setup" is the pre-wait handler overhead (config reads + framerate/channel/graphics
// mediator sends + worker spawn) so total = setup + startup + fill accounts for every ms.
long totalMs = (long)coldStartStopwatch.Elapsed.TotalMilliseconds;
long startupMs = (long)segments.ProcessStartup.TotalMilliseconds;
long fillMs = (long)segments.SegmentFill.TotalMilliseconds;
long setupMs = Math.Max(0, totalMs - startupMs - fillMs);
_logger.LogInformation(
"HLS cold-start channel {Channel} mode {Mode}: total {TotalMs}ms " +
"(setup {SetupMs}ms + startup {ProcessStartupMs}ms + fill {SegmentFillMs}ms), " +
"segments {SegmentsReached}/{InitialSegmentCount}, " +
"deadlineExpired {DeadlineExpired}, subtitleBurnIn {SubtitleBurnIn}, hwaccel {HwAccel}",
request.ChannelNumber,
request.Mode,
totalMs,
setupMs,
startupMs,
fillMs,
segments.SegmentsReached,
segments.InitialSegmentCount,
segments.DeadlineExpired,
segments.Features.SubtitleBurnIn,
segments.Features.HardwareAcceleration);
return Unit.Default;
}
@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO.Abstractions;
@@ -55,6 +55,12 @@ public class HlsSessionWorker : IHlsSessionWorker
private string _workingDirectory;
private Option<double> _slugSeconds;
// The arguments of the first ffmpeg process launched for this session, captured for
// cold-start telemetry (#350). Written once on the sequential Run loop, read on the
// handler thread from WaitForPlaylistSegments after segments exist (a happens-before:
// segments cannot exist until this process ran) — volatile for cross-thread visibility.
private volatile string _coldStartFFmpegArguments;
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
IGraphicsEngine graphicsEngine,
@@ -300,17 +306,20 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
public async Task WaitForPlaylistSegments(
public async Task<PlaylistSegmentsResult> WaitForPlaylistSegments(
int initialSegmentCount,
CancellationToken cancellationToken)
{
_logger.LogDebug("Waiting for playlist segments...");
var sw = Stopwatch.StartNew();
var processStartup = TimeSpan.Zero;
var segmentCount = 0;
try
{
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
// Phase A: ffmpeg process spawn -> playlist file exists (startup + probe + init + first GOP)
_logger.LogDebug("Waiting for playlist to exist");
while (!_fileSystem.File.Exists(playlistFileName))
{
@@ -318,12 +327,13 @@ public class HlsSessionWorker : IHlsSessionWorker
}
_logger.LogDebug("Playlist exists");
processStartup = sw.Elapsed;
// start the segment-wait deadline only after the playlist file appears,
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
var segmentCount = 0;
// Phase B: playlist exists -> the requested number of segments are present (or deadline)
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
{
@@ -345,6 +355,14 @@ public class HlsSessionWorker : IHlsSessionWorker
segmentCount = result.SegmentCount;
}
}
return new PlaylistSegmentsResult(
processStartup,
sw.Elapsed - processStartup,
segmentCount,
initialSegmentCount,
segmentCount < initialSegmentCount,
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments));
}
finally
{
@@ -533,6 +551,9 @@ public class HlsSessionWorker : IHlsSessionWorker
Command process = processModel.Process;
// capture the first process's arguments once for cold-start telemetry (#350)
_coldStartFFmpegArguments ??= process.Arguments;
_logger.LogDebug("ffmpeg hls arguments {FFmpegArguments}", process.Arguments);
try
@@ -0,0 +1,89 @@
using ErsatzTV.Core.FFmpeg;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class ColdStartFeaturesTests
{
[Test]
public void Should_Detect_Subtitle_BurnIn_From_Subtitles_Filter()
{
const string Args =
"-i ep.mkv -filter_complex \"[0:0]subtitles=http://localhost:8409/media/subtitle/1.ass[v]\" " +
"-c:v h264_vaapi -f hls";
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
features.SubtitleBurnIn.ShouldBeTrue();
}
[Test]
public void Should_Not_Flag_Subtitle_BurnIn_For_Copy_Or_Overlay()
{
// subtitle stream copy (-c:s) and a watermark/image-subtitle overlay must NOT be read as burn-in
const string Args = "-i ep.mkv -filter_complex \"[v][wm]overlay=10:10:format=0[vwm]\" -c:s copy -c:v libx264 -f hls";
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
features.SubtitleBurnIn.ShouldBeFalse();
}
[TestCase("-c:v h264_vaapi -f hls", "vaapi")]
[TestCase("-c:v hevc_vaapi -f hls", "vaapi")]
[TestCase("-c:v h264_nvenc -f hls", "nvenc")]
[TestCase("-c:v h264_qsv -f hls", "qsv")]
[TestCase("-c:v h264_videotoolbox -f hls", "videotoolbox")]
[TestCase("-c:v h264_amf -f hls", "amf")]
public void Should_Detect_Hardware_Family_From_Encoder(string args, string expected)
{
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(args);
features.HardwareAcceleration.ShouldBe(expected);
}
[Test]
public void Should_Detect_Hardware_Family_From_Vaapi_Filter_Even_Without_Encoder_Token()
{
// a vaapi-accelerated pipeline surfaces the family via filters (e.g. scale_vaapi) too
const string Args = "-hwaccel vaapi -i ep.mkv -vf scale_vaapi=1920:1080 -f hls";
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
features.HardwareAcceleration.ShouldBe("vaapi");
}
[Test]
public void Should_Fall_Back_To_Hwaccel_Flag_When_No_Hardware_Encoder_Or_Filter()
{
// hardware decode, software encode: attribute the decode accel
const string Args = "-hwaccel cuda -i ep.mkv -c:v libx264 -f hls";
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
features.HardwareAcceleration.ShouldBe("cuda");
}
[Test]
public void Should_Report_Software_When_No_Acceleration()
{
const string Args = "-i ep.mkv -c:v libx264 -c:a aac -f hls";
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
features.SubtitleBurnIn.ShouldBeFalse();
features.HardwareAcceleration.ShouldBe("software");
}
[TestCase("")]
[TestCase(" ")]
[TestCase(null)]
public void Should_Report_Unknown_For_Empty_Arguments(string args)
{
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(args);
features.SubtitleBurnIn.ShouldBeFalse();
features.HardwareAcceleration.ShouldBe("unknown");
}
}
@@ -187,6 +187,47 @@ public class ChannelGuideGoldenTests
xml.ShouldNotContain("a&b");
}
// The access-token value is HTTP-request-derived (?access_token=) and interpolated raw into the
// {AccessTokenUri} placeholder, so a token containing XML-special chars must be escaped too —
// otherwise it malforms the whole guide, exactly like the {RequestBase} case above. (Finding #376.)
[Test]
public async Task Guide_xml_escapes_access_token()
{
MockFileSystem fileSystem = BuildCacheFileSystem();
var localFileSystem = Substitute.For<ILocalFileSystem>();
localFileSystem
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
.Returns(new[]
{
FragmentPath(fileSystem, "channels.xml"),
FragmentPath(fileSystem, "2.xml")
});
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.None);
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem,
configElementRepository);
Either<BaseError, ChannelGuide> result = await handler.Handle(
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: "tok&<>\""),
CancellationToken.None);
string xml = result.Match(
Right: guide => guide.ToXml(),
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
// Every XML-special char in the token must be escaped; the raw token must never reach the output.
xml.ShouldContain("access_token=tok&amp;&lt;&gt;&quot;");
xml.ShouldNotContain("access_token=tok&<");
}
// --- harness ---
private async Task Verify(string goldenName, GetChannelGuide request)
@@ -0,0 +1,108 @@
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Heavy Movie 01
001 | 2026-01-15 00:30:00 - 2026-01-15 01:00:00 | None | Heavy Movie 02
002 | 2026-01-15 01:00:00 - 2026-01-15 01:30:00 | None | Light Movie 04
003 | 2026-01-15 01:30:00 - 2026-01-15 02:00:00 | None | Heavy Movie 01
004 | 2026-01-15 02:00:00 - 2026-01-15 02:30:00 | None | Heavy Movie 02
005 | 2026-01-15 02:30:00 - 2026-01-15 03:00:00 | None | Heavy Movie 01
006 | 2026-01-15 03:00:00 - 2026-01-15 03:30:00 | None | Light Movie 03
007 | 2026-01-15 03:30:00 - 2026-01-15 04:00:00 | None | Heavy Movie 02
008 | 2026-01-15 04:00:00 - 2026-01-15 04:30:00 | None | Heavy Movie 01
009 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Heavy Movie 02
010 | 2026-01-15 05:00:00 - 2026-01-15 05:30:00 | None | Light Movie 01
011 | 2026-01-15 05:30:00 - 2026-01-15 06:00:00 | None | Heavy Movie 01
012 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Heavy Movie 02
013 | 2026-01-15 06:30:00 - 2026-01-15 07:00:00 | None | Heavy Movie 01
014 | 2026-01-15 07:00:00 - 2026-01-15 07:30:00 | None | Light Movie 02
015 | 2026-01-15 07:30:00 - 2026-01-15 08:00:00 | None | Heavy Movie 02
016 | 2026-01-15 08:00:00 - 2026-01-15 08:30:00 | None | Heavy Movie 01
017 | 2026-01-15 08:30:00 - 2026-01-15 09:00:00 | None | Heavy Movie 02
018 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Light Movie 02
019 | 2026-01-15 09:30:00 - 2026-01-15 10:00:00 | None | Heavy Movie 01
020 | 2026-01-15 10:00:00 - 2026-01-15 10:30:00 | None | Heavy Movie 02
021 | 2026-01-15 10:30:00 - 2026-01-15 11:00:00 | None | Heavy Movie 01
022 | 2026-01-15 11:00:00 - 2026-01-15 11:30:00 | None | Light Movie 04
023 | 2026-01-15 11:30:00 - 2026-01-15 12:00:00 | None | Heavy Movie 02
024 | 2026-01-15 12:00:00 - 2026-01-15 12:30:00 | None | Heavy Movie 01
025 | 2026-01-15 12:30:00 - 2026-01-15 13:00:00 | None | Heavy Movie 02
026 | 2026-01-15 13:00:00 - 2026-01-15 13:30:00 | None | Light Movie 01
027 | 2026-01-15 13:30:00 - 2026-01-15 14:00:00 | None | Heavy Movie 01
028 | 2026-01-15 14:00:00 - 2026-01-15 14:30:00 | None | Heavy Movie 02
029 | 2026-01-15 14:30:00 - 2026-01-15 15:00:00 | None | Heavy Movie 01
030 | 2026-01-15 15:00:00 - 2026-01-15 15:30:00 | None | Light Movie 03
031 | 2026-01-15 15:30:00 - 2026-01-15 16:00:00 | None | Heavy Movie 02
032 | 2026-01-15 16:00:00 - 2026-01-15 16:30:00 | None | Heavy Movie 01
033 | 2026-01-15 16:30:00 - 2026-01-15 17:00:00 | None | Heavy Movie 02
034 | 2026-01-15 17:00:00 - 2026-01-15 17:30:00 | None | Light Movie 02
035 | 2026-01-15 17:30:00 - 2026-01-15 18:00:00 | None | Heavy Movie 01
036 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Heavy Movie 02
037 | 2026-01-15 18:30:00 - 2026-01-15 19:00:00 | None | Heavy Movie 01
038 | 2026-01-15 19:00:00 - 2026-01-15 19:30:00 | None | Light Movie 03
039 | 2026-01-15 19:30:00 - 2026-01-15 20:00:00 | None | Heavy Movie 02
040 | 2026-01-15 20:00:00 - 2026-01-15 20:30:00 | None | Heavy Movie 01
041 | 2026-01-15 20:30:00 - 2026-01-15 21:00:00 | None | Heavy Movie 02
042 | 2026-01-15 21:00:00 - 2026-01-15 21:30:00 | None | Light Movie 01
043 | 2026-01-15 21:30:00 - 2026-01-15 22:00:00 | None | Heavy Movie 01
044 | 2026-01-15 22:00:00 - 2026-01-15 22:30:00 | None | Heavy Movie 02
045 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Heavy Movie 01
046 | 2026-01-15 23:00:00 - 2026-01-15 23:30:00 | None | Light Movie 04
047 | 2026-01-15 23:30:00 - 2026-01-16 00:00:00 | None | Heavy Movie 02
048 | 2026-01-16 00:00:00 - 2026-01-16 00:30:00 | None | Heavy Movie 01
049 | 2026-01-16 00:30:00 - 2026-01-16 01:00:00 | None | Heavy Movie 02
050 | 2026-01-16 01:00:00 - 2026-01-16 01:30:00 | None | Light Movie 02
051 | 2026-01-16 01:30:00 - 2026-01-16 02:00:00 | None | Heavy Movie 01
052 | 2026-01-16 02:00:00 - 2026-01-16 02:30:00 | None | Heavy Movie 02
053 | 2026-01-16 02:30:00 - 2026-01-16 03:00:00 | None | Heavy Movie 01
054 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Light Movie 04
055 | 2026-01-16 03:30:00 - 2026-01-16 04:00:00 | None | Heavy Movie 02
056 | 2026-01-16 04:00:00 - 2026-01-16 04:30:00 | None | Heavy Movie 01
057 | 2026-01-16 04:30:00 - 2026-01-16 05:00:00 | None | Heavy Movie 02
058 | 2026-01-16 05:00:00 - 2026-01-16 05:30:00 | None | Light Movie 01
059 | 2026-01-16 05:30:00 - 2026-01-16 06:00:00 | None | Heavy Movie 01
060 | 2026-01-16 06:00:00 - 2026-01-16 06:30:00 | None | Heavy Movie 02
061 | 2026-01-16 06:30:00 - 2026-01-16 07:00:00 | None | Heavy Movie 01
062 | 2026-01-16 07:00:00 - 2026-01-16 07:30:00 | None | Light Movie 03
063 | 2026-01-16 07:30:00 - 2026-01-16 08:00:00 | None | Heavy Movie 02
064 | 2026-01-16 08:00:00 - 2026-01-16 08:30:00 | None | Heavy Movie 01
065 | 2026-01-16 08:30:00 - 2026-01-16 09:00:00 | None | Heavy Movie 02
066 | 2026-01-16 09:00:00 - 2026-01-16 09:30:00 | None | Light Movie 01
067 | 2026-01-16 09:30:00 - 2026-01-16 10:00:00 | None | Heavy Movie 01
068 | 2026-01-16 10:00:00 - 2026-01-16 10:30:00 | None | Heavy Movie 02
069 | 2026-01-16 10:30:00 - 2026-01-16 11:00:00 | None | Heavy Movie 01
070 | 2026-01-16 11:00:00 - 2026-01-16 11:30:00 | None | Light Movie 02
071 | 2026-01-16 11:30:00 - 2026-01-16 12:00:00 | None | Heavy Movie 02
072 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Heavy Movie 01
073 | 2026-01-16 12:30:00 - 2026-01-16 13:00:00 | None | Heavy Movie 02
074 | 2026-01-16 13:00:00 - 2026-01-16 13:30:00 | None | Light Movie 03
075 | 2026-01-16 13:30:00 - 2026-01-16 14:00:00 | None | Heavy Movie 01
076 | 2026-01-16 14:00:00 - 2026-01-16 14:30:00 | None | Heavy Movie 02
077 | 2026-01-16 14:30:00 - 2026-01-16 15:00:00 | None | Heavy Movie 01
078 | 2026-01-16 15:00:00 - 2026-01-16 15:30:00 | None | Light Movie 04
079 | 2026-01-16 15:30:00 - 2026-01-16 16:00:00 | None | Heavy Movie 02
080 | 2026-01-16 16:00:00 - 2026-01-16 16:30:00 | None | Heavy Movie 01
081 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Heavy Movie 02
082 | 2026-01-16 17:00:00 - 2026-01-16 17:30:00 | None | Light Movie 03
083 | 2026-01-16 17:30:00 - 2026-01-16 18:00:00 | None | Heavy Movie 01
084 | 2026-01-16 18:00:00 - 2026-01-16 18:30:00 | None | Heavy Movie 02
085 | 2026-01-16 18:30:00 - 2026-01-16 19:00:00 | None | Heavy Movie 01
086 | 2026-01-16 19:00:00 - 2026-01-16 19:30:00 | None | Light Movie 02
087 | 2026-01-16 19:30:00 - 2026-01-16 20:00:00 | None | Heavy Movie 02
088 | 2026-01-16 20:00:00 - 2026-01-16 20:30:00 | None | Heavy Movie 01
089 | 2026-01-16 20:30:00 - 2026-01-16 21:00:00 | None | Heavy Movie 02
090 | 2026-01-16 21:00:00 - 2026-01-16 21:30:00 | None | Light Movie 04
091 | 2026-01-16 21:30:00 - 2026-01-16 22:00:00 | None | Heavy Movie 01
092 | 2026-01-16 22:00:00 - 2026-01-16 22:30:00 | None | Heavy Movie 02
093 | 2026-01-16 22:30:00 - 2026-01-16 23:00:00 | None | Heavy Movie 01
094 | 2026-01-16 23:00:00 - 2026-01-16 23:30:00 | None | Light Movie 01
095 | 2026-01-16 23:30:00 - 2026-01-17 00:00:00 | None | Heavy Movie 02
096 | 2026-01-17 00:00:00 - 2026-01-17 00:30:00 | None | Heavy Movie 01
097 | 2026-01-17 00:30:00 - 2026-01-17 01:00:00 | None | Heavy Movie 02
098 | 2026-01-17 01:00:00 - 2026-01-17 01:30:00 | None | Light Movie 04
099 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Heavy Movie 01
100 | 2026-01-17 02:00:00 - 2026-01-17 02:30:00 | None | Heavy Movie 02
101 | 2026-01-17 02:30:00 - 2026-01-17 03:00:00 | None | Heavy Movie 01
102 | 2026-01-17 03:00:00 - 2026-01-17 03:30:00 | None | Light Movie 02
103 | 2026-01-17 03:30:00 - 2026-01-17 04:00:00 | None | Heavy Movie 02
104 | 2026-01-17 04:00:00 - 2026-01-17 04:30:00 | None | Heavy Movie 01
105 | 2026-01-17 04:30:00 - 2026-01-17 05:00:00 | None | Heavy Movie 02
106 | 2026-01-17 05:00:00 - 2026-01-17 05:30:00 | None | Light Movie 01
107 | 2026-01-17 05:30:00 - 2026-01-17 06:00:00 | None | Heavy Movie 01
@@ -124,6 +124,16 @@ public class PlayoutBuildGoldenTests
[Test]
public Task Classic_shuffle() => Verify("classic-shuffle.txt", BuildShufflePlayout);
// Classic + PlaybackOrder.WeightedShuffle (#70): the weight only means anything end-to-end if it survives
// MultiCollection -> MediaCollectionRepository -> CollectionWithItems.Weight -> ShuffleSourceBuilder ->
// WeightedShuffleCollectionEnumerator. The unit tests pin the enumerator's sequence in isolation; this pins
// that the real builder actually distributes by weight. The fixture is deliberately lopsided -- the LIGHTER
// source (weight 1) is the LARGER collection (4 items vs 2) -- so a golden that merely tracked collection
// size, or that ignored weight, would look obviously different from one that honors a 3:1 ratio.
// Same determinism contract as Classic_shuffle: pinned Seed + Continue (Reset would randomize the seed).
[Test]
public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);
[Test]
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
public async Task Regenerate_goldens()
@@ -376,6 +386,183 @@ public class PlayoutBuildGoldenTests
return (playout.Id, titles);
}
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildWeightedPlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedWeightedData(cancellationToken);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
NullLogger<PlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Continue,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedWeightedData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Weighted LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Titles carry their source so the golden reads as a rotation rather than a list of ids.
// "Heavy" is the SMALL collection with weight 3; "Light" is the LARGE one with weight 1.
async Task<List<Movie>> SeedMovies(string prefix, int count)
{
var movies = new List<Movie>();
for (var i = 1; i <= count; i++)
{
movies.Add(
new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(30) } },
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"{prefix} Movie {i:D2}",
ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return movies;
}
List<Movie> heavy = await SeedMovies("Heavy", 2);
List<Movie> light = await SeedMovies("Light", 4);
var titles = heavy.Concat(light).ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
var heavyCollection = new Collection
{
Name = "Weighted Heavy Collection",
MediaItems = heavy.Cast<MediaItem>().ToList()
};
var lightCollection = new Collection
{
Name = "Weighted Light Collection",
MediaItems = light.Cast<MediaItem>().ToList()
};
await context.Collections.AddRangeAsync([heavyCollection, lightCollection], cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var multiCollection = new MultiCollection
{
Name = "Weighted Multi Collection",
MultiCollectionItems =
[
new MultiCollectionItem
{
CollectionId = heavyCollection.Id,
ScheduleAsGroup = true,
PlaybackOrder = PlaybackOrder.WeightedShuffle,
Weight = 3
},
new MultiCollectionItem
{
CollectionId = lightCollection.Id,
ScheduleAsGroup = true,
PlaybackOrder = PlaybackOrder.WeightedShuffle,
Weight = 1
}
]
};
await context.MultiCollections.AddAsync(multiCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItems = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
MultiCollection = multiCollection,
MultiCollectionId = multiCollection.Id,
CollectionType = CollectionType.MultiCollection,
PlayoutDuration = TimeSpan.FromHours(1),
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.WeightedShuffle
}
};
var ffmpegProfile = new FFmpegProfile { Name = "Weighted FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000005"))
{
Name = "Weighted Test Channel",
Number = "5",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = "Weighted Test Schedule", Items = scheduleItems };
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id,
ScheduleKind = PlayoutScheduleKind.Classic,
// Pinned so the weighted rotation is deterministic; Continue preserves it (Reset would overwrite).
Seed = 7654321
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
@@ -0,0 +1,355 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using LanguageExt.UnsafeValueAccess;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
/// <summary>
/// Pins the weighted / fair-share distribution contract (#70). The sequence semantics here are the
/// product decision, so they are asserted exactly rather than statistically.
/// </summary>
[TestFixture]
public class WeightedShuffleCollectionEnumeratorTests
{
// ids are allocated per source so an emitted item's source is identifiable from its id
private const int SourceAFirstId = 100;
private const int SourceBFirstId = 200;
private const int SourceCFirstId = 300;
private static CollectionWithItems Source(string key, int firstId, int itemCount, int weight) =>
new(
0,
0,
key,
Enumerable.Range(firstId, itemCount)
.Select(i => new Movie { Id = i, MovieMetadata = [] })
.Cast<MediaItem>()
.ToList(),
true,
PlaybackOrder.WeightedShuffle,
false,
weight);
private static string SourceOf(int id) => id switch
{
>= SourceCFirstId => "C",
>= SourceBFirstId => "B",
_ => "A"
};
private static List<string> TakeSourceSequence(WeightedShuffleCollectionEnumerator enumerator, int count)
{
var result = new List<string>();
for (var i = 0; i < count; i++)
{
enumerator.Current.IsSome.ShouldBeTrue();
result.Add(SourceOf(enumerator.Current.ValueUnsafe().Id));
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
return result;
}
[Test]
public void Weights_Three_To_One_Emit_A_A_B_A()
{
// the canonical smooth-WRR contract: 3:1 spreads B through the rotation (A A B A),
// rather than draining A first (A A A B) the way PlaylistItem.Count does
var collections = new List<CollectionWithItems>
{
Source("A", SourceAFirstId, 3, 3),
Source("B", SourceBFirstId, 1, 1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
CancellationToken.None);
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "B", "A"]);
}
[Test]
public void Equal_Weights_Are_Fair_Share_Regardless_Of_Collection_Size()
{
// the heart of goal (2): a 20-item source must air as often as a 2-item source.
// ShuffleInOrder cannot do this -- it plays every item once, so airtime tracks size.
var collections = new List<CollectionWithItems>
{
Source("A", SourceAFirstId, 20, 1),
Source("B", SourceBFirstId, 2, 1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
CancellationToken.None);
List<string> sequence = TakeSourceSequence(enumerator, 40);
// equal weights => strict alternation, so the small source loops rather than falling silent
sequence.Count(s => s == "A").ShouldBe(20);
sequence.Count(s => s == "B").ShouldBe(20);
}
[Test]
public void Ties_Break_To_The_Earliest_Source_In_List_Order()
{
var collections = new List<CollectionWithItems>
{
Source("A", SourceAFirstId, 4, 1),
Source("B", SourceBFirstId, 4, 1),
Source("C", SourceCFirstId, 4, 1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 99, Index = 0 },
CancellationToken.None);
// all accumulators tie every round, so list order decides
TakeSourceSequence(enumerator, 6).ShouldBe(["A", "B", "C", "A", "B", "C"]);
}
[Test]
public void An_Unweighted_Collection_Is_Fair_Share()
{
// a source arriving without an explicit weight must rotate as fair-share, never fall out
var collections = new List<CollectionWithItems>
{
new(0, 0, "A", [new Movie { Id = SourceAFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false),
new(0, 0, "B", [new Movie { Id = SourceBFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 7, Index = 0 },
CancellationToken.None);
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
}
[Test]
[TestCase(0)]
[TestCase(-5)]
public void A_Non_Positive_Weight_Does_Not_Delete_The_Source(int weight)
{
// the write path bounds weight, but a row can predate that gate. Treating a 0/negative weight as a
// filter would remove the source from the channel silently -- the exact failure this order avoids
// everywhere else. It is clamped to the floor instead.
var collections = new List<CollectionWithItems>
{
Source("A", SourceAFirstId, 2, 1),
Source("B", SourceBFirstId, 2, weight)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 21, Index = 0 },
CancellationToken.None);
TakeSourceSequence(enumerator, 8).ShouldContain("B");
}
[Test]
public void An_Enormous_Weight_Does_Not_Overflow_The_Rotation()
{
// summing unclamped weights is checked arithmetic, so an out-of-range row would throw from inside a
// playout build rather than merely schedule oddly
var collections = new List<CollectionWithItems>
{
Source("A", SourceAFirstId, 2, int.MaxValue),
Source("B", SourceBFirstId, 2, int.MaxValue)
};
WeightedShuffleCollectionEnumerator enumerator = null;
Should.NotThrow(() => enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 5, Index = 0 },
CancellationToken.None));
// both clamp to the ceiling, so they tie and alternate by list order
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
}
[Test]
[TestCase(12)]
[TestCase(13)]
[TestCase(20)]
[TestCase(37)]
public void Restoring_Past_A_Rotation_Wrap_Equals_Advancing_To_It(int target)
{
// the stateless claim has to hold ACROSS a wrap, not just within the first rotation: each wrap
// re-derives the rotation from the new seed alone, so (Seed, Index) still determines position.
// Cycle length here is 12, so every case but the first crosses at least one wrap.
List<CollectionWithItems> Collections() =>
[
Source("A", SourceAFirstId, 5, 3),
Source("B", SourceBFirstId, 3, 1)
];
const int Seed = 909;
var advanced = new WeightedShuffleCollectionEnumerator(
Collections(),
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
CancellationToken.None);
for (var i = 0; i < target; i++)
{
advanced.MoveNext(Option<DateTimeOffset>.None);
}
var restored = new WeightedShuffleCollectionEnumerator(
Collections(),
new CollectionEnumeratorState { Seed = advanced.State.Seed, Index = advanced.State.Index },
CancellationToken.None);
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
}
[Test]
public void Restoring_At_An_Index_Equals_Advancing_To_It()
{
// the stateless contract: (Seed, Index) fully determines position, which is what lets the
// existing CollectionEnumeratorState persistence carry this order with no per-source counters
List<CollectionWithItems> Collections() =>
[
Source("A", SourceAFirstId, 5, 3),
Source("B", SourceBFirstId, 3, 1)
];
const int Seed = 4242;
const int Target = 7;
var advanced = new WeightedShuffleCollectionEnumerator(
Collections(),
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
CancellationToken.None);
for (var i = 0; i < Target; i++)
{
advanced.MoveNext(Option<DateTimeOffset>.None);
}
var restored = new WeightedShuffleCollectionEnumerator(
Collections(),
new CollectionEnumeratorState { Seed = Seed, Index = Target },
CancellationToken.None);
restored.State.Index.ShouldBe(advanced.State.Index);
restored.State.Seed.ShouldBe(advanced.State.Seed);
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
}
[Test]
public void A_Single_Source_Emits_All_Of_Its_Items()
{
var collections = new List<CollectionWithItems> { Source("A", SourceAFirstId, 5, 3) };
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 11, Index = 0 },
CancellationToken.None);
var seen = new System.Collections.Generic.HashSet<int>();
for (var i = 0; i < 5; i++)
{
seen.Add(enumerator.Current.ValueUnsafe().Id);
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
seen.Count.ShouldBe(5);
}
[Test]
public void An_Empty_Source_Is_Ignored_Rather_Than_Emitting_Nothing()
{
var collections = new List<CollectionWithItems>
{
Source("A", SourceAFirstId, 2, 1),
new(0, 0, "empty", [], true, PlaybackOrder.WeightedShuffle, false, 5)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 3, Index = 0 },
CancellationToken.None);
// a heavily-weighted empty source must not starve the rotation or emit None
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "A", "A"]);
}
[Test]
[Timeout(10_000)]
public void Single_Item_Sources_Do_Not_Hang_On_Rotation_Wrap()
{
// regression: the wrap retries a rebuild to avoid an immediate repeat, but this order's lead item is
// decided by weight, so the heaviest source always leads. With one item in it the lead is invariant and
// an unbounded retry never terminates -- a hung playout build, not a wrong one. Found by the
// non-vacuity control, which hung instead of failing.
var collections = new List<CollectionWithItems>
{
Source("A", SourceAFirstId, 1, 3),
Source("B", SourceBFirstId, 1, 1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
CancellationToken.None);
// walk several full rotations so the wrap path is exercised repeatedly
List<string> sequence = TakeSourceSequence(enumerator, 24);
sequence.ShouldContain("A");
sequence.ShouldContain("B");
}
[Test]
public void No_Sources_Yields_No_Current()
{
var enumerator = new WeightedShuffleCollectionEnumerator(
[],
new CollectionEnumeratorState { Seed = 1, Index = 0 },
CancellationToken.None);
enumerator.Current.IsNone.ShouldBeTrue();
enumerator.Count.ShouldBe(0);
Should.NotThrow(() => enumerator.MoveNext(Option<DateTimeOffset>.None));
}
[Test]
public void A_Custom_Ordered_Source_Keeps_Its_Order()
{
var collections = new List<CollectionWithItems>
{
new(
0,
0,
"A",
Enumerable.Range(SourceAFirstId, 4)
.Select(i => new Movie { Id = i, MovieMetadata = [] })
.Cast<MediaItem>()
.ToList(),
true,
PlaybackOrder.WeightedShuffle,
true,
1)
};
var enumerator = new WeightedShuffleCollectionEnumerator(
collections,
new CollectionEnumeratorState { Seed = 555, Index = 0 },
CancellationToken.None);
var ids = new List<int>();
for (var i = 0; i < 4; i++)
{
ids.Add(enumerator.Current.ValueUnsafe().Id);
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
ids.ShouldBe([SourceAFirstId, SourceAFirstId + 1, SourceAFirstId + 2, SourceAFirstId + 3]);
}
}
@@ -1,4 +1,4 @@
#nullable enable
#nullable enable
using Newtonsoft.Json;
namespace ErsatzTV.Core.Api.Channels;
@@ -15,4 +15,5 @@ public record ChannelResponseModel(
string Language,
string StreamingMode,
bool IsEnabled,
bool ShowInEpg);
bool ShowInEpg,
int PlayoutCount);
@@ -5,4 +5,14 @@ public record HealthCheckResponseModel(
string Title,
string Status,
string Detail,
string? Link);
string? Brief,
// Deprecated: the raw remediation target, kept for back-compat. Prefer `Remediation`,
// which also carries the kind (external doc vs in-app route). Still populated.
string? Link,
HealthCheckRemediationResponseModel? Remediation);
// Where the user should go to investigate or fix the check. `Kind` is "ExternalDoc"
// (open `Target` as an external URL) or "AppRoute" (navigate to the `Target` /app path).
public record HealthCheckRemediationResponseModel(
string Kind,
string Target);
@@ -13,7 +13,8 @@ public record MultiCollectionItemResponseModel(
int? SmartCollectionId,
string Name,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
PlaybackOrder PlaybackOrder,
int Weight = 1);
public record PagedMultiCollectionsResponseModel(
int TotalCount,
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Search;
/// <summary>
/// One filterable field in the visual rule builder's catalog.
/// <c>Type</c> is one of: text, fulltext, number, date, enum.
/// <c>Values</c> is populated only for <c>enum</c> fields (allowed dropdown values); empty otherwise.
/// </summary>
public record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values);
@@ -11,4 +11,5 @@ public record SearchResultAllItemsResponseModel(
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
List<int> RemoteStreamIds);
List<int> RemoteStreamIds,
SearchResultAllItemsTotalsResponseModel Totals);
@@ -0,0 +1,17 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
// Per-kind total hit counts for a search all-items query, independent of the current page. Lets a
// client page GET /api/v1/search/all-items to completeness (the SPA "add all" flow) instead of
// materializing every id in one unbounded response (issue #293).
public record SearchResultAllItemsTotalsResponseModel(
int MovieCount,
int ShowCount,
int SeasonCount,
int EpisodeCount,
int ArtistCount,
int MusicVideoCount,
int OtherVideoCount,
int SongCount,
int ImageCount,
int RemoteStreamCount);
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Domain;
@@ -12,4 +12,12 @@ public class MultiCollection : IVersionedAggregate
public List<SmartCollection> SmartCollections { get; set; }
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
/// <summary>
/// When non-null, this multi collection is a system-owned artifact created for an auto-tune weighted
/// channel (#425): its members are the per-content-source SmartCollections that carry the rotation
/// weights. Hidden from the user-facing collection lists and deleted when the channel is deleted.
/// Null for every user-created multi collection.
/// </summary>
public int? OwnedByChannelId { get; set; }
}
@@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Domain;
public class MultiCollectionItem
{
@@ -8,4 +8,10 @@ public class MultiCollectionItem
public Collection Collection { get; set; }
public bool ScheduleAsGroup { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
/// <summary>
/// Relative share of airtime under <see cref="PlaybackOrder.WeightedShuffle" /> (#70). Defaults to 1,
/// which is fair-share. Ignored by every other playback order.
/// </summary>
public int Weight { get; set; } = 1;
}
@@ -0,0 +1,32 @@
namespace ErsatzTV.Core.Domain;
/// <summary>
/// Bounds for a multi-collection member's per-source <c>Weight</c> (#70).
/// <para>
/// The floor is <b>1, not 0</b>. A weight is a <i>share of airtime</i>, and 0 has no meaning on that scale —
/// "don't play this source" is expressed by removing it from the multi collection, not by weighting it to
/// nothing. Rejecting it here keeps that intent from being expressed in a way the rotation would have to
/// interpret. <c>WeightedShuffleCollectionEnumerator</c> independently clamps to this range rather than
/// trusting it, so a row that predates this gate rotates at the floor instead of vanishing; the two are
/// belt-and-braces, not duplicates — <b>neither is redundant</b>.
/// </para>
/// <para>
/// The ceiling is the same argument as the floor, not an arithmetic guard. A weight of a billion is not a
/// share of airtime any more than 0 is, so it is refused at the boundary rather than silently reinterpreted.
/// The rotation arithmetic is made safe by <c>EffectiveWeight</c>'s clamp (and by <c>CycleLength</c> summing
/// to <c>long</c>) — <b>not</b> by this constant: with the clamp in place a sum cannot overflow whatever the
/// stored value is, which is exactly why a row that predates this gate is still safe.
/// <c>An_Enormous_Weight_Does_Not_Overflow_The_Rotation</c> pins that.
/// </para>
/// </summary>
public static class MultiCollectionItemWeight
{
public const int Minimum = 1;
public const int Maximum = 1000;
public const int Default = 1;
public static bool IsValid(int weight) => weight is >= Minimum and <= Maximum;
public static string ValidationMessage =>
$"Weight must be between {Minimum} and {Maximum}";
}
@@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Domain;
public class MultiCollectionSmartItem
{
@@ -8,4 +8,10 @@ public class MultiCollectionSmartItem
public SmartCollection SmartCollection { get; set; }
public bool ScheduleAsGroup { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
/// <summary>
/// Relative share of airtime under <see cref="PlaybackOrder.WeightedShuffle" /> (#70). Defaults to 1,
/// which is fair-share. Ignored by every other playback order.
/// </summary>
public int Weight { get; set; } = 1;
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Domain;
@@ -10,4 +10,12 @@ public class SmartCollection
public string Query { get; set; }
public List<MultiCollection> MultiCollections { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
/// <summary>
/// When non-null, this is a system-owned artifact created for an auto-tune weighted channel (#425):
/// one per-content-source SmartCollection (or the catch-all remainder) inside that channel's
/// MultiCollection. Hidden from the user-facing collection lists and deleted when the channel is
/// deleted. Null for every user-created smart collection.
/// </summary>
public int? OwnedByChannelId { get; set; }
}
+6 -2
View File
@@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Domain;
public enum PlaybackOrder
{
@@ -11,5 +11,9 @@ public enum PlaybackOrder
MultiEpisodeShuffle = 5,
SeasonEpisode = 6,
RandomRotation = 7,
Marathon = 8
Marathon = 8,
// weighted / fair-share distribution (#70); Classic schedule items only -- write-path validation
// rejects it elsewhere, because the other engines silently drop or substitute an unknown order
WeightedShuffle = 9
}
+68
View File
@@ -0,0 +1,68 @@
namespace ErsatzTV.Core.FFmpeg;
/// <summary>
/// Heavy transcode features detected from a cold-start's FFmpeg arguments, so each HLS
/// cold-start latency sample is self-describing (#350). Only cleanly-detectable signals are
/// surfaced: subtitle burn-in (the libass <c>subtitles=</c> text filter) and the hardware
/// acceleration family. Watermark, HDR-&gt;SDR, and <em>image</em>-based subtitle burn-in are
/// intentionally NOT flagged here: in the argument string they all reduce to <c>overlay=</c>,
/// indistinguishable from one another, so a boolean would mislead the very measurement it
/// serves. Inspect the full <c>ffmpeg hls arguments</c> debug log for those details.
/// Detection is a case-sensitive substring scan of the whole argument string (which includes
/// the input media path), so a media filename literally containing e.g. <c>subtitles=</c> or
/// <c>_amf</c> could false-positive; this is telemetry-only (no behavior impact) and low-probability.
/// </summary>
public readonly record struct ColdStartFeatures(bool SubtitleBurnIn, string HardwareAcceleration)
{
// Encoder/filter suffix -> accel family. Matching any of these substrings means the
// pipeline is that hardware family (the encoder codec, e.g. h264_vaapi, dominates
// cold-start encoder init cost; VAAPI/QSV/CUDA filters carry the same suffix).
private static readonly (string Token, string Family)[] HardwareFamilies =
[
("_vaapi", "vaapi"),
("_nvenc", "nvenc"),
("_qsv", "qsv"),
("_videotoolbox", "videotoolbox"),
("_amf", "amf"),
("_rkmpp", "rkmpp")
];
public static ColdStartFeatures FromFFmpegArguments(string arguments)
{
if (string.IsNullOrWhiteSpace(arguments))
{
return new ColdStartFeatures(false, "unknown");
}
bool subtitleBurnIn = arguments.Contains("subtitles=", StringComparison.Ordinal);
return new ColdStartFeatures(subtitleBurnIn, DetectHardwareAcceleration(arguments));
}
private static string DetectHardwareAcceleration(string arguments)
{
foreach ((string token, string family) in HardwareFamilies)
{
if (arguments.Contains(token, StringComparison.Ordinal))
{
return family;
}
}
// No hardware encoder/filter present: fall back to the decode accel (a hardware
// decode + software encode still pays an init cost worth attributing).
const string HwAccelFlag = "-hwaccel ";
int index = arguments.IndexOf(HwAccelFlag, StringComparison.Ordinal);
if (index >= 0)
{
string rest = arguments[(index + HwAccelFlag.Length)..].TrimStart();
int end = rest.IndexOf(' ');
string value = end >= 0 ? rest[..end] : rest;
if (value.Length > 0)
{
return value;
}
}
return "software";
}
}
@@ -0,0 +1,16 @@
namespace ErsatzTV.Core.FFmpeg;
/// <summary>
/// Timing + descriptive result of an HLS cold-start's wait-for-first-segments phase (#350).
/// <see cref="ProcessStartup"/> is Phase A (wait entry -&gt; playlist file exists — approximately
/// FFmpeg process spawn + probe + libass/encoder init + first GOP, since the wait begins right
/// after the fire-and-forget worker is launched); <see cref="SegmentFill"/> is Phase B (playlist
/// exists -&gt; the requested number of segments are present, or the 8s deadline).
/// </summary>
public readonly record struct PlaylistSegmentsResult(
TimeSpan ProcessStartup,
TimeSpan SegmentFill,
int SegmentsReached,
int InitialSegmentCount,
bool DeadlineExpired,
ColdStartFeatures Features);
+9 -1
View File
@@ -1,3 +1,11 @@
namespace ErsatzTV.Core.Health;
public record HealthCheckLink(string Link);
// A remediation link attached to a health-check result: where the user should go to
// investigate or fix the reported problem, and whether that is an external doc or an
// in-app SPA route. `Target` is the URL (ExternalDoc) or the /app path (AppRoute).
public record HealthCheckLink(string Target, HealthCheckLinkKind Kind)
{
public static HealthCheckLink ExternalDoc(string url) => new(url, HealthCheckLinkKind.ExternalDoc);
public static HealthCheckLink AppRoute(string path) => new(path, HealthCheckLinkKind.AppRoute);
}
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Health;
public enum HealthCheckLinkKind
{
// Opens an external documentation page in a new tab.
ExternalDoc = 0,
// Navigates to an in-app SPA route (an /app/... path).
AppRoute = 1
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
@@ -10,5 +10,5 @@ public interface IHlsSessionWorker : IDisposable
void PlayoutUpdated();
HlsSessionModel GetModel();
Task Run(string channelNumber, Option<TimeSpan> idleTimeout, CancellationToken incomingCancellationToken);
Task WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
Task<PlaylistSegmentsResult> WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Scheduling;
@@ -9,4 +9,5 @@ public record CollectionWithItems(
List<MediaItem> MediaItems,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder,
bool UseCustomOrder);
bool UseCustomOrder,
int Weight = 1);
@@ -1217,7 +1217,8 @@ public class SchedulingEngine(
return _state;
}
private static Option<IMediaCollectionEnumerator> EnumeratorForContent(
// instance rather than static so an unsupported order can be reported (#70)
private Option<IMediaCollectionEnumerator> EnumeratorForContent(
List<MediaItem> items,
CollectionEnumeratorState state,
PlaybackOrder playbackOrder,
@@ -1235,6 +1236,12 @@ public class SchedulingEngine(
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
}
// None means the caller's foreach never runs, so the content is simply absent from the playout with
// nothing said. Report it instead of leaving a silently empty schedule (#70).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
playbackOrder);
return Option<IMediaCollectionEnumerator>.None;
}
@@ -1383,6 +1383,14 @@ public class PlayoutBuilder : IPlayoutBuilder
state,
activeSchedule.RandomStartPoint,
cancellationToken);
case PlaybackOrder.WeightedShuffle:
return new WeightedShuffleCollectionEnumerator(
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
_mediaCollectionRepository,
collectionKey,
cancellationToken),
state,
cancellationToken);
case PlaybackOrder.MultiEpisodeShuffle when
collectionKey.CollectionType == CollectionType.TelevisionShow &&
collectionKey.MediaItemId.HasValue:
@@ -0,0 +1,260 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// Weighted / fair-share distribution (issue #70). Picks a *source* by smooth weighted round-robin,
/// then takes that source's next item. A source's <see cref="CollectionWithItems.Weight" /> is its
/// relative share of airtime: weights 3:1 emit <c>A A B A</c> per rotation.
/// <para>
/// Fair-share is the equal-weights degenerate case (the default, since Weight defaults to 1):
/// every source airs equally often regardless of library size, so a small show loops while a
/// large one works through its items. This is what distinguishes it from
/// <see cref="ShuffleInOrderCollectionEnumerator" />, whose balanced shuffle plays every item
/// exactly once per cycle and therefore leaves airtime proportional to collection size
/// (it prevents clumping, not domination).
/// </para>
/// <para>
/// Deterministic and stateless: the emitted sequence is a pure function of
/// (<see cref="CollectionEnumeratorState.Seed" />, <see cref="CollectionEnumeratorState.Index" />),
/// so it restores by replay exactly like its siblings and needs no per-source persisted counters.
/// </para>
/// </summary>
public class WeightedShuffleCollectionEnumerator : IMediaCollectionEnumerator
{
/// <summary>
/// Upper bound on the precomputed rotation, so a large collection paired with a low weight
/// (picks needed grows as items * totalWeight / weight) can't allocate without limit. Hitting the
/// clamp only means the largest source doesn't finish its items within one rotation before the
/// reseed; the weight ratio itself is unaffected.
/// </summary>
private const int MaxCycleLength = 100_000;
/// <summary>
/// Bounds the avoid-an-immediate-repeat retry when a rotation wraps. Unlike
/// <see cref="ShuffleInOrderCollectionEnumerator" />, whose reshuffle randomizes which item leads the
/// next cycle, this order's lead item is decided by weight — the heaviest source always wins the first
/// pick. So when that source has a single item the lead never changes and an unbounded retry would spin
/// forever. Avoiding a back-to-back repeat is a nicety; never terminating is not.
/// </summary>
private const int MaxReshuffleAttempts = 10;
private readonly CancellationToken _cancellationToken;
private readonly IList<CollectionWithItems> _collections;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private Random _random;
private MediaItem[] _shuffled;
public WeightedShuffleCollectionEnumerator(
IList<CollectionWithItems> collections,
CollectionEnumeratorState state,
CancellationToken cancellationToken)
{
CurrentIncludeInProgramGuide = Option<bool>.None;
_collections = collections;
_cancellationToken = cancellationToken;
int cycleLength = CycleLength(collections);
if (cycleLength > 0 && state.Index >= cycleLength)
{
state.Index = 0;
state.Seed = new Random(state.Seed).Next();
}
_random = new Random(state.Seed);
_shuffled = Build(_collections, _random);
// computed over every source's items, not the current rotation: a rotation can be a strict subset
// (see MaxCycleLength) and is rebuilt on every wrap, so caching over it would go stale
_lazyMinimumDuration =
new Lazy<Option<TimeSpan>>(() =>
_collections
.Bind(c => c.MediaItems)
.Bind(i => i.GetNonZeroDuration())
.OrderBy(identity)
.HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
{
MoveNext(Option<DateTimeOffset>.None);
}
}
public void ResetState(CollectionEnumeratorState state)
{
// only rebuild if needed
if (State.Seed != state.Seed)
{
_random = new Random(state.Seed);
_shuffled = Build(_collections, _random);
}
State.Seed = state.Seed;
State.Index = state.Index;
}
public string SchedulingContextName => "Weighted Shuffle";
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _shuffled.Length != 0 ? _shuffled[State.Index % _shuffled.Length] : None;
public Option<bool> CurrentIncludeInProgramGuide { get; }
public void MoveNext(Option<DateTimeOffset> scheduledAt)
{
if (_shuffled.Length == 0)
{
return;
}
if ((State.Index + 1) % _shuffled.Length == 0)
{
Option<MediaItem> tail = Current;
State.Index = 0;
var attempts = 0;
do
{
State.Seed = _random.Next();
_random = new Random(State.Seed);
_shuffled = Build(_collections, _random);
attempts++;
// guard on the rotation, not the raw collection count: an empty source contributes nothing,
// so a 2-collection/1-item rotation would otherwise burn every attempt chasing an
// impossible non-repeat on every wrap
} while (!_cancellationToken.IsCancellationRequested && _shuffled.Length > 1 &&
attempts < MaxReshuffleAttempts &&
Current.Map(x => x.Id) == tail.Map(x => x.Id));
}
else
{
State.Index++;
}
if (_shuffled.Length > 0)
{
State.Index %= _shuffled.Length;
}
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Length;
/// <summary>
/// Length of one rotation: enough picks for the source that needs the most of them to work through
/// all of its items at its share of the rotation. Sources needing fewer picks loop within the
/// rotation — that looping is exactly what makes equal weights mean equal airtime.
/// </summary>
private static int CycleLength(IList<CollectionWithItems> collections)
{
List<CollectionWithItems> active = ActiveSources(collections);
if (active.Count == 0)
{
return 0;
}
long totalWeight = active.Sum(c => (long)EffectiveWeight(c));
var length = 0;
foreach (CollectionWithItems collection in active)
{
// picks needed for this source to emit every item once, given it wins Weight of every totalWeight picks
var needed = (int)Math.Min(
MaxCycleLength,
Math.Ceiling(collection.MediaItems.Count * (double)totalWeight / EffectiveWeight(collection)));
length = Math.Max(length, needed);
}
return length;
}
private static List<CollectionWithItems> ActiveSources(IList<CollectionWithItems> collections) =>
collections.Filter(c => c.MediaItems.Count > 0).ToList();
/// <summary>
/// Weight as the rotation should treat it. The write path bounds this (<see cref="MultiCollectionItemWeight" />),
/// but a row can predate that gate, so it is clamped rather than trusted: an out-of-range value must not
/// drop a source from the channel (0 or negative) or overflow the weight sum inside a playout build.
/// </summary>
private static int EffectiveWeight(CollectionWithItems collection) =>
Math.Clamp(collection.Weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum);
private static MediaItem[] Build(IList<CollectionWithItems> collections, Random random)
{
List<CollectionWithItems> active = ActiveSources(collections);
if (active.Count == 0)
{
return [];
}
var sources = active
.Map(c => new WeightedSource { Weight = EffectiveWeight(c), Items = OrderItems(c, random) })
.ToList();
int totalWeight = sources.Sum(s => s.Weight);
int cycleLength = CycleLength(collections);
var result = new List<MediaItem>(cycleLength);
for (var i = 0; i < cycleLength; i++)
{
// smooth weighted round-robin: every source gains its weight, the richest wins and pays the total.
// Strict '>' keeps ties on the earliest source in list order, which makes the sequence deterministic.
WeightedSource pick = null;
foreach (WeightedSource source in sources)
{
source.Accumulator += source.Weight;
if (pick is null || source.Accumulator > pick.Accumulator)
{
pick = source;
}
}
pick.Accumulator -= totalWeight;
result.Add(pick.Items[pick.Cursor % pick.Items.Length]);
pick.Cursor++;
}
return result.ToArray();
}
private static MediaItem[] OrderItems(CollectionWithItems collectionWithItems, Random random)
{
// A custom-ordered collection is an explicit user sequence, so it is honored rather than shuffled
// (same rule as ShuffleInOrderCollectionEnumerator). Otherwise the source's items are shuffled per
// rotation, which is what makes the reseed on wrap produce a different rotation.
if (collectionWithItems.UseCustomOrder)
{
return collectionWithItems.MediaItems.ToArray();
}
return Shuffle(collectionWithItems.MediaItems, random);
}
private static MediaItem[] Shuffle(IEnumerable<MediaItem> list, Random random)
{
MediaItem[] copy = list.ToArray();
int n = copy.Length;
while (n > 1)
{
n--;
int k = random.Next(n + 1);
(copy[k], copy[n]) = (copy[n], copy[k]);
}
return copy;
}
private class WeightedSource
{
public int Weight { get; init; }
public MediaItem[] Items { get; init; }
public int Cursor { get; set; }
public int Accumulator { get; set; }
}
}
@@ -93,7 +93,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
cancellationToken);
break;
// playlist is handled later
// playlist is handled later
}
_mediaItems[content.Key] = items;
@@ -178,7 +178,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
cancellationToken);
}
switch (Enum.Parse<PlaybackOrder>(content.Order, true))
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
switch (parsedOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
@@ -190,6 +191,13 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
}
// this path schedules nothing for the content, which is indistinguishable from "no items" downstream.
// Orders are addressed by name here, so any order the enum knows parses fine and then lands here --
// say so, rather than leaving an empty schedule to be explained (#70).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
parsedOrder);
return Option<IMediaCollectionEnumerator>.None;
}
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
namespace ErsatzTV.Infrastructure.MySql.Data;
/// <summary>
/// Classifies MySQL provider exceptions wrapped by EF Core. Wired to
/// <see cref="ErsatzTV.Infrastructure.Data.TvContext.IsUniqueConstraintViolation" /> at startup.
/// </summary>
public static class MySqlErrorClassifier
{
// ER_DUP_ENTRY (1062): a duplicate value for a PRIMARY KEY or UNIQUE index.
private const int ErDupEntry = 1062;
public static bool IsUniqueConstraintViolation(DbUpdateException ex) =>
ex.InnerException is MySqlException { Number: ErDupEntry };
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class AddMultiCollectionItemWeight : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Weight",
table: "MultiCollectionSmartItem",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "Weight",
table: "MultiCollectionItem",
type: "int",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Weight",
table: "MultiCollectionSmartItem");
migrationBuilder.DropColumn(
name: "Weight",
table: "MultiCollectionItem");
}
}
}
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class AddCollectionOwnedByChannelId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OwnedByChannelId",
table: "SmartCollection",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OwnedByChannelId",
table: "MultiCollection",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_SmartCollection_OwnedByChannelId",
table: "SmartCollection",
column: "OwnedByChannelId");
migrationBuilder.CreateIndex(
name: "IX_MultiCollection_OwnedByChannelId",
table: "MultiCollection",
column: "OwnedByChannelId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_SmartCollection_OwnedByChannelId",
table: "SmartCollection");
migrationBuilder.DropIndex(
name: "IX_MultiCollection_OwnedByChannelId",
table: "MultiCollection");
migrationBuilder.DropColumn(
name: "OwnedByChannelId",
table: "SmartCollection");
migrationBuilder.DropColumn(
name: "OwnedByChannelId",
table: "MultiCollection");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -1780,6 +1780,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("varchar(50)")
.UseCollation("utf8mb4_general_ci");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("int");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("int");
@@ -1789,6 +1792,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("MultiCollection", (string)null);
});
@@ -1806,6 +1811,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<bool>("ScheduleAsGroup")
.HasColumnType("tinyint(1)");
b.Property<int>("Weight")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(1);
b.HasKey("MultiCollectionId", "CollectionId");
b.HasIndex("CollectionId");
@@ -1827,6 +1837,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<bool>("ScheduleAsGroup")
.HasColumnType("tinyint(1)");
b.Property<int>("Weight")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(1);
b.HasKey("MultiCollectionId", "SmartCollectionId");
b.HasIndex("SmartCollectionId");
@@ -3515,6 +3530,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("varchar(50)")
.UseCollation("utf8mb4_general_ci");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("int");
b.Property<string>("Query")
.HasColumnType("longtext");
@@ -3523,6 +3541,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("SmartCollection", (string)null);
});
@@ -0,0 +1,22 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Sqlite.Data;
/// <summary>
/// Classifies SQLite provider exceptions wrapped by EF Core. Wired to
/// <see cref="ErsatzTV.Infrastructure.Data.TvContext.IsUniqueConstraintViolation" /> at startup.
/// </summary>
public static class SqliteErrorClassifier
{
// SQLITE_CONSTRAINT (primary result code 19), narrowed to the two extended codes that mean a
// duplicate key: SQLITE_CONSTRAINT_PRIMARYKEY (1555) and SQLITE_CONSTRAINT_UNIQUE (2067). Other
// constraint failures (FK 787, NOT NULL 1299, CHECK 275) are deliberately NOT treated as duplicates.
private const int SqliteConstraint = 19;
private const int SqliteConstraintPrimaryKey = 1555;
private const int SqliteConstraintUnique = 2067;
public static bool IsUniqueConstraintViolation(DbUpdateException ex) =>
ex.InnerException is SqliteException { SqliteErrorCode: SqliteConstraint } inner
&& inner.SqliteExtendedErrorCode is SqliteConstraintPrimaryKey or SqliteConstraintUnique;
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddMultiCollectionItemWeight : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Weight",
table: "MultiCollectionSmartItem",
type: "INTEGER",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "Weight",
table: "MultiCollectionItem",
type: "INTEGER",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Weight",
table: "MultiCollectionSmartItem");
migrationBuilder.DropColumn(
name: "Weight",
table: "MultiCollectionItem");
}
}
}
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddCollectionOwnedByChannelId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OwnedByChannelId",
table: "SmartCollection",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OwnedByChannelId",
table: "MultiCollection",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_SmartCollection_OwnedByChannelId",
table: "SmartCollection",
column: "OwnedByChannelId");
migrationBuilder.CreateIndex(
name: "IX_MultiCollection_OwnedByChannelId",
table: "MultiCollection",
column: "OwnedByChannelId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_SmartCollection_OwnedByChannelId",
table: "SmartCollection");
migrationBuilder.DropIndex(
name: "IX_MultiCollection_OwnedByChannelId",
table: "MultiCollection");
migrationBuilder.DropColumn(
name: "OwnedByChannelId",
table: "SmartCollection");
migrationBuilder.DropColumn(
name: "OwnedByChannelId",
table: "MultiCollection");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -1703,6 +1703,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("varchar(50)")
.UseCollation("NOCASE");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("INTEGER");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
@@ -1712,6 +1715,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("MultiCollection", (string)null);
});
@@ -1729,6 +1734,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<bool>("ScheduleAsGroup")
.HasColumnType("INTEGER");
b.Property<int>("Weight")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.HasKey("MultiCollectionId", "CollectionId");
b.HasIndex("CollectionId");
@@ -1750,6 +1760,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<bool>("ScheduleAsGroup")
.HasColumnType("INTEGER");
b.Property<int>("Weight")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.HasKey("MultiCollectionId", "SmartCollectionId");
b.HasIndex("SmartCollectionId");
@@ -3362,6 +3377,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("varchar(50)")
.UseCollation("NOCASE");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("INTEGER");
b.Property<string>("Query")
.HasColumnType("TEXT");
@@ -3370,6 +3388,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("SmartCollection", (string)null);
});
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -19,6 +19,10 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
builder.HasIndex(mc => mc.Name)
.IsUnique();
// Cleanup + list-hiding of the system multi collection an auto-tune weighted channel owns (#425).
// Nullable: null = a normal user multi collection.
builder.HasIndex(mc => mc.OwnedByChannelId);
builder.HasMany(m => m.Collections)
.WithMany(m => m.MultiCollections)
.UsingEntity<MultiCollectionItem>(
@@ -30,7 +34,16 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
.WithMany(mc => mc.MultiCollectionItems)
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId }));
j =>
{
j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId });
// default 1 = fair share (#70). Must be a DB default too, or existing rows migrate to 0 --
// a value that means nothing on a share-of-airtime scale. The enumerator clamps such a
// row to the floor, so it rotates rather than vanishing; the backfill should still be
// right at the source.
j.Property(mci => mci.Weight).HasDefaultValue(1);
});
builder.HasMany(m => m.SmartCollections)
.WithMany(m => m.MultiCollections)
@@ -43,6 +56,13 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
.WithMany(mc => mc.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId }));
j =>
{
j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId });
// mirrors MultiCollectionItem above -- omitting it would silently un-weight
// every smart-collection member of a multi collection
j.Property(mci => mci.Weight).HasDefaultValue(1);
});
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -16,5 +16,9 @@ public class SmartCollectionConfiguration : IEntityTypeConfiguration<SmartCollec
builder.HasIndex(sc => sc.Name)
.IsUnique();
// Cleanup + list-hiding of the per-source system smart collections an auto-tune weighted channel
// owns (#425). Nullable: null = a normal user smart collection.
builder.HasIndex(sc => sc.OwnedByChannelId);
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
@@ -14,6 +14,9 @@ public class ChannelRepository(IDbContextFactory<TvContext> dbContextFactory) :
.Include(c => c.FFmpegProfile)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.Playouts)
.Include(c => c.MirrorSourceChannel)
.ThenInclude(mc => mc.Playouts)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -738,7 +738,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
sortedItems,
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder,
multiCollectionItem.Collection.UseCustomPlaybackOrder));
multiCollectionItem.Collection.UseCustomPlaybackOrder,
multiCollectionItem.Weight));
}
}
else
@@ -751,7 +752,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
items,
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder,
multiCollectionItem.Collection.UseCustomPlaybackOrder));
multiCollectionItem.Collection.UseCustomPlaybackOrder,
multiCollectionItem.Weight));
}
}
@@ -769,7 +771,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
items,
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder,
false));
false,
multiCollectionSmartItem.Weight));
}
}

Some files were not shown because too many files have changed in this diff Show More