Compare commits

..
Author SHA1 Message Date
timothy 1ef581403c fix(609): close round-5 test gaps and a prose misattribution
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 29s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Fable's round-5 review ran the 16 tmp_path tests that no prior round could
execute (16/16 pass) and mutation-tested every fix. Six of seven reverts were
killed; one survived, which is finding 1.

1. The exact-arity refusal in `_token_armed` had ZERO coverage -- deleting it
   passed all 55 tests, because nothing fed malformed git-log output to that
   function. Now pinned by a test that stubs `_run` with 2-field and 4-field
   output and asserts refusal, plus a 3-field control proving the refusal is
   about arity rather than the token. Verified the new test kills the mutation.

2. `test_integration_separator_in_subject_cannot_inject` did not actually pin
   the NUL framing: with `\x1f` framing restored and the arity check kept, it
   still passed, because one or two injected separators break arity and get
   absorbed. Added a case with THREE separators, which restores a multiple-of-3
   arity and would false-arm under that revert -- so it pins the framing itself.

3. The decision record attributed the "old git echoes the trailers atom" case to
   the arity check. Wrong: an echoed atom is one well-formed field, so arity
   cannot catch it -- that case is handled by the `git --version` capability
   probe. Corrected in the record.

Not changed: review also noted subject matching is now case-insensitive, so
`[DECISIONS-EDIT]` arms where the old substring check was case-sensitive.
Deliberate and harmless -- arming still requires typing the token.
2026-07-25 18:25:26 +02:00
timothy 990d32f31a fix(609): anchor and bound the git version probe
Both round-4 findings, both in the version regex introduced in round 3. Both
reproduced against the old code and confirmed closed.

1. The regex was UNANCHORED, so the first dotted number anywhere in the output
   won. `wrapper 2026.1; git version 2.20.1` read as 2026.1 -> True, enabling
   trailer parsing on a git that cannot expand the atom, whose verbatim echo then
   reads as a non-empty trailer and FALSELY ARMS. Now anchored to the canonical
   `git version X.Y` prefix.

2. Digits were unbounded, so a pathological version string raised ValueError
   instead of returning the documented safe False -- Python refuses int()
   conversion of a literal over 4300 digits. Digits are now bounded to 5 each,
   plus a try/except that the bounded regex should make unreachable.

Adds seven probe cases: the wrapper-prefix and multiline-shim strings, Apple git,
an rc suffix, a three-digit major, and the 5000-digit pathological input.
2026-07-25 18:25:26 +02:00
timothy 851dca2596 fix(609): close round-3 review findings
All four LOW; no HIGH remained. The subject_of fix from round 2 was confirmed
correct across every message shape and all 38 historical commits.

1. The old-git compat check was a VALUE sentinel: it blanked any trailer whose
   value happened to equal the atom string, so a legitimate
   `Decisions-Edit: %(trailers:key=Decisions-Edit,valueonly)` was silently
   discarded. Replaced with a capability probe on `git --version` (>= 2.22).
   Detecting by version instead of by sniffing output removes the collision
   class entirely rather than narrowing it. Unknown/unparseable version resolves
   to False -- trailers ignored, subject-only matching -- which is the safe
   direction: a trailer-only token not arming is an annoyance, whereas reading an
   unexpanded atom as a value would falsely arm and disable the guard.

2. The compat test never called `_token_armed`, so it pinned nothing -- deleting
   the guard would have left it green. Replaced with three tests that drive the
   real function through a stubbed `_run`, covering old git (trailers ignored),
   modern git (trailer arms), a tokened subject surviving an unusable trailer,
   and version-string parsing incl. unparseable input. Proven non-vacuous:
   forcing the probe True makes the old-git test fail.

3. `_repo()` still ignored return codes from init/config/base-commit and never
   checked that the base sha resolved, so a rejected base could leave it
   returning ("", <root sha>) and negative range tests would pass vacuously. All
   commands are now checked and the base sha is asserted to be a full 40 chars.

4. docs/decisions.md line 65 still said "append it, as every prior use does".
   37 of 38 append; docs(434) is mid-subject.
2026-07-25 18:25:26 +02:00
timothy aa4a8fb849 fix(609): close round-2 review findings
HIGH -- `subject_of` used `lstrip("\n")`, so it returned the first NON-EMPTY
line. `git commit --cleanup=verbatim` accepts a message that begins with a blank
line and `%B` returns it raw, so body prose on line 2 was promoted to "subject"
and armed the token. Now literally line 1: an empty first line yields "", which
arms nothing -- failing toward the guard running.

LOW -- the old-git compat guard was a PREFIX match (`startswith("%(trailers")`)
that also `continue`d before the subject was evaluated. So a legitimate trailer
value beginning with that text was discarded, and worse, a perfectly good tokened
SUBJECT was thrown away because of its trailer field. Now an exact match against
the full atom, neutralising only the trailer and leaving the subject honoured.

LOW -- docstrings still said every historical use "appends" the token. Of the 38
uses, 37 append and `docs(434)` is mid-subject.

LOW (plausible) -- the `_repo` test helper ignored every git return code, so a
rejected commit would leave HEAD at base and every negative assertion would pass
vacuously. Return codes are now checked and HEAD is asserted to have moved.

Adds a regression test for the verbatim leading-blank-line case and one pinning
the compat guard to an exact atom match.
2026-07-25 18:25:26 +02:00
timothy 243bec708d fix(609): close two false-arm holes found in cross-family review
Codex review of the first attempt found both, and both were in the git plumbing
that my unit tests never touched -- they only exercised the pure predicate.

1. HIGH: git's `%s` is the first PARAGRAPH, not the first line. It joins
   consecutive non-blank lines with spaces, so
     `fix: harmless subject`
     `This explains [decisions-edit] on line two.`
   came back as ONE line containing the token and armed it -- the exact
   false-arm this change exists to prevent. The first line is now taken from
   `%B` via `subject_of()`.

2. HIGH: the in-band `\x1f`/`\x1e` field separators were injectable. A subject
   containing a literal `\x1f` was split at the wrong place and its tail read as
   a trailer, arming the token. Framing is now NUL, which git forbids inside a
   commit message and which therefore cannot be injected, with exact-arity
   parsing (fields must be a multiple of three) that refuses to arm otherwise.

Also from the same review:
- Refuse to arm on a `%(trailers:...)` atom echoed literally by a git older than
  2.22, which would otherwise read as a non-empty trailer (exit 0, so `_run`
  returns it rather than None).
- Record corrected: 38 subject-tokened commits in ancestry, not "twenty"; and it
  no longer claims a blanket fail-safe -- `_token_armed` failing is safe, but the
  surrounding `_diff_findings` fails open earlier on an unresolvable merge-base,
  skipping every check. That predates this change.

Adds 8 integration tests that drive `_token_armed` against a real throwaway git
repo -- the gap that let both defects pass. Verified non-vacuous by
reconstructing the old implementation in memory: it arms on both inputs, the new
one does not.

Note `--format` uses git's `%x00` escape, not a literal NUL: a NUL in argv raises
ValueError from subprocess, which broke every diff-engine test until fixed.
2026-07-25 18:25:26 +02:00
timothy 4596603020 fix(609): scope the decisions edit token to the subject line or a trailer
The token was armed by a bare substring match over every commit message in the
range, so a commit that merely DESCRIBED the mechanism armed it and skipped the
entire `if not token:` block -- all three rationale-rewrite comparisons (active
survivors, active->archive laundering, archive survivors). `removed` and `demoted`
still ran, so the job printed `decisions-validate: OK` while doing nothing. It
bit in PR#605, which had hand-resolved an append-vs-append conflict inside
docs/decisions.md -- precisely the operation the guard exists to police.

Now recognized in exactly two places:
  * the commit SUBJECT line -- the established form. All twenty prior tokened
    commits append it to the subject (or place it mid-subject, as docs(434)
    does); none put it on its own line, so the obvious "own-line only" rule
    would have broken every historical use.
  * a `Decisions-Edit: <reason>` git trailer -- the forward-looking form, which
    can carry a reason the bracketed marker cannot.

Fail-open posture unchanged: unresolvable git means the token reads unarmed, so
the guard still runs.

Verified by measuring the guard rather than reading a green check -- a positive
control over the real corpus across all three placements: no token fires (exit 1),
subject token suppresses (exit 0), body-only mention fires (exit 1). Plus an
end-to-end matcher test against a throwaway git repo covering the established
form, mid-subject placement, the trailer, a merge commit quoting a tokened PR
title, a multi-commit range, and an unresolvable ref.

fixes #609
2026-07-25 18:25:26 +02:00
timothy 54ed75624a Merge pull request 'fix(496): per-library server identity for music videos — itemId diff + soft trash' (#607) from fix/496-musicvideo-identity into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 32s
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 + UI contracts) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 16m15s
2026-07-25 16:08:12 +00:00
timothy b5b6e7f636 test(496,484): record projection failures during enumeration, not eagerly [decisions-edit]
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 10s
PR Gates / decisions lifecycle (pull_request) Successful in 15s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review Low. The substitute incremented the failure counter inside .Returns(...), i.e. when
the enumerable was handed out, while the real paginator records from ProjectToMusicVideo's catch
DURING enumeration. A refactor that snapshotted Count before the enumeration completed would
then break production while both replacement tests kept passing — exactly the regression the
guard exists to prevent.

Moves the recording into an async iterator, and corrects the decision record to describe #484's
removed music-video test accurately and name its two replacements.
2026-07-25 17:36:34 +02:00
timothy 4bdad4bf52 fix(496,484): thread #484's projection-failure guard through the new music-video scanner [decisions-edit]
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 24s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Rebasing onto #612 exposed a silent gap rather than a conflict. #612 added a
`projectionFailureCount` parameter to MediaServerReconciliationGuard.ShouldFlagMissing that
refuses the sweep when the enumeration reported swallowed projection exceptions — but the
parameter is OPTIONAL with a default of 0, so this scanner compiled unchanged while opting
out of the protection entirely. ProjectToMusicVideo has exactly the swallowing catch #484
exists to defend against, so the music-video sweep would have been the only one unguarded.

- MediaServerMusicVideoLibraryScanner creates one MediaServerProjectionFailureCounter per
  enumeration (never a field on the singleton api client, so concurrent scans of different
  libraries can't leak failures into each other's sweep decision), passes it to
  GetMusicVideoLibraryItems, and feeds its Count to ShouldFlagMissing.
- The single guard call also gates the #496 legacy path diff, which is more exposed: a
  legacy row has no etag to fall back on.
- ProjectToMusicVideo now returns MediaServerProjectionResult<JellyfinMusicVideo>, combining
  #612's Skipped/Failed distinction with #496's identity type.
- main's own #484 music-video test targeted the pre-#496 hard-delete scanner (FindMusicVideoPaths
  /DeleteByPath) and no longer applies; it is replaced by two tests in the new architecture
  covering the identity sweep and the legacy sweep. Both proven non-vacuous — removing
  projectionFailures.Count from the guard call fails exactly those two.

4,277 tests green; format/BOM clean; decisions validator OK.
2026-07-25 17:27:50 +02:00
timothy 147166b053 fix(496): harden the legacy path sweep against MySQL collation (re-review Low)
Re-review returned MERGEABLE with one Low, MySQL-collation-dependent edge in
FlagFileNotFoundByPaths: the C#-side Except diff is ordinal, but `MF.Path IN @LocalPaths`
runs under MySQL's case-insensitive default collation, so a still-reported identified row
differing only in case from an absent legacy row could be matched and flagged missing.

Match on the indexed PathHash instead of collated Path text, and re-state the
NOT EXISTS (JellyfinMusicVideo) guard so the identity pass and the legacy pass are disjoint
by construction rather than by the caller's diff being correct. SQLite was unaffected.
2026-07-25 17:20:53 +02:00
timothy 6bd1d954bd fix(496): review fixes — thread the replaced local path, scope identity per library, sweep legacy rows [decisions-edit]
Independent cold review (Codex) returned BLOCKED. Findings 1, 3 and 4 are fixed here;
each has a regression test proven non-vacuous by a negative control.

1. Blocker — the replaced local path was discarded. The scanner computed localPath but
   GetOrAdd only received `incoming`, so the repository re-derived the path from the
   UNREPLACED projection. On any install with path replacements, adoption hashed the
   server-side path, missed the existing row, ALSO slipped past MediaFileAlreadyExists
   (which hashes that same wrong string) and inserted a duplicate row under a server path,
   leaving the original collection-linked row identity-less forever. The test harness hid
   this because its path-replacement stub was an identity function.
   → GetOrAdd now takes localPath explicitly and never reads the projection's path;
     BuildPathReplacement takes a real mapping and the new test genuinely replaces.

3. Medium — GetByItemId matched on ItemId alone, so two media sources presenting the same
   item id (cloned Jellyfin DB) resolved to each other's row, letting one library repoint
   another's. → filtered by LibraryPath.LibraryId.

4. Medium — a row predating the identity that the server had ALREADY stopped reporting was
   never adopted (adoption only runs for an incoming item) and carried no identity, so the
   itemId diff could not see it either: it sat Normal and schedulable forever, strictly
   worse than the hard delete it replaced. → GetExistingLegacyMusicVideoPaths +
   FlagFileNotFoundByPaths reconcile legacy rows by local path, and they are counted into
   the #477 empty-fetch guard (on the first scan after this ships they ARE the whole
   library, so a guard counting only identity rows would sweep all of them on a transient
   empty fetch).

Finding 2 (the issue's Done-when #2) is a scope question, not a defect, and is unchanged:
one file path is still one MediaItem row globally, so this lands music videos at parity
with movies rather than eliminating shared-row trashing. Recorded honestly in the decision
record; raised for an explicit call before the issue is closed.

fixes #496
2026-07-25 17:20:53 +02:00
timothy 64decd492e fix(496): give music videos a per-library server identity; itemId diff + soft trash
Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to
reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served
by two libraries with overlapping local paths is a single row owned by whichever library
scanned it first, so that owner's sweep destroyed a row another library still served —
taking collection membership and playout references with it, irreversibly.

This is #494's deferred "option 2":

- New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table,
  varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo.
- New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed
  existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per
  library via LibraryPath.LibraryId.
- New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds
  onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim.
- The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible
  and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep.
- Pre-identity rows are ADOPTED in place: the identity row is inserted against the same
  MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership
  survives and a local/second-library row is never hijacked.
- AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection
  fills them from the server-reported path, which would break every later PathHash lookup.

Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as
superseded; new active record scan.musicvideo-server-identity.

fixes #496
2026-07-25 17:20:53 +02:00
timothy eb4de0cc2b Merge pull request 'feat(484): suppress the media-server sweep on projection failures; reject the ratio threshold' (#612) from feat/484-scanner-guard-threshold into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 14s
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) Successful in 4m25s
2026-07-25 15:08:07 +00:00
timothy 7cc12881de docs(484): correct the counted-enumeration counts on the api client interfaces
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m26s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review follow-up. The comments still said three (Jellyfin) / two (Emby)
library-level enumerations after the nested season and episode ones were
counted, which understates the reach of the very safety property this
branch establishes -- a future auditor reading them would conclude the
nested sweeps are unprotected.
2026-07-25 16:36:35 +02:00
timothy 6f4497e1ce fix(484): guard the nested TV season and episode sweeps against projection failures
Review finding 1 (blocking). ScanSeasons' FlagFileNotFoundSeasons and ScanEpisodes'
FlagFileNotFoundEpisodes had no guard at all — neither #477's nor #484's — so ProjectToSeason /
ProjectToEpisode returning Failed() was computed and discarded.

#477 scoped those out because "the blast radius is one show's seasons / one season's episodes",
which holds for a per-parent EMPTY fetch but not for a projection failure: that is systematic by
construction. One bad code path fires on every parent, so every season enumerates zero episodes,
existing.Except([]) is the whole episode library, and EmptyTrashHandler deletes it permanently.

Threads the counter into GetSeasonLibraryItems / GetEpisodeLibraryItems(WithoutPeople) for
Jellyfin and Emby using the same optional-trailing-param shape, and guards both sweeps with
MediaServerReconciliationGuard.ShouldFlagMissingDescendants — the same class and the same private
failure predicate as ShouldFlagMissing, deliberately WITHOUT #477's empty-fetch branch so
per-parent empty behaviour (and #476's cascade, which depends on it) is unchanged.

Also from the review:
- finding 3: tests now pin the same-instance JOIN at every level (movie, show, season, episode,
  music video) by driving the real ScanLibrary entry point and recording the failure from inside
  the enumeration, so a refactor handing the api client a fresh counter goes red.
- finding 4: the missing-library Failed() branch is documented as defensive and unreachable.
- finding 2: the mass-Skip residual (Emby's response-shape-dependent MediaSources guard, Plex's
  pre-projection filter) is stated as a known limitation in the decision record.
- finding 5: the log-contract change (only the #484 message when both refusals apply) is noted.

fixes #484
2026-07-25 16:36:35 +02:00
timothy e3645a2840 feat(484): suppress the media-server sweep on projection failures; reject the ratio threshold
Extends MediaServerReconciliationGuard (#477) with a second deterministic refusal: when the
enumeration that produced the incoming set silently dropped items whose projection THREW, the
file-not-found sweep is refused. A dropped item the server did return is indistinguishable
from a deletion at the reconcile step, so a projection regression could otherwise mass-flag a
healthy library FileNotFound (which EmptyTrash then deletes permanently).

Deliberate guard-clause skips (STRM files, virtual items, unsupported types) are explicitly NOT
failures and never suppress a sweep — counting them would permanently disable reconciliation for
any library holding a single STRM file.

The ratio / missing-fraction threshold is REJECTED, not deferred: it is a two-sided heuristic
with no tunable default and no telemetry, and the failure it approximates is exactly observable
via the projection-failure count (a genuine bulk deletion produces zero failures).

Seam is deliberately narrow — the private ProjectTo* contract inside each api client changed from
Option<T> to MediaServerProjectionResult<T> (projected/skipped/failed), the paged helper counts
IsFailure in one place, and the scanner reads it through an optional trailing
MediaServerProjectionFailureCounter on only the five library-level methods that feed a sweep.
The counter is per-enumeration state created by the scanner, never a field on an api client.

fixes #484
2026-07-25 16:36:35 +02:00
timothy fd70e6eb4f Merge pull request 'fix(503): map WatermarkLocation.MiddleCenter to a centered overlay position' (#611) from fix/503-watermark-middlecenter 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 7m56s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m30s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-25 14:35:27 +00:00
timothy c3beee1629 Merge pull request 'feat(603): adopt OKF's optional stale-after and Sources decision-record metadata' (#605) from worktree-603-decisions-stale-after-sources into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 12s
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
2026-07-25 14:15:59 +00:00
timothy f04412a95b docs(603): correct the record's own no-backfill claim, which the backfill commit falsifies
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m40s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m26s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Caught in re-review. The record shipped in 0ad02db6 said 'no backfill was done
here'; 8e1e15e4 then backfilled three ci.* records on the same branch. Corrected
now while the record is still NEW at HEAD, so the body-diff guard does not apply
and no edit token is needed -- after merge this would be surviving rationale
prose and the same fix would cost one.

Deliberately avoiding the literal bracketed token string here: decisions_validate
matches it as a bare substring over the whole commit range, so a message merely
DESCRIBING the mechanism arms it and suppresses the rationale-rewrite guard for
the entire PR. Found exactly that way on the first draft of this commit.
2026-07-25 15:30:43 +02:00
timothy 8e1e15e4d7 docs(603): backfill stale-after + Sources onto the three ci.* outside-world records
The mechanism shipped unused. First adopters are the records that encode measured
host behavior, which is what drifts silently:

- ci.runner-placement    2027-01-15 — bumblebee sizing (25 GiB/12 cores) + container
  caps. Premise has already partly moved: the media transcoders left for jazz on
  2026-07-20 and the runners were retuned since.
- ci.infra-shaped-red-under-load  2027-02-15 — a triage heuristic calibrated against
  load 243 on a host that has since been retuned.
- ci.peak-anon-measurement        2027-03-15 — weakest of the three (mostly our own
  script) but it does assert that memory.peak is cache-inflated, a cgroup fact.

Dates staggered so they don't all come due in the same week. Each Sources line cites
the incident evidence already named in the record's own prose.
2026-07-25 15:22:50 +02:00
timothy 75139bbf31 fix(603): close four defects found in adversarial review of the stale-after fields
1. `date.fromisoformat` is not a YYYY-MM-DD validator. On Python >= 3.11 it also
   accepts ISO basic format ("20270101") and week dates ("2027-W01-1"), so a
   malformed-looking value passed the blocking check — and which forms parse
   depends on the interpreter, meaning the same corpus could validate differently
   on a dev machine and on the runner (pr-checks.yml pins only python-version
   '3.x'). Knock-on: the catalog's Review-due section sorts on the raw STRING, so
   an accepted "20270101" sorted AFTER "2027-01-15" ('-' < '0'), contradicting the
   section's own "sorted soonest-first" text. Gate on ^\d{4}-\d{2}-\d{2}$ first,
   which fixes both — a fixed-width zero-padded form makes string sort == date sort.

2. A present-but-empty `stale-after:` was collapsed to None by `or None` in the
   parser and then skipped by a truthiness guard in the validator, so it passed as
   "absent" — a field that silently never fires, which is the exact failure mode
   the blocking check exists to prevent. Keep "" distinct from None and test with
   `is not None`.

3. `test_catalog_is_date_independent` was partly vacuous: with no date in either
   render, both sides were trivially equal after the .replace(). It did still catch
   an injected clock-derived marker, but it passed with the feature deleted. Assert
   the dates are present.

4. The malformed-date check ran only over the active set, exempting archive
   records. Staleness is moot there, but a typo is still a typo — check both wings.

Adds regression tests for each, plus a Review-due row for a topic-file record
(pinning the `../decisions.md` vs bare-filename link forms).
2026-07-25 15:22:50 +02:00
timothy 0ad02db651 feat(603): adopt OKF's optional stale-after and Sources decision-record metadata
Evaluated the Open Knowledge Format (GoogleCloudPlatform/knowledge-catalog okf
v0.2, scaccogatto/okf-skills) as a replacement for our decision-record system and
rejected it: its conformance rules are deliberately permissive exactly where ours
are strict (broken links, unknown types and missing fields must all be tolerated;
`deprecated` points at no successor), and its stable identity is the file path,
which the breadcrumb rule tells agents not to trust.

Adopted two of its optional families instead, additively:

- `stale-after: YYYY-MM-DD` on the metadata line — marks a record asserting an
  outside-world fact as due for re-confirmation. Absolute date, no TTL.
- `**Sources:**` in the metadata block — the evidence a record rests on, as
  distinct from `Signals:` (recall keywords).

Neither is required; absence is never an error. A malformed `stale-after` is
blocking (it would silently never fire), but a past-due record is only a
non-blocking `::notice::` — going stale is the passage of time, not a defect in
whatever commit is under test. The catalog's new "Review due" section renders the
date only and never a clock-derived verdict, so it cannot drift `--check` red on a
calendar boundary with no commit touching the corpus.

No backfill: no existing record adopts either field here.

fixes #603
2026-07-25 15:22:50 +02:00
timothy 39873811da test(503): guard WatermarkLocation exhaustiveness and cover source-content margins
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 18s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m53s
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 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m21s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review follow-up. The 9-element ExpectedPositions table meant a future enum
member would pass every case and silently render bottom-right again -- the
exact bug #503 fixes. Assert the table covers Enum.GetValues instead, and
exercise the previously untested SourceContentMargins() branch.
2026-07-25 15:19:23 +02:00
timothyandClaude Sonnet 5 97bc346539 fix(503): map WatermarkLocation.MiddleCenter to a centered overlay position
OverlayWatermarkFilter.Position had no switch arm for MiddleCenter, so it
silently fell into the BottomRight default and rendered bottom-right.
OverlayWatermarkCudaFilter and OverlayWatermarkQsvFilter both inherit this
Position property without overriding it, so they were affected too (the
whole ErsatzTV.FFmpeg project has only this one WatermarkLocation switch).

- Add an explicit MiddleCenter arm: x=(W-w)/2:y=(H-h)/2
- Make BottomRight an explicit arm instead of the fallthrough default
- The default case now logs a warning (not throw - this runs on the
  playback hot path constructing ffmpeg args, and sibling filters in this
  project already use safe string fallbacks rather than throwing for an
  unmapped enum) and falls back to the BottomRight position
- Strip the pre-existing UTF-8 BOM from OverlayWatermarkFilter.cs (#311
  formatting gate: touching a legacy BOM'd file makes removing it ours)
- Add ErsatzTV.FFmpeg.Tests/Filter/OverlayWatermarkFilterTests.cs asserting
  the exact position expression for every WatermarkLocation value across
  the software, CUDA, and QSV overlay filters, plus the unmapped-value
  fallback

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 15:19:23 +02:00
timothy abeef63f05 Merge pull request 'feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe' (#591) from feat/445-533-ui-e2e into main
Build CI Toolchain Image / Build & push CI image (push) Successful in 27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 18m39s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 23m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 24m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 21m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
2026-07-25 12:52:32 +00:00
timothy 9798877631 ci(445): re-point the toolchain pin after the second rebase (1652fc5 -> 32747a0)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m58s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m8s
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 15m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Completes the second pin-recovery cycle. main moved twice during this branch's
review and each rebase rewrites the sha of the commit that touched docker/ci, so
the pin has to be re-pointed each time.

Simulated the guard's full logic before pushing, including the length check main
added in #598 (which came from this session's #594):
  length   = 7                                    (guard requires exactly 7)
  expected = git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml
           = 32747a067e
  pin      = 32747a0 -> resolves to the same commit
  => WOULD PASS

Confirmed the registry actually holds ersatztv-ci:32747a0 before pinning it, so
this cannot be the "pin resolves but no such tag exists" failure #594 describes.

Refs #445 #594
2026-07-25 14:35:02 +02:00
timothy 32747a067e docs(445): note the ci-image-pin length guard in the rebase-trap warning
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 16s
PR Gates / CI image pin matches docker/ci (pull_request) Failing after 16s
PR Gates / Docs update reminder (pull_request) Successful in 25s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
Build CI Toolchain Image / Build & push CI image (push) Successful in 1m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m38s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m48s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 23m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Second pin-recovery cycle on this branch — main moved twice during review and each
rebase rewrites the sha of the commit that touched docker/ci. Records that #598 now
length-checks the pin at exactly 7 chars, which turns a locally-computed 8-char
`git rev-parse --short` into a loud gate failure instead of a confusing
manifest-unknown at image-pull time. That guard came from #594, filed by this
session after hitting exactly that ambiguity.

This commit also IS the recovery: it touches docker/ci, so ci-image.yml tags it and
the pin can be re-pointed in the follow-up commit.

Refs #445 #594
2026-07-25 14:29:27 +02:00
timothy 352aa70634 refactor(445): drop e2e-ui.sh's port pre-flight, now owned by e2e-local.sh (#586/#598)
main's #598 added a port pre-flight to scripts/e2e-local.sh while this branch was
in review, and theirs is strictly better than the one I had here:

  - it probes BOTH bound ports. The app binds a SECOND (streaming) listener that
    defaults to 8409 regardless of ETV_UI_PORT, so my single-port check could
    certify a port free while the run still died binding 8409. That also means
    this PR's CI step (ETV_UI_PORT=8410) was only working because the curl step's
    server had already been killed — #598's fix, defaulting ETV_STREAMING_PORT to
    the given port, is what makes it correct rather than lucky. A real latent bug
    in my work, surfaced by their change.
  - it REPORTS rather than reaps, which is the #586 rule.

Keeping mine would leave two divergent port checks on the same concern.

Also records the app's SINGLE-INSTANCE guard, which is independent of ports: a
stray instance blocks a run whatever port you choose, so 'pick another port' is
not a workaround. Kill the leftover BY PID (#586).

Refs #445 #586
2026-07-25 14:29:27 +02:00
timothy e87285c33d Merge pull request 'fix(460): write null LastScan on disable-sync instead of the MinValue sentinel' (#601) from fix/460-lastscan-null 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 8m1s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 16m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 6m0s
2026-07-25 12:11:34 +00:00
timothy e7bac06d1b ci(445): re-point the toolchain pin after the rebase (e9fd26f -> 1652fc5)
Second half of the post-rebase pin recovery. The preceding commit touched
docker/ci, so ci-image.yml tagged it and published ersatztv-ci:1652fc5; this
commit points all five container jobs (plus the header comment) at it.

Simulated ci-image-pin's own logic before pushing rather than guessing:
  expected = git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml
           = 1652fc568e53184c92c7e0bc5a41546aed19744d
  pin      = 1652fc5 -> resolves to the same commit
  => WOULD PASS

Verified the published image is the one CI will actually consume: pulled
:1652fc5 on the runner host, /ms-playwright holds chromium_headless_shell-1234,
chromium launches (151.0.7922.34), dotnet 10.0.302 intact.

Refs #445
2026-07-25 14:04:52 +02:00
timothy 5dd0b147e8 docs(445): record that rebasing invalidates the CI toolchain image pin
Hit this for real on this branch. `ci-image-pin` went red after a rebase that
was otherwise clean, and the failure is confusing on three counts:

  - the pin must equal the short sha of the commit that touched docker/ci, and a
    rebase REWRITES that sha (e9fd26f6 -> 9130274c here);
  - the pin still resolves to a real commit and the tagged image still exists in
    the registry, so nothing looks broken;
  - the force-push does NOT republish: ci-image.yml filters on
    `paths: docker/ci/**`, and a rebase that leaves the Dockerfile's content
    unchanged produces no diff for that path.

And it cannot be fixed by re-dispatching ci-image.yml, because that tags
`git rev-parse --short HEAD` — the branch HEAD, not the commit that touched
docker/ci. The two coincide only when the docker/ci commit IS HEAD, which is why
the original two-step worked and the post-rebase state does not.

Recorded in docs/ci-cd.md with the recovery, plus the cheaper lesson: land a
toolchain-image change on its OWN branch first, so the consuming branch never
carries the docker/ci commit through a rebase.

This commit is also the recovery itself — it touches docker/ci, so it becomes the
commit ci-image.yml tags, restoring the pin dance.

Refs #445
2026-07-25 14:04:52 +02:00
timothy dbe72dbf03 docs(445): refresh web test count after rebase (983 -> 995)
main added 12 web tests while this branch was in review, so the count I wrote
into docs/testing.md went stale during the rebase. Verified by running the suite
against the rebased tree: 995 tests / 105 files.
2026-07-25 14:04:52 +02:00
timothy 54919e4730 fix(445): pidfile-first cleanup ordering + clear BOOT_PID after wait
Codex round-3 verdict was BLOCKED @ 29bc5119 with three High findings. All three
are real; all three are fixed. They are narrower than the previous rounds — two
are sub-millisecond races between adjacent statements — but the fixes are cheap
and make the ordering correct rather than lucky.

**Recycled-PID kill (the one that genuinely mattered).** `BOOT_PID` stayed set
after `wait` had already reaped the launcher. A stale PID is not merely useless:
the OS can RECYCLE that number during a long spec run, and cleanup would then
SIGTERM an unrelated process. This is the same "never kill something you did not
prove is yours" failure that got the earlier port-based reap deleted — it came
back in a different disguise. Cleared immediately after `wait` returns.

**Cleanup ordering.** cleanup() killed e2e-local.sh BEFORE reading the pidfile,
so a signal landing between the server fork and the pidfile write could kill the
launcher and lose the only handle on an already-running server. The pidfile is
now read FIRST; stopping the launcher is best-effort and never the primary handle.

**Fork/publish sliver.** A signal can also land between `... &` and `BOOT_PID=$!`,
or between e2e-local.sh's fork and its pidfile write — the pidfile reads empty,
then populates microseconds later, and the server is orphaned. cleanup now does a
bounded grace re-read (~1s) when it has no PID. Verified this does NOT cost
anything on a genuinely server-less abort: a bad ETV_BUILD_CONFIG still exits in
1s rather than sitting out the loop.

## Verification
- passing run 3/3; failing run exits 1
- server-less abort exits in 1s (grace loop stays bounded)
- targeted SIGTERM: exit 143, no listener left
- zero orphaned processes after the full gate

Refs #445
2026-07-25 14:04:52 +02:00
timothy e8ad79623f fix(445): make the boot wait interruptible so a mid-boot signal can't orphan the server
Codex round-2 verdict was BLOCKED @ 2a5f26c0 on one High finding, which was
correct and is the subtlest defect in this whole change.

## The defect

Bash DEFERS a trapped signal while it waits on a FOREGROUND command. The boot was

    OUT="$(ETV_UI_PORT=... scripts/e2e-local.sh "$CONFIG_DIR")"

so a TERM arriving during the (up to 120s) readiness wait did not run `on_signal`
until boot completed — and a supervisor escalating TERM->KILL means the trap
never runs at all, orphaning the server on its port. The pidfile added in the
previous commit only helps IF the trap runs; this is the case where it doesn't.

Proved the mechanism in isolation rather than asserting it, since the real boot
is only ~2s locally and kept winning the race:

    foreground command substitution : TERM at t=1s -> trap ran at t=6s (deferred)
    background job + `wait`         : TERM at t=1s -> trap ran at t=1s (prompt)

## The fix

Boot in the background and `wait` on it. `wait` is interruptible, so the handler
runs immediately; by then e2e-local.sh has already written $ETV_PIDFILE, so
cleanup can reap a server whose PID this script has not yet parsed. Cleanup also
now stops the backgrounded e2e-local.sh itself, so it cannot sit waiting on a
server we just killed, and removes its temp output file.

Boot diagnostics are preserved on the failure path (verified with a deliberately
bad ETV_BUILD_CONFIG: the underlying "bin/<config> does not exist" error is
surfaced, along with the exit status, which is now propagated rather than
flattened to 1).

## Verification
- mechanism: deferred-vs-prompt trap proven as above
- passing run 3/3 green; failing run (--grep ZZZ_NOPE) exits 1
- boot-failure path prints e2e-local.sh's real error and its exit status
- targeted SIGTERM: exit 143, no listener, no stray process
- curl harness unaffected: 45/45 PASS
- no orphaned processes after the full gate (an earlier count of 1 was a
  graceful shutdown still in flight, not a leak — re-checked clean)

Refs #445
2026-07-25 14:04:52 +02:00
timothy 71706f3849 fix(445): silent-pass on spec failure + prove server ownership via pidfile [decisions-edit]
Second review round. Codex returned BLOCKED @ e50a2624 with three High findings;
all three were real and all three are fixed. One of them was severe and was
introduced by my OWN previous "fix" commit.

## HIGH 1 — a FAILING spec run exited 0, silently passing CI

`if ! npx playwright test; then status=$?; ... exit "$status"; fi`

Under `!` negation bash sets `$?` to the LOGICAL NEGATION of the command's
status, so inside the failure branch `$?` reads 0 — the script exited 0 on a
failing run. Verified: `if ! (exit 42); then echo $?; fi` prints 0.

A UI-E2E harness that reports success when its specs fail is worse than no
harness. My five green local runs could never have caught this: the bug lives
only on the failure path. Introduced by the log-tail improvement in e50a2624.

Fixed with `set +e` / read `$?` / `set -e`, then exit that status explicitly.
Verified: a deliberately-failing run (`--grep ZZZ_NOPE`) now exits 1.

## HIGH 2 + 3 — the pre-PID fallback needed lsof and only GUESSED ownership

The mid-boot fallback reaped "whatever LISTENS on $PORT", which was wrong twice:
  - it needed `lsof`, which is ABSENT from the CI toolchain image (verified
    directly in the published image) — so it silently no-opped precisely where
    it was needed;
  - it INFERRED ownership from the earlier pre-flight rather than proving it, so
    a process that grabbed the port after the pre-flight — or a real instance on
    a shared host — could be killed. Reaping someone else's server is worse than
    the leak it was meant to fix.

Replaced with an opt-in `ETV_PIDFILE`: e2e-local.sh writes the PID the instant
it forks, BEFORE its readiness wait, which is exactly the window a mid-boot
signal lands in. A pidfile we asked for PROVES ownership, needs no external
tool, and works in CI. The port-based kill is gone; the lsof pre-flight remains
as a friendly local check only.

Verified: the pidfile is populated while still mid-boot (readiness not yet
reached), names the real `dotnet ErsatzTV` process (not a subshell — which also
re-confirms the `exec` fix), and killing that PID alone frees the port.

Also dropped the `seq` dependency inside the trap (shell arithmetic instead),
addressing the other reviewer's busybox concern.

## Docs-reviewer finding — my stated reasoning was wrong

I justified amending `testing.e2e-local-fresh-config-dir` rather than superseding
it partly on "renaming the heading trips CI". That's a true statement that does
NOT bear on the choice: a supersession relocates to `archive/` with the heading
INTACT (verified: archive/api.md keeps the #72 heading verbatim). Corrected to
the actual reasons — the Rule never reversed, and the key is cited from
docs/handoffs/chicorytv-issue-queue.md plus two docs/superpowers/ files, which a
supersession would aim at an archived, stale-labelled record.

## Gotcha found by accident, now documented

A flawed test of mine booted two e2e-local.sh instances concurrently and the
first mysteriously failed to become ready. Cause: every run `rm -rf`s and
re-copies the SAME build-output wwwroot, so a second run yanks the static files
out from under a still-starting first instance. Documented in both the script
header and docs/e2e-local.md, because the symptom (readiness timeout, or /app
404ing) looks nothing like a shared-directory race. CI is unaffected — its curl
and UI-E2E steps are sequential.

## Budget: filed, not shaved

This PR pushes the active decisions corpus 7 lines past its 5600-line soft
budget (main was under). I trimmed my records repeatedly and each rewrite
recovered ~1 line, because the content is load-bearing; continuing would have
meant deleting useful rationale from a new convention record to hit an arbitrary
cap. The validator's own remedy is "schedule a consolidation", so that is filed
as #595 rather than paid for by starving the record. Non-blocking warning.

Also filed #594 for the pre-existing `ci-image-pin` any-hex-length weakness.

## Verification
- failing run exits 1 (was 0); passing run still 3/3 green
- pidfile written mid-boot, names the real dotnet proc, reap frees the port
- SIGTERM mid-run: exit 143, no orphan listener or process
- curl harness unaffected: 45/45 PASS; ETV_PIDFILE unset => unchanged behaviour
- decisions validator OK; zero orphaned processes after the full gate

Refs #445 #533 #594 #595
2026-07-25 14:04:52 +02:00
timothy 0f1951340e fix(445,533): harden e2e-ui lifecycle + retire stale #533 decision record [decisions-edit]
Addresses the adversarial review round. Codex returned BLOCKED on the harness
lifecycle contract; the second (cold) reviewer independently flagged the same
trap-ordering defect, which is what made it credible.

**Trap installed AFTER boot -> signal mid-boot orphans the server.** The window
between `e2e-local.sh` returning and `trap ... EXIT INT TERM` had no handler, so
a Ctrl-C/TERM there (or the PID-parse bail-out) left dotnet holding the port —
violating the script's own "always kills the server" contract. The trap is now
installed BEFORE boot. When the PID is not yet known, cleanup falls back to
reaping whatever LISTENS on our port; attribution is sound because the
pre-flight proved that port free moments earlier.

**Signals were not re-raised.** A TERM landing beside a passing Playwright run
could exit 0, reporting success for a cancelled run. INT/TERM now clean up and
re-raise, so the wrapper dies BY the signal.

Verified, not assumed: SIGTERM mid-run -> wrapper exits 143 (128+15) and leaves
zero listeners and zero stray ErsatzTV processes.

`e2e-local.sh` backgrounded a MULTI-command subshell, so `$!` is the subshell —
not dotnet — wherever bash does not collapse it. Measured both ways:

  bash 3.2.57 (stock macOS /bin/bash), 2-command subshell : $! = SUBSHELL
  bash 5.3.15 (homebrew)                                   : $! = leaf
  with `exec` (both versions)                              : $! = leaf

Consequence on stock-macOS bash: every PID-based kill/liveness check targeted
the wrong process, the escalation silently no-opped, and the server leaked. Fixed
at the source with `exec`, which benefits all consumers (the CI step's trap and
the e2e-functional.sh pairing), not just e2e-ui.sh.

My first attempt to test this was WRONG and would have cleared the finding: a
single-command subshell is collapsed on both versions. Only the 2-command form
reproduces it.

`testing.e2e-local-fresh-config-dir` was still `status: active` asserting the bug
'wait for either line' option today", "widening the probe ... is tracked as #533".
`READY_LINE` existed ONLY in that record; nowhere in code.

Amended rather than superseded: the operative rule (use a fresh config dir) is
unchanged and still correct — only its RATIONALE moved from "the probe hangs" to
"state bleed". Heading deliberately left alone: the validator matches records by
`## HEADING`, so renaming fails CI as an "unlogged removal"; an explicit note now
tells the reader the heading is historical.

- Boot-failure diagnostics were lost: `OUT="$(...)"` aborts under `set -e` before
  the print. Now `if ! OUT=$(...)` so the output is shown.
- Playwright failures surfaced no server-side evidence (nothing uploads the
  traces in web/e2e/.output). The server log tail is now printed on failure.
- `lsof -ti :PORT` also matched outbound/TIME_WAIT sockets -> false positives.
  Now `-sTCP:LISTEN`. Documented honestly that the CI image ships no lsof, so
  the pre-flight is a local-developer guard only.
- Predictable `/tmp/etv-pw-probe.$$` -> `mktemp` (symlink-redirect on a shared host).
- Suggested escape-hatch port was 8411, which is scripts/security-scan.sh's
  default; 8409/8410 are prod/ersatztv-test on jazz. Now suggests 8419 and names
  the conflicts.
- boot-gate.spec.ts comment overclaimed: after logout the browser holds NO
  cookie, so that assertion cannot prove stamp rotation (the curl harness does,
  by replaying the same cookie). Comment corrected to what it actually proves.

Deliberately NOT changed: `ci-image-pin`'s regex accepts any hex length rather
than the exact 7 chars ci-image.yml publishes (pre-existing guard weakness, not
introduced here — filed as a follow-up rather than widened in this PR).

Also trimmed the two records I had bloated: the active decisions corpus went over
its 5600-line budget as a result of this PR (baseline on main was under), so the
overflow was mine to pay down, not to pass on.

- SIGTERM mid-run: exit 143, no orphan listener/process
- curl harness unaffected by the `exec` change: 45/45 PASS
- UI-E2E 2x clean; typecheck + lint clean; decisions validator OK, no size warning

Refs #445 #533
2026-07-25 14:04:52 +02:00
timothy f7fa57c816 docs(445): fix dangling decision key + clarify UI-E2E step reference [decisions-edit]
Two review findings from the cold docs/convention pass.

HIGH — the new `ci.ui-e2e-harness` record cited `` `ci.toolchain-image` `` as if
it were a resolvable decision key. No such key exists anywhere in the corpus
(verified: the only occurrence in all of docs/ was that citation itself). #390's
toolchain-image work was deliberately never migrated to a standalone record —
`ci.runner-placement`'s own Signals line says so.

This is exactly the breadcrumb hazard docs/README.md -> "Knowledge retrieval"
warns about: a future session (or a MemPalace lookup) resolving that key gets
nothing back, and cannot distinguish "retired" from "never existed". Replaced
with an explicit pointer to #390, `ci.runner-placement`, and docs/ci-cd.md, and
stated outright that #390 has no standalone record.

Swept the whole class rather than the one instance: every dotted key cited in
added lines across the diff now resolves (ci.functional-e2e-harness,
ci.runner-placement, ci.ui-e2e-harness).

NIT — docs/ci-cd.md said the UI-E2E step is "Step 4 of this same job", which
refers to the doc's own 4-item prose summary, not the YAML step list (where it
is the 11th `steps:` entry). Reworded to name the actual step so a reader
skimming the workflow isn't sent looking for a 4th YAML step.

Refs #445
2026-07-25 14:03:59 +02:00
timothy d8c0b3e752 feat(445,533): headless Playwright UI-E2E flows + fix e2e-local readiness probe [decisions-edit]
Adds the last deferred #299/#363 follow-up: the flows that CANNOT be expressed
as curl calls. Scope rule (the durable part) — assert only what the curl
harness structurally cannot reach:

  1. client-side form validation (the Setup confirm-password gate is pure React
     state and makes no request, so there is no HTTP contract to assert)
  2. AuthGate's RENDERED states (Setup vs Login vs app)
  3. the session cookie authenticating the SPA's OWN /api XHRs — curl proves the
     cookie works for curl, not that the app sends it
  4. sign-out through the UserMenu back to the login gate

New: web/e2e/boot-gate.spec.ts, web/playwright.config.ts, scripts/e2e-ui.sh
(owns the whole lifecycle: fresh config dir -> boot -> specs -> always kill).

Runs as a second step of the EXISTING advisory `functional-e2e` job rather than
a new job: the dominant cost there is `npm ci` + the Release build, both already
done, so this adds ~5s instead of duplicating a heavy job. It boots its own
fresh instance on port 8410 because the first spec asserts the one-shot Setup
gate that the curl step has already claimed on its config dir.

Determinism (the issue asked for it explicitly): `serial`, `workers: 1`,
`retries: 0` even in CI — a retry would let a flaky flow merge looking green.
Measured 5 consecutive clean runs, ~2s each.

Pins all five `container:` jobs to the toolchain image built by the preceding
commit, which bakes `chromium-headless-shell`.

Non-obvious coupling fixed: vitest's default include glob would have collected
web/e2e/*.spec.ts and run it under jsdom. Excluded `e2e/**` by spreading
`configDefaults.exclude` rather than narrowing `include` to `src/**`, because
web/scripts/ holds a real vitest test an src-only include would silently stop
running.

`RebuildSearchIndexHandler` logs one of two mutually-exclusive lines just before
`SystemStartup.SearchIndexIsReady()`:

  fresh config  -> "Done migrating search index in {Duration}"
  reused config -> "Search index is already version {Version}"

The probe watched only the first, so a reused dir waited out the full 120s
timeout and then killed a perfectly healthy server. Widened to a `grep -Eq`
alternation; the handler's if/else is exhaustive, so the pair covers every path
to readiness.

Verified with a negative control: on a reused dir the server is ready in 2s via
the "already version" line, and the OLD probe string is genuinely ABSENT from
that run's log — so the old code would have hung, i.e. the fix is load-bearing
rather than incidentally passing.

The "prefer a fresh config dir" guidance stays: that guards state bleed, which
is a separate concern from the probe hanging.

- `wait "$PID"` in the cleanup trap was a NO-OP: the server is a grandchild
  (launched in e2e-local.sh's subshell, which then exits), so `wait` fails
  instantly and was swallowed by `|| true` — cleanup did not actually ensure the
  port was released, exactly what its comment claimed. Replaced with a bounded
  `kill -0` poll, then SIGKILL.
- Added a port pre-flight check: previously an occupied port surfaced as a 120s
  readiness timeout that reads like a broken build. Now fails in 0s naming the
  PIDs, and warns against blanket-killing `dotnet ErsatzTV.dll` (that reaps
  other sessions' servers).

- UI-E2E: 5x clean (3 specs, ~2s); back-to-back runs pass with no manual cleanup
- curl harness unaffected by the boot-script change: 45/45 PASS
- web: 983 tests / 105 files green; typecheck + lint clean
- vitest collection verified: excludes web/e2e, still collects web/scripts
- Dockerfile sequence + browser launch validated verbatim in a container on the
  real amd64 base before committing; chromium launches as root with NO sandbox
  opt-out needed
- decisions validator green; catalog regenerated
- docs/decisions.md TOC repaired: it had drifted to 69 of 97 records and held a
  dangling anchor to the #72 record that #415 superseded into archive/.
  Regenerated with a generator validated against the 68 existing anchors (0
  mismatches) -> 97/97, no dangling, no duplicates.

Docs: docs/e2e-local.md (new "UI-E2E harness" section), docs/ci-cd.md (toolchain
image + UI-E2E step), docs/testing.md, docs/README.md, docs/decisions.md
(new `ci.ui-e2e-harness` record; `ci.functional-e2e-harness` amended — its Rule
said "curl-only", now accurate).

Refs #445 #533
2026-07-25 14:03:59 +02:00
timothy cb5d1af65f ci(445): bake headless Chromium into the CI toolchain image
Adds `chromium-headless-shell` + its system deps to the shared CI toolchain
image so the `functional-e2e` job can run the UI-E2E Playwright flows without
installing a browser per run — the same "jobs install nothing at run time"
rule as the rest of this image.

Measured on this exact base (Ubuntu 24.04 ffmpeg base, amd64):
  - headless shell: 267M   vs   full chromium: 656M
  - `chromium.launch()` resolves to the shell anyway (the config never asks
    for headed), so the only cost is that a HEADED run inside this image would
    fail — CI-only, and deliberate.
  - Chromium launches as root in a container with NO --no-sandbox /
    chromiumSandbox:false opt-out, so the Playwright config needs no sandbox
    workaround. Verified rather than assumed.

`ARG PLAYWRIGHT_VERSION` must stay equal to web/package.json's EXACT
`@playwright/test` pin: Playwright ties a browser revision to the package
version, so a mismatch leaves no usable browser.

The build-time smoke test LAUNCHES the browser (not just a file test), so a
missing system library fails the image build instead of a CI run.

First half of the deliberate two-step toolchain bump: this commit is what
ci-image.yml publishes as :<sha>; the follow-up commit pins it.

Refs #445
2026-07-25 14:02:31 +02:00
timothy 67e53b1304 Merge pull request 'chore(586,594,485): PID-scoped E2E cleanup, ci-image-pin length guard, .gitignore core fix' (#598) from chore/586-594-485-hygiene into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m11s
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) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-25 11:51:57 +00:00
timothy 0081bed4b1 docs(460): drop the "clean at rest" overstatement from the test header
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m51s
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 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Follow-up to the cold review of 615e00a. The record was corrected to say only
that no NEW sentinel rows are created, but the test file's header still carried
the disclaimed "the data is clean at rest for every provider" claim — sitting on
the very test that supposedly proved it. It now states what the tests actually
pin, and names what they cannot speak to (rows written before this change, which
keep the sentinel and are covered only by the read coercion).

Also corrects docs/testing.md's ErsatzTV.Core.Tests count (543 -> ~650 measured;
the neighbouring Scanner.Tests cell is corrected in #602 instead, to keep the two
open PRs off the same line).
2026-07-25 13:46:41 +02:00
timothy 890745d1d4 fix(460): write null LastScan on disable-sync instead of the MinValue sentinel [decisions-edit]
MediaSourceRepository's Plex/Jellyfin/Emby remove-and-recreate (disable-sync)
flows stamped SystemTime.MinValueUtc into library.LastScan, so normal use kept
minting 0001-01-01 sentinel rows. #409 fixed the READ side (the API coerces the
sentinel to null and a migration cleaned the historical residue), so the wire
contract was already correct — this stops new sentinel rows being written.

Safe because every remaining LastScan reader either coalesces
(LastScan ?? SystemTime.MinValueUtc) for its own non-nullable scan-comparison
needs, or is the API read-boundary coercion itself — audited every reference
under ErsatzTV{,.Application,.Core,.Infrastructure,.Scanner,.Mcp} plus web/.
There is no Where/OrderBy/GroupBy on LastScan anywhere, so the SQL
null-ordering divergence between SQLite and MySQL has no surface here.
Scan-comparison behavior is unchanged.

Deliberately ships NO second cleanup migration: rows written between #409's
NullOutNeverScannedLastScan and this change keep the sentinel at rest, and the
permanent read coercion — not a migration — is what keeps the contract honest
for them (as it must be anyway for a restored or hand-edited DB).
LibraryPath.LastScan is likewise left untouched: the flows re-add Paths with
their original values, and its only readers are the local-library scan
handlers, so it has no API surface and no remote-scan effect.

Regression test per provider (MediaSourceRepositoryDisableSyncTests), proven
non-vacuous: restoring the MinValue writes fails all three.

[decisions-edit] — the media.lastscan-null-boundary record documented this
write as an ONGOING sentinel source and rested its "the coercion is permanent"
argument on it, so the rationale prose is corrected in the same PR per
docs.decision-lifecycle. The Rule line is unchanged, so the generated
decisions/README.md catalog is byte-identical.

MediaSourceRepository.cs also loses its UTF-8 BOM (fix-as-you-touch, #311).

fixes #460
2026-07-25 13:46:09 +02:00
timothy 6f2898d7a9 Merge pull request 'fix(500): dedup incoming metadata collections so a duplicate name inserts once' (#602) from fix/500-distinct-metadata into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 32s
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 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-25 11:45:41 +00:00
timothy 2f2bcca681 fix(500): dedup incoming metadata collections so a duplicate name inserts once
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
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 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The remove-stale + add-new reconcile idiom materializes its add set with
.ToList() BEFORE the loop mutates the existing collection, so the add filter
(`incoming.All(x2 => x2.Name != x.Name)`) is evaluated against a snapshot. Two
identically-named incoming entries whose name is not yet on the existing item
therefore BOTH passed the filter and BOTH inserted — a duplicate row.

Deduplicate the incoming set on the same key the filter compares (Name; Guid
for Guids), in both copies of the idiom:

- PlexMovieLibraryScanner.UpdateMetadata (the original) — genres, studios,
  actors, directors, writers, guids, tags.
- JellyfinMusicVideoLibraryScanner.Reconcile{Genres,Tags,Studios,Artists}
  (added in #497, mirrors the Plex pattern verbatim).

Plex ACTORS are the exception and get an artwork-preferring dedup hoisted out
and shared with the remove filter, because that filter is keyed on
(Name, artwork-presence) — it is the mechanism that drops an artwork-less actor
so the add loop can re-add it WITH artwork. A bare DistinctBy(a => a.Name)
there keeps the FIRST duplicate, so Plex listing the artwork-less copy first
discarded the artwork; worse, the remove filter would still see the
artwork-less duplicate, making its upgrade clause false, so the stale row was
never removed and the artwork never arrived on ANY later scan either. Actor
also carries Role/Order, which first-wins would silently drop too. Caught by
the cold review of the first version of this commit.

For the Jellyfin scanner the dedup sits at the incoming-list declaration, which
also covers the remove filter — safe because all four of those filters only ask
"is this name present at all", an answer duplicates cannot change.

Tests: duplicate-collapse for both paths, the two Actors cases above, and a
POSITIVE CONTROL proving distinct entries are still all added and stale ones
still removed (without it, a mis-keyed dedup that collapsed genuinely different
entries would pass every other assertion). Each proven non-vacuous.
The Plex tests drive the protected UpdateMetadata through a minimal test-only
subclass, as MediaServerMovieLibraryScannerTests already does.

Low likelihood in practice (a media server emitting two identically-named
genres for one item is unusual); this is defensive, with no observed occurrence.

The same idiom is copied into 8 further scanners/repositories that this change
deliberately does not touch (the issue scoped it to two paths) — filed as #600
so the class of bug is tracked rather than silently left in the majority of its
instances. The dedup rule is recorded under scan.musicvideo-reconciliation.

fixes #500
2026-07-25 13:04:38 +02:00
timothyandClaude Opus 5 c8e79f49f4 chore(586,594,485): PID-scoped E2E cleanup, ci-image-pin length guard, .gitignore core fix
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / decisions lifecycle (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m55s
Three independent CI/repo-hygiene fixes swept together; disjoint file sets.

fixes #586 — E2E cleanup is scoped by PID, never a pattern-wide pkill
  - New decision record `testing.e2e-cleanup-scope-by-pid`.
  - docs/e2e-local.md states the constraint where a BRIEF-WRITER sees it (the
    #586 root cause was a delegation gap, not agent error).
  - scripts/e2e-local.sh: reviewed against e2e-ui.sh's trap lifecycle and
    deliberately does NOT adopt it — its contract is to hand a running instance
    back to its caller, so an EXIT trap would kill the server the instant the
    launcher returned (both callers use `OUT="$(e2e-local.sh ...)"`). Recorded.
  - Instead it gains what actually prevents the incident: an lsof pre-flight
    that NAMES a foreign listener's PID rather than letting Kestrel fail its
    bind and surface as "process N exited before becoming ready".
  - Pre-flight probes BOTH bound ports, and ETV_STREAMING_PORT now defaults to
    ETV_UI_PORT. Program.cs binds a second listener whose port defaults to 8409
    independently of ETV_UI_PORT, so `ETV_UI_PORT=8420` alone still bound 8409
    and died against a foreign holder — i.e. the documented escape hatch was a
    dead end that led straight back to the confusion behind the pattern kill.

fixes #594 — ci-image-pin accepts any hex length
  - Length is a separate invariant from correctness: the resolve/staleness
    checks compare resolved shas, so an 8-char pin of the right commit passes
    green while matching NO registry tag, and all five container: jobs then die
    at image-pull with `manifest unknown` (reads like a registry outage).
  - Guard fails at the gate and prints the exact tag to use. Verified against
    doctored pins: 7 green; 6/8/10 red.
  - Uses a literal 7 rather than a derived `--short=7`: in a full clone git may
    widen an ambiguous abbreviation, demanding a pin ci-image.yml can never
    publish. Escape hatch documented inline.
  - Also fixes a pre-existing misdiagnosis: zero pins reported "MORE THAN ONE".
  - docs/ci-cd.md documents the 7-char rule and `git rev-parse --short=7 HEAD`.

fixes #485 — .gitignore `core` silently ignored `*/Core/` files
  - A bare `core` matched any path component named `core`; case-insensitively
    on macOS that swallowed every `*/Core/` SOURCE dir, so new untracked files
    were dropped by `git add -A` while tracked ones stayed fine — a clean local
    build and a CI checkout that fails to compile.
  - Now `/core` + `/core.[0-9]*`, both anchored (an unanchored `core.[0-9]*`
    would re-introduce the same silent-exclusion class this fixes).
  - Verified by diffing the full ignored-file set before/after: identical, and
    the three real Core/ dirs are trackable without -f.

Docs updated in-PR: docs/e2e-local.md, docs/ci-cd.md, docs/decisions/
workflow-process.md (+ regenerated catalog), docs/handoffs/chicorytv-issue-queue.md.

Follow-ups filed: #596 (the same shared-host reap in the Playwright-MCP
recovery record) and the ci-image.yml `--short=7` publisher-side fix, which
cannot ride this PR — editing ci-image.yml re-points ci-image-pin's `expected`
at this commit and reds the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:46:13 +02:00
timothy 0952078c2b Merge pull request 'chore(docs): trim derivable content from CLAUDE.md, lazy-load the task-completion protocol' (#588) from chore/claudemd-trim-lazy-skill 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 42s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 42s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 15s
2026-07-25 10:25:21 +00:00
timothy b7d9e0ecb9 Merge pull request 'docs(592): record that a skipped CI context is not red' (#593) from chore/skipped-not-red into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 15s
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 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 9s
2026-07-25 10:07:05 +00:00
timothy bcff7c686a Merge pull request 'feat(440): per-source weight steppers + exclude/add-untagged in the Auto-Tune DetailPanel' (#589) from feat/440-autotune-weights into main
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
2026-07-25 10:06:02 +00:00
timothy 2fee3f0b94 docs(592): record that a skipped CI context is not red [decisions-edit]
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 23s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Amends ci.monitor-armed-at-pr-open (prose + Signals only; heading and key
unchanged, no supersession) with the monitor-classification rules that were
missing.

Landing #436/#583 today, my CI monitor filtered per-context statuses on
`!= "success"` and announced "NOT all green" on two fully green PRs, because
`Build & push image (amd64)` reports `skipped`. Nothing was blocked — Gitea's
combined /status already treats skipped as non-blocking and reported
overall=success — but a false red costs a diagnosis cycle every time.

Two corrections recorded:
- The image job is skipped on EVERY PR (job-level `if: github.event_name !=
  'pull_request'`; images build only on push-to-main and tags), NOT because of
  the docs-only mechanism. Misattributing it to docs-only is a plausible-sounding
  wrong diagnosis, since docs-only gates STEPS precisely so required jobs still
  report success. decisions.md already stated the fact from the branch-protection
  angle; the monitor-authoring consequence was missing.
- skipped / failure / cancelled are three distinct meanings and must not be
  collapsed. Prefer gating on the combined `.state`.

The documented filter is verified in BOTH directions: silent on a green PR
carrying a skipped build, and still dirty on a genuinely pending run. My first
draft of it was itself broken — `select(.status != …)` after the pipeline had
renamed `.status` to `.st`, so it compared against null and reported a green PR
as nine failures. That failure is recorded in the note, per the "verify your
detector" rule.

fixes #592
2026-07-25 11:53:35 +02:00
timothy 8664220aa5 Merge pull request 'chore(583): make per-agent model routing a hard constraint + PreToolUse gate' (#585) from chore/agent-model-routing into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m2s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-25 09:47:43 +00:00
timothy fa789bc06f Merge pull request 'feat(436): arbitrary-depth rule-builder group nesting' (#584) from feat/436-deeper-nesting into main
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
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
2026-07-25 09:47:14 +00:00
timothy 22e89fb9a4 docs(440): correct two misleading text defects found in review
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Text-only follow-up; no behavior change (14/14 tests, lint clean, tsc clean).

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

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

Remaining review nits deferred to a follow-up issue: exporting compile.ts's
Lucene escaper instead of duplicating it, an exclude-all warning, and >50-source
axis handling.
2026-07-25 11:30:36 +02:00
timothyandClaude Opus 5 e678e6653e chore(docs): trim derivable content from CLAUDE.md, lazy-load the task-completion protocol
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 30s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 20s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Session context/config checkup (/doctor) found ~2.5k chars of always-loaded
CLAUDE.md text that a session can reconstruct from the codebase, plus a
task-specific workflow that only matters when closing an issue.

Cut (derivable from the repo):
- the `### Project Layout` table (what `ls` shows)
- the `dotnet build` / `dotnet run` invocations (standard for the toolchain;
  the non-obvious `docker build -f docker/Dockerfile` line is kept)
- the Language / Media / Functional C# bullets (stated by the csproj and
  Directory.Packages.props)

Migrated to lazy loading:
- the 7 mandatory completion steps and the `## Closing record` template move
  to .claude/skills/closing-an-issue/SKILL.md; only its one-line description
  stays resident. The `## Done-when` / merge-consent block stays in CLAUDE.md
  on purpose — it is safety-critical and describes hook behaviour that fires
  whether or not a skill was loaded.

CLAUDE.md 13,306 -> 10,801 chars (~625 est. tokens saved per session).
No convention, route, endpoint or decision changes, so no other doc updates
are triggered by the docs-update table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 11:22:48 +02:00
timothy 7e5d20be98 fix(583): gate every model-less dispatch, not just implementer-looking ones
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 55s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent review of d221a065 found the prompt-text heuristic both over- and
under-fired. Confirmed repros: a read-only recon brief merely mentioning
"worktree" nagged, while "author the change and open a PR", "land this on the
branch" and "make the changes and commit them" all passed silently — the gate
missed exactly the case it existed to catch.

Root cause behind all three findings: the hook contradicted its own rule. The
HARD CONSTRAINT says "every dispatched agent"; the hook gated only dispatches
whose prose looked like an implementer. Prompt text is not a reliable signal for
authority.

Inverted: ask whenever `model` is absent, exempting only `fork` (the tool ignores
a fork's model override, so a prompt could not be acted on). This dissolves all
three findings rather than patching the regex — no phrasing dependence, no
false-positive concept, and no "cannot commit" claim to be wrong about. The
reviewer was right that Explore/Plan/claude-code-guide all carry Bash, so the old
exemption rationale was false.

The noise objection is answered by the escape hatch, not by scoping: naming a
tier costs one parameter and the hook never fires. Self-eliminating for anyone
following the rule.

Decision record updated to record the rejected narrow design and why.

Re-verified: 11 payloads incl. all three findings, the invariants (model named ->
never fires; fork exempt; non-Agent passes), and robustness (malformed JSON,
empty payload, missing tool_input, embedded backticks/quotes/newlines) — never
crashes, never emits malformed JSON. decisions-validate: OK.
2026-07-25 11:19:31 +02:00
timothy d3c89d87aa feat(440): per-source weight steppers + exclude/add-untagged in Auto-Tune DetailPanel
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m48s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Wires the Auto-Tune DetailPanel's Content-sources pane to #425's backend: each
member row gains a 1..1000 weight stepper and an include/exclude toggle, and a
library typeahead adds a source that isn't in the axis's base set. Edits
accumulate in the screen's per-channel draft (the existing §8/§11 guard covers
them) and are flushed as the create request's `sources` array.

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

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

fixes #440
2026-07-25 11:15:20 +02:00
timothy d221a06522 chore(583): make per-agent model routing a hard constraint + PreToolUse gate
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m42s
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 / Build & test (.NET) (pull_request) Successful in 21m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The kickoff tells the orchestrator to route agents by capability, but that rule
lived only in a prose paragraph. On 2026-07-25 a session dispatched two
implementers (#436, #440) with `model` omitted, silently inheriting the
orchestrator tier — while following every bullet in HARD CONSTRAINTS in the same
session. The bulleted list is what functions as the checklist; prose above it
reads as background.

- HARD CONSTRAINTS: keyed routing bullet requiring the tier to be stated in the
  dispatch itself, so an invisible omission becomes visible output. Prose
  paragraph tightened to point at the key rather than restate the table (the
  kickoff is pasted into every session — duplication is a per-session tax, #542).
- .claude/hooks/pretooluse-agent-model.sh: PreToolUse on Agent, `ask` when a
  committing agent is dispatched with no explicit `model`. Narrow by design —
  passes through read-only/recon types, `fork` (model override ignored by the
  tool), and any dispatch already naming a tier, because a gate that fires on
  every fan-out trains one-shot dismissal. `ask` not `deny`: routing is a
  judgment call with no derivable right answer, unlike the H6/H10 merge gate.
- New decision record `process.per-agent-model-routing`; catalog regenerated.

Decision matrix verified against 9 payloads incl. a replay of the dispatch that
missed. decisions-validate: OK.

fixes #583
2026-07-25 11:02:42 +02:00
timothy 6cf99718ee feat(436): arbitrary-depth rule-builder group nesting
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 34s
PR Gates / Docs update reminder (pull_request) Successful in 38s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 42s
PR Gates / decisions lifecycle (pull_request) Successful in 1m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m3s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The rule builder's Group nesting was capped at one level (#176's Kodi
model). Generalize it to recursive nesting bounded by a single shared
constant, MAX_GROUP_DEPTH (types.ts, = 5, root group is depth 0):

- parse.ts: replace the allowNested boolean with a depth counter that
  recurses to the cap; deeper input stays out of subset (null -> raw-text
  fallback), so parse remains the exact inverse of compile. Sub-group
  detection now requires the leading '(' to be the one closed by the
  trailing ')' (quote/escape aware), so '(a)x(b)' can't be mistaken for
  one wrapped group.
- RuleBuilder.tsx: 'Add group' is offered while depth < MAX_GROUP_DEPTH
  instead of only at the root; nested group boxes get box-sizing:
  border-box so per-level padding can't overflow (no global reset).
- roundtrip.test.ts: the 500-tree generator nests to the cap and asserts
  the corpus actually reached it; explicit depth-3 cases added to
  compile/parse/validation tests and a depth-gate test to RuleBuilder.

compile.ts and validation.ts already recursed correctly and are unchanged.
No backend/OpenAPI change.

Docs: spa-conventions.md §12; decisions lifecycle — new active record
spa.rulebuilder-nesting, predecessor spa.smartcollection-rule-builder
relocated to docs/decisions/archive/spa.md as superseded.

fixes #436
2026-07-25 11:01:35 +02:00
timothy be1070de51 Merge pull request 'feat(437): inline RuleBuilder smart-query authoring in Channel Builder' (#579) from feat/437-rulebuilder-channelbuilder into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m56s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 16m3s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20m47s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m29s
2026-07-23 21:17:41 +00:00
timothyandtimothy 65c0e09179 feat(415): per-channel fault detection — server-derived health object + Problems filter (#581)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 15m30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 15m48s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Closes #415. Server-derived health object on the channel list + detail DTOs (built-timeline detection, kind-agnostic across all 5 PlayoutScheduleKind; assessable gate keyed to the owning channel's mode), single "Problems" SPA filter with per-fault badges. Supersedes #72's api.channel-health-signal decision.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:08:41 +02:00
timothy aca70b8398 Merge pull request 'feat(438,435,434): RuleBuilder validation, relative-date operators, DB-sourced facet typeahead' (#577) from feat/rulebuilder-bundle 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 33s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m5s
Merge PR #577 (feat/rulebuilder-bundle): RuleBuilder validation (#438), relative-date operators (#435), DB-sourced facet typeahead (#434)
2026-07-23 19:16:55 +00:00
timothyandClaude Opus 4.8 7e6861a928 fix(434): accurate endpoint description (DB-sourced) + tests for case-insensitivity, tag exclusion, distinct
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m50s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:55:32 +02:00
timothyandClaude Opus 4.8 cc5ec712e0 chore(434): restore ISearchIndex/LuceneSearchIndex to main (revert incidental BOM strip)
The GetFieldValues additions were reverted in the DB-sourcing rework; these two
files had only an incidental BOM strip left, which pulled unrelated pre-existing
whitespace debt into the scoped format gate. Restore byte-identical to origin/main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:44:15 +02:00
timothyandClaude Opus 4.8 a654ae554a fix(434): actually strip the BOM this time (previous commit staged before stripping)
ISearchIndex.cs / LuceneSearchIndex.cs still carried a BOM after the prior
commit — the strip ran after `git add`, so the staged (BOM'd) content was
what got committed. No content change beyond the BOM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:44:15 +02:00
timothyandClaude Opus 4.8 9cb51f6a73 docs(434): [decisions-edit] update field-values decision + api-conventions for DB sourcing
Edits the api.search-field-values decision record's rationale prose in
place (same key, same date, not a reversal) to describe the DB-sourced
per-field distinct-values design and the narrowed allow-list, replacing
the superseded Lucene-term-dictionary description. Updates the endpoint's
api-conventions.md entry the same way. Regenerated docs/decisions/README.md
via build_decisions_catalog.py; decisions_validate.py passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:44:15 +02:00
timothyandClaude Opus 4.8 fb5f609cef rework(434): source facet typeahead from DB distinct values, not Lucene analyzed tokens
The Lucene term dictionary stores lowercased word tokens for analyzed text
fields ("Science Fiction" -> science/fiction), so the typeahead was
suggesting fragments instead of whole values. GetSearchFieldValuesHandler
now injects IDbContextFactory<TvContext> and resolves an explicit
per-field-name distinct-values query (genre/studio/director/writer/actor/
artist/tag/network/collection/video_codec/album), with state and
video_dynamic_range computed in memory and content_rating split on '/' to
match what search actually matches on. title/show_title/album_artist have
no distinct source and now correctly 404 (free-text fallback), same as
before. Reverts the GetFieldValues additions to ISearchIndex/
LuceneSearchIndex/ElasticSearchIndex back to their pre-#434 state (BOM
stripped per #311, otherwise byte-identical). Endpoint shape, DTO,
controller, and OpenAPI are unchanged (no diff from
./scripts/update-openapi.sh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:44:15 +02:00
timothyandClaude Opus 4.8 a1d3adda9c fix(435): reject non-integer relative-date N (1e2/7.0) — strict digit-string validation
- validation.ts: ruleError now uses /^\d+$/ regex instead of Number.isInteger
- dateMacro.ts: isValidN now uses /^\d+$/ regex instead of Number.isInteger
- Prevents accepting scientific/decimal notation (e.g. '1e2', '7.0') that break compile↔parse contract
- Added tests for both invalid cases returning null/error

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:27 +02:00
timothyandClaude Opus 4.8 b66a3400ae feat(438,435): RuleBuilder UI — validation surface, relative-date inputs, single-child toggle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 30a8a5752c feat(435): parse relative-date macros; round-trip against normalizeGroup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 d487ce3601 fix(438): drop empty nested subgroups instead of emitting "()" in compiled Lucene
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 575f652760 feat(438,435): compile relative-date macros; drop invalid rules instead of malformed Lucene
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 000a987a48 test(435): cover all relative-date combos, invalid-input rejection, composed round-trip
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 e91891a453 feat(435): dateMacro compile/parse mapping for relative-date operators
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 c301db10c4 feat(438,435): extend rule types; validation + single-child normalization helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 5618ad12ea plan(434,435,438): RuleBuilder bundle implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandClaude Opus 4.8 4e2183d9a3 design(434,435,438): RuleBuilder bundle spec — validation, relative-date operators, facet typeahead
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:43:26 +02:00
timothyandtimothy 8b9a7ed541 feat(414): stamp immutable Channel.Origin (auto-tuned vs user-created) and surface it (#575)
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 12m1s
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-23 18:12:25 +00:00
timothyandtimothy 7a7c611267 fix(458): reject duplicate names on playlist &amp; playlist-group rename (#576)
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
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-23 18:07:43 +00:00
timothyandtimothy 0c063c23fb harden(421,559): percent-encode access_token in IPTV URLs, redact from logs, no-store on tokened manifests (#574)
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 7m45s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-23 16:30:59 +00:00
timothy 41291b3686 Merge pull request #573 (feat/392): per-schedule clock-boundary padding toggle + 60-min increment
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 32s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 33s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m40s
2026-07-23 07:10:29 +00:00
timothy bb3e245b3c docs(392): drop predecessor cross-ref line (append-only decisions diff)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m53s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m58s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-23 08:27:02 +02:00
timothyandClaude Opus 4.8 60fecd18b7 chore(392): regenerate migration + OpenAPI + decisions catalog after rebase onto main
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 38s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 34s
PR Gates / decisions lifecycle (pull_request) Failing after 43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m55s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 18m23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 23m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The rebase onto origin/main (which merged #74's ChannelGraphicsElement
migration) left the PadToNearestMinute migration's embedded Designer.cs
model snapshot stale — it still reflected the pre-#74 model, so EF's
diff against it produced an empty Up()/Down() when naively regenerated.
Reset TvContextModelSnapshot.cs to origin/main's true post-#74 state,
then re-ran scripts/add-migration.sh so the migration's Designer.cs
correctly folds in ChannelGraphicsElement and the migration's Up() adds
only the PadToNearestMinute column. Verified has-pending-model-changes
is clean for both providers.

OpenAPI (v1.json/v1.d.ts/endpoint-index.md) and docs/decisions/README.md
regenerated identically to the auto-merged state, so nothing to commit
there — confirmed both padToNearestMinute and #74's graphics-elements
endpoints/decision key are present.

Stripped a UTF-8 BOM this dotnet-ef/dotnet-format toolchain wrote into
the regenerated migration + snapshot files (known BOM trap, ersatztv#311).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:18:15 +02:00
timothy 767f96802e fix(392): apply schedule-level pad to Fill-With-Group items (reverse nav lost in DeepCopy) 2026-07-23 08:03:31 +02:00
timothyandClaude Opus 4.8 6a05363e2a design(392): mirror 60-min pad option into FillerPresets prototype
Schedule-level pad control has no prototype counterpart (the schedule-level
scalar form is not modeled in Schedules.jsx); nothing to mirror there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:03:31 +02:00
timothy e99e72ee71 docs(392): record per-schedule clock-padding decision + domain-model field 2026-07-23 08:03:31 +02:00
timothy f4ba219000 feat(392): add pad-to-clock-boundary control to the schedule editor 2026-07-23 08:03:31 +02:00
timothyandClaude Opus 4.8 beab219a90 test(392): cover schedule pad through Flood/Duration/Multiple; make day-seam clamp precise
Adds parameterized invariant tests (Schedule_clock_padded_offline_multimode)
exercising schedule-level clock pad + offline advance across a 2-day window
(two midnight crossings) through Flood, Duration, and Multiple — previously
only PlayoutModeSchedulerOne had any coverage. Fixture uses sub-15-min content
so each padded item occupies one :15 slot and Duration's fill-the-block
contract tiles exactly (no off-boundary packing).

Part 2 (precision): replaces the day-boundary anchor-clamp magnitude heuristic
(overrun <= one pad interval on a padded schedule) with a precise signal — the
last scheduler now reports the exact offline-pad target it advanced CurrentTime
to (transient PlayoutSchedulerResult.ClockPadOfflineTarget, never persisted),
and the clamp exempts only when CurrentTime equals that target exactly. A
non-offline overrun (Duration/Flood/Multiple ending short of its natural end, a
hard-stop, a tail advance) changes CurrentTime away from the target and still
clamps, so persisted NextStart no longer shifts by up to an interval. No
persisted-schema/migration change. Output byte-identical for all existing
goldens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:03:31 +02:00
timothy 7efe38dd04 feat(392): honor ProgramSchedule.PadToNearestMinute in the Classic builder 2026-07-23 08:03:31 +02:00
timothy 9ea2750cbf feat(392): expose ProgramSchedule.padToNearestMinute on the REST API 2026-07-23 08:03:31 +02:00
timothy 75d5c385b2 feat(392): add ProgramSchedule.PadToNearestMinute column (dual-provider migration) 2026-07-23 08:03:31 +02:00
timothy 2f1ede6030 feat(392): add 60-minute option to filler-preset pad increment 2026-07-23 08:03:31 +02:00
timothy 0b814abf41 docs(392): extract shared ComputePadBoundary helper in plan (DRY) 2026-07-23 08:03:31 +02:00
timothyandClaude Opus 4.8 9385bc8acd docs(392): implementation plan for per-schedule clock-boundary padding
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:03:31 +02:00
timothyandClaude Opus 4.8 425b4b65f9 docs(392): design spec for per-schedule clock-boundary padding toggle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:03:31 +02:00
timothy 5a7a4ed7e0 Merge pull request 'fix(570): On Now/Next overlay YAML renders (font_family + format_datetime)' (#571) from fix/74-overlay-render 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 15s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 29s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 9m39s
Merge pull request '#571 fix(570): On Now/Next overlay YAML renders (font_family + format_datetime)' (#571) from fix/74-overlay-render into main

fixes #570
2026-07-22 21:39:32 +00:00
timothyandClaude Opus 4.8 892f4e354b fix(570): On Now/Next overlay YAML renders — font_family + format_datetime
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m46s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m50s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The #74 seeded on-now-next.yml never rendered at transcode time:
- format_datetime was called with 2 args; it needs 3 (DateTimeOffset, timeZoneId,
  format) and does the tz conversion itself, so the Scriban render threw. Drop the
  NEXT start-time (avoids hardcoding a timezone in a shipped default).
- styles had no font_family; CustomFontMapper.TypefaceFromStyle crashes on a null
  family before its default-font fallback. Add font_family: Noto Sans.
Verified live on ersatztv-test via frame capture. Adds a seeder test that
deserializes the YAML and asserts base_style resolves + every style sets a font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:10:35 +02:00
timothy 7080f63ebe Merge pull request 'feat(74): per-channel On Now/Next transient overlay' (#569) from feat/74-on-now-next-overlay 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 15s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 29s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m45s
Merge pull request '#569 feat(74): per-channel On Now/Next transient overlay' (#569) from feat/74-on-now-next-overlay into main

fixes #74
2026-07-22 20:36:41 +00:00
timothyandClaude Opus 4.8 b7d58bbf32 docs(74): tighten overlay suppression rule (Merge-during-filler also clears)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m45s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m51s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Folds whole-branch review finding #2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:11:46 +02:00
timothy c80839b338 docs(74): channel-level graphics attachment + On Now/Next overlay 2026-07-22 22:11:46 +02:00
timothy b139921691 feat(74): channel Branding-tab On Now/Next overlay toggle 2026-07-22 22:11:46 +02:00
timothy 7757814766 feat(74): channel graphicsElementIds + graphics builtIn; regen OpenAPI 2026-07-22 22:11:46 +02:00
timothy d6652dbe13 feat(74): selector emits channel-level graphics elements as a base layer 2026-07-22 22:11:46 +02:00
timothyandClaude Opus 4.8 fe0a273aad feat(74): add ChannelGraphicsElement join + dual-provider migration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:11:45 +02:00
timothy 9c6b524aed feat(74): seed built-in on-now-next.yml text element (file + marker) 2026-07-22 22:11:45 +02:00
timothy ed3acb931c docs(74): fix Task 3 test to Testably MockFileSystem API 2026-07-22 22:11:45 +02:00
timothyandClaude Opus 4.8 6ad2f83ea6 docs(74): implementation plan for On Now/Next overlay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:11:45 +02:00
timothyandClaude Opus 4.8 2901b5293a docs(74): design spec for per-channel On Now/Next transient overlay
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 22:11:45 +02:00
timothy d759a9fc83 Merge pull request 'refactor(395): dedup Scripted≡YAML enumerator construction into ContentEnumeratorBuilder' (#566) from issue-395-dedup-enumerator into main
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 / Functional E2E (curl contracts) (push) Successful in 31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 31s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 32s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 9m37s
2026-07-22 18:52:23 +00:00
timothyandClaude Opus 4.8 7b77a37f0f docs(395): keep the decision record's original heading (fix decisions-lifecycle)
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 21s
PR Gates / Docs update reminder (pull_request) Successful in 24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The lifecycle validator matches records by their `##` heading for removal
detection (gone = base_headings - head_headings), ungated by [decisions-edit].
Renaming the heading read as an unlogged record removal. Restore the exact
original heading -- which stays literally true ("excluded from the golden net":
#395 adds a unit test, not a golden) -- and keep the rationale correction in the
Rule/Signals/body, which is what [decisions-edit] authorizes.

[decisions-edit]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:28:58 +02:00
timothyandClaude Opus 4.8 f54c9ae195 refactor(395): dedup Scripted≡YAML enumerator construction into ContentEnumeratorBuilder
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m25s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m36s
PR Gates / decisions lifecycle (pull_request) Failing after 14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
Extract the byte-identical PlaybackOrder -> IMediaCollectionEnumerator switch
shared by SchedulingEngine.EnumeratorForContent (Scripted) and
EnumeratorCache.GetEnumeratorForContent (Sequential/YAML) into one static
per-family seam, mirroring #380's ShuffleSourceBuilder. Each engine keeps its own
"not supported" warning on the None branch, so the per-engine message is unchanged.
Adds ContentEnumeratorBuilderTests pinning the block-shuffle-not-classic trap and
the unsupported-order -> None (#70) contract across all 8 unsupported orders.

Corrects the testing.scripted-playout-golden-deferred decision record: Scripted's
external-process + HTTP pipeline is integration-only (deferred to #563), but the
in-process SchedulingEngine it drives IS unit-testable (ScriptedScheduleController
is a 1:1 pass-through) -- the earlier "un-golden-able by construction" framing
conflated transport with engine. docs/testing.md reframed to match.

[decisions-edit]

fixes #395

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:21:52 +02:00
timothy 5c20a98468 Merge pull request 'test(381): Sequential (YAML) playout golden; document Scripted deferral' (#564) from feat/381-scheduler-goldens 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 / Functional E2E (curl contracts) (push) Successful in 5m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m56s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m8s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m14s
2026-07-22 17:40:31 +00:00
timothy 04ca7ae981 Merge pull request 'feat(297): add channelId to PlayoutListItemResponseModel; SPA reset keys directly' (#562) from issue-297-playout-channelid 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) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
2026-07-22 17:30:24 +00:00
timothy 5c73c7a4bd Merge pull request 'fix(248): focus-trap the shared modal overlay so Tab can't escape a dialog' (#561) from issue-248-overlay-focus-trap into main
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has started running
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
2026-07-22 17:30:13 +00:00
timothyandClaude Opus 4.8 80a9824a4e test(381): golden coverage for Sequential (YAML) playout builder; document Scripted deferral
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 28s
PR Gates / Docs update reminder (pull_request) Successful in 46s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m53s
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 18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m29s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Extends the #163 PlayoutBuildGoldenTests in-memory net to the Sequential (YAML)
builder: a committed fixture (Goldens/Fixtures/sequential-schedule.yml) with two
`count: 2` instructions over one chronological collection, built via
SequentialPlayoutBuilder over the pinned window. The count/all/duration handlers
do UTC-only arithmetic off the caller-supplied start, so the case is
TZ-independent (passes, not skips, under a non-UTC TZ) and needs no Assume guard.
Non-vacuity: a fixture count tweak flips the golden + the contiguity assertion.

Scripted is deliberately excluded from the golden net — ScriptedPlayoutBuilder
shells out via Cli.Wrap to an external process that drives SchedulingEngine over
HTTP, which no in-memory golden can characterize. Recorded as the Done-when
"documented decision" arm in docs/decisions.md
(testing.scripted-playout-golden-deferred) + docs/testing.md; the scripted
integration harness is tracked as follow-up #563.

fixes #381

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:18:12 +02:00
timothyandClaude Opus 4.8 1a3c8e277f feat(297): add channelId to PlayoutListItemResponseModel; SPA reset keys directly
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m37s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m43s
Added ChannelId to PlayoutNameViewModel and all 6 construction sites
(Mapper, GetPlayoutByIdHandler, and the Update{,Scripted,ExternalJson,Sequential}
PlayoutHandler commands), plus the list DTO PlayoutListItemResponseModel and the
PlayoutController list projection. Regenerated OpenAPI (v1.json) and the TS client
(v1.d.ts); endpoint-index.md unchanged (no endpoint/operation delta). Simplified
PlayoutsScreen resetSelectedChannel to key directly on selectedSummary.channelId
instead of resolving via channelStates. Updated controller + SPA tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:57:54 +02:00
timothyandClaude Opus 4.8 ae4408c6b2 fix(248): focus-trap the shared modal overlay so Tab can't escape a dialog
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m42s
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 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
focus trap added to shared useOverlayBehavior (covers Dialog + SlideOver);
Tab/Shift+Tab now cycle within the panel; test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:55:26 +02:00
timothy 1681ae4e60 Merge pull request 'fix(552): mint a short-lived JWT so the SPA reaches /iptv/* under JWT auth' (#560) from feat/552-spa-iptv-jwt 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 7m44s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 16m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Has started running
2026-07-22 16:52:04 +00:00
timothy d49d078264 Merge pull request 'test(512): flush LibrariesScreen scan-poll chain deterministically instead of waitFor timeouts' (#558) from issue-512-deterministic-libraries-test 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 8m1s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 15m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 19m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m45s
2026-07-22 16:02:22 +00:00
timothy 869253cd83 Merge pull request 'fix(553): exclude bot-authored issues from the queue selector' (#557) from issue-553-selector-bot-exclude into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 13s
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 32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 34s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-22 16:01:19 +00:00
timothyandClaude Opus 4.8 ce30bbbb32 fix(552): don't reload the stale-token URL on preview Retry (review regression)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m41s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Re-review of 60a0c505 caught a regression the prior fix introduced: onRetry cleared
the token cache and bumped playToken but kept the old resolvedSrc, so HlsPlayer
reloaded the stale-token URL before the remint resolved — a duplicate manifest
session and a stale 401 that could stick the panel as failed even after the fresh
stream succeeded.

Null resolvedSrc in onRetry before bumping playToken so the player unmounts until the
async effect resolves the freshly-minted URL. Added a controlled-async test proving
the stale-token URL is never reloaded and the retry loads the new token (validated by
negative control: the test fails with the fix removed, and only that test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:42:15 +02:00
timothyandClaude Opus 4.8 60a0c50578 fix(552): fold #552 security-review findings
Cold review (no Critical/High). Folded:
- Low: clamp JWT:BrowserTokenLifetimeMinutes to a 24h max so a seconds-vs-minutes
  typo can't mint a multi-year bearer token (non-positive/unparseable still falls
  back to 60 min).
- Low: reset the SPA iptv-token cache on the preview panel's Retry and on each
  troubleshooting Play, so a stale token (key rotated) or a stale "JWT disabled"
  latch (backend reconfigured since page load) can't wedge a user-initiated retry.

Deferred to #559 (tracked): redact access_token from Serilog request logs and set
no-store on token-bearing /iptv manifests — pre-existing properties of the shared
?access_token= transport (Jellyfin/M3U already use it), now bounded by the 60-min
lifetime; cross-cutting fixes beyond this feature's scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:34:01 +02:00
timothyandClaude Opus 4.8 f8ae4d62ab fix(552): mint a short-lived JWT so the SPA reaches /iptv/* under JWT auth
Under a JWT-enabled deployment (JWT:IssuerSigningKey set), /iptv/* is gated by
ConditionalIptvAuthorizeFilter and the "jwt" scheme does not accept the SPA's
ctv-session cookie, and nothing minted a JWT for the browser. So the #60 channel
preview was declared Unavailable and could not run at all.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:21:05 +02:00
timothyandClaude Opus 4.8 b5596f55f3 test(512): flush LibrariesScreen scan-poll chain deterministically instead of waitFor timeouts
PR Gates / Docs update reminder (pull_request) Successful in 18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 8s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m57s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The "stops scan polling and refreshes sources once when scans complete" test
fired the poll tick via runPollTick but discarded the promise loadScanStatuses
returns, so the progress-clear state update and the fire-and-forget
loadSources() refetch it triggers landed on real microtasks AFTER act() had
resolved. The test out-waited that race with two waitFor({ timeout: 5000 })
calls, which were marginal on a starved CI VM (timed out on PR #509/run 918);
prior timeout bumps (#447) were diminishing whack-a-mole.

Drive the chain to completion deterministically instead of out-waiting it:
runPollTick now awaits the promise the handler returns (settling the
progress-clear) and then yields to a single macrotask (setTimeout 0) to drain
the microtask queue — including the fire-and-forget loadSources() refetch —
all INSIDE act(async () => ...). The mock fetches resolve synchronously on
microtasks and setInterval is the only mocked timer, so one macrotask turn
completes both chains with no wall-clock delay. The two waitFor calls are
replaced by synchronous assertions (progress label gone; exactly one extra
/api/v1/media-sources fetch), and the 20s test-level budget is dropped.

Verified: LibrariesScreen suite green 15/15 consecutive runs; npm run lint and
npm run build clean. No production code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Fixes #512
2026-07-22 17:02:14 +02:00
timothyandClaude Opus 4.8 6208d66864 fix(553): exclude bot-authored issues from the queue selector
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 30s
PR Gates / Docs update reminder (pull_request) Successful in 55s
PR Gates / decisions lifecycle (pull_request) Successful in 58s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m34s
scripts/select-queue.sh ranked Renovate's Dependency Dashboard (#22) as an
ordinary `priority: low` candidate. With an all-low backlog ordered by issue
number, #22 sorted to the top and was proposed to every fresh session — a bot-
rewritten status board whose checkboxes are commands to Renovate, not work
items. A session trusting the selector's "trust the ordering" contract would
either waste a pickup or make the exact undocumented judgment call the script
exists to eliminate (this session hit it live).

Drop bot-authored issues in the same jq pass that drops PRs/in-progress/parked:
match login `renovate` plus the GitHub `name[bot]` convention so a future bot
dashboard is excluded too. A bot's actionable output is PRs (already excluded);
it never files a human work-item issue, so the whole class is never a pickup.

Also make "scan for bundle-able siblings after claiming" an explicit kickoff
step (new step 4) with a third bundle axis — shared label / adjacent subject —
so a session sweeps small independent same-label issues together (e.g. this
change's own #553 + #512 ci-cd hygiene bundle) instead of closing one at a time.

Verified: selector no longer lists #22; genuine backlog issues still rank; the
only bot-authored open issue is #22. shellcheck clean.

Fixes #553

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:01:46 +02:00
timothy 732322dc84 Merge pull request 'feat(60): in-browser channel preview on the channels list' (#551) from feat/60-channel-preview into main
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m0s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20m8s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 15m24s
2026-07-21 22:29:35 +00:00
timothy fd97284a85 Merge pull request 'docs(jellyfin-skill): correct stale "music videos are typed Movie" gotcha' (#556) from docs/jellyfin-skill-musicvideo-correction 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 / Functional E2E (curl contracts) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7s
2026-07-21 21:57:56 +00:00
timothy 6fa6dd76a0 docs(jellyfin-skill): correct stale "music videos are typed Movie" gotcha
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 33s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 34s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The skill told every session that music videos are typed "Movie" in
Jellyfin. That described a deliberate DB reclassification workaround which
existed only because ErsatzTV could not consume MusicVideo items. #42
shipped that sync (and #177 just extended it), so the workaround's premise
is gone -- but the note outlived it and would send a session querying
includeItemTypes=Movie and finding nothing.

Verified live: the Music Videos library (/data/music, collection type
"musicvideos") holds 1437 items typed MusicVideo and zero typed Movie.

Also records two Jellyfin API traps found while working #177:

- Album is NOT an ItemFields value. It is a plain BaseItemDto property
  serialized whenever set, so it returns regardless of `fields=` -- adding
  it there would be cargo-culted from Genres/People/Chapters, which ARE
  ItemFields. 111 of 1437 items returned Album with fields=Path alone.
- IndexNumber is the track number; ParentIndexNumber is the disc/season
  axis. Frequency misleads: ParentIndexNumber is populated 16x more often
  (66 vs 4), but where both exist it is 1 while IndexNumber holds the real
  ordinal, and alone it is a collection grouping tracking the album.

Scoped to this repo's copy. The canonical skill is owned by
server-management and is corrected separately -- see the issue filed there.

refs #177
2026-07-21 23:56:40 +02:00
timothy c90a11d2b4 Merge pull request 'fix(177): map Album/Track in the Jellyfin music video projection' (#555) from issue-177-jellyfin-mv-album-track into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 11s
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 25s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-21 21:50:19 +00:00
timothy 6bf8e191cb test(60): prove the failed-flag reset effect actually runs on channel switch
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12m57s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m39s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m30s
The reset moved from render-phase into an effect; the existing switch test
asserted only that the error banner cleared, which stays green even if the
effect is deleted. Now the new channel must also reach 'playing'.

Negative control: disabling the reset fails exactly this test, and only it.

Refs #60
2026-07-21 23:25:22 +02:00
timothy b45dcc7190 fix(177): map Album/Track in the Jellyfin music video projection
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m40s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m10s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m39s
Jellyfin-sourced music videos rendered weaker MTV-style credits than local
NFO libraries: the Scriban credits templates expose Album/Track, and
MusicVideoNfoReader has always mapped both, but the Jellyfin projection
never did. ChronologicalMediaComparer orders music videos by the same two
fields, so they were also ordering worse.

Verified against the live server (1437 MusicVideo items): Album comes back
on 111 and IndexNumber on 4, both WITHOUT being named in the `fields` query
param -- Album is a plain BaseItemDto property, not an ItemFields value, so
no Refit `fields` change is needed (and adding one would be wrong).

ParentIndexNumber is deliberately NOT used for Track: on live data, where
both are present ParentIndexNumber is 1 while IndexNumber carries the real
ordinal, and where only ParentIndexNumber is present it is a collection/disc
grouping that tracks the Album ("Glastonbury: 2022" -> 230).

The fix is two layers, not one. The projection alone would only ever reach
music videos ADDED after it -- UpdateMetadata copies scalars field by field,
so an existing item whose album/track is set or corrected in Jellyfin would
keep a stale value forever. That is the same class of bug #497 fixed for
child collections, one layer up.

Also strips a pre-existing UTF-8 BOM from JellyfinLibraryItemResponse.cs,
which the format gate flags once the file is touched (format-as-you-touch).

fixes #177
2026-07-21 23:21:10 +02:00
timothy 6838979780 fix(60): re-review fixups for channel preview
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m11s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m26s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 22m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- ChannelPreviewPanel: a manual play-button click on a video already
  showing a fatal error was clearing the error, silently hiding the
  fault the panel exists to reveal. onPlaying now ignores the event
  while a fatal error is showing (tracked via a ref, reset in an
  effect keyed on channel.id); Retry remains the only way to clear it.
- shell.css: .ctv-preview-facts spacing was dead — equal-specificity
  .ctv-detail-infogrid{margin:0} later in the file won. Raised
  specificity with a compound selector instead of touching
  .ctv-detail-infogrid, which MediaDetailScreen also relies on.
- ChannelPreviewTests: added two cases exercising two simultaneously-
  true Unavailable causes, so the documented guard precedence in
  Mapper.GetPreview is actually pinned by a test.
- design doc: fixed a garbled sentence describing which DTO gained
  the Preview field.
2026-07-21 23:13:27 +02:00
timothy 54610fe0af docs(60): correct stale channel-preview design/decisions statements
The design spec claimed ChannelDetailResponseModel also gained the Preview
field; only ChannelResponseModel did (deliberate — nothing consumes it on the
detail DTO). Also record the two new Unavailable causes (disabled channel,
zero playouts) added to Mapper.GetPreview in the api.channel-preview-capability
decisions.md record.
2026-07-21 23:13:27 +02:00
timothy 6154aaebb2 fix(60): onPlaying reflects real playback, panel is styled, availability is type-safe
- HlsPlayer: drive onPlaying from the <video> element's own `playing` event on
  BOTH the hls.js and Safari-native paths instead of MANIFEST_PARSED, which
  fires before any media has decoded (an HttpLiveStreamingDirect manifest
  always parses, even over a black video). MANIFEST_PARSED now only kicks
  play(). Restore `void video.play().catch(...)` at both call sites and stub
  HTMLMediaElement.prototype.play in setupTests.ts instead, so the `?.` that
  existed only to survive jsdom is gone from production code.
- HlsPlayer.test.tsx: assert the auto-recovery guard against hls.js's own
  startLoad()/recoverMediaError(), not just loadSource's call count.
- ChannelPreviewPanel: reuse existing ctv-* classes (ctv-channels-error,
  ctv-settings-warn-callout, ctv-detail-actions, ctv-detail-infogrid) instead
  of five undefined ctv-preview-* classes; add the two genuinely new rules
  (ctv-preview-video max-width, spacing tweaks) to shell.css.
- Add an exported ChannelPreviewAvailability union (web/src/api/channels.ts)
  so a typo like 'ForcedHLSOnly' fails to compile instead of silently
  disabling a branch forever; use it in ChannelPreviewPanel's prop type and
  at the ChannelsScreen comparison sites.
2026-07-21 23:13:27 +02:00
timothy 6000356739 fix(60): declare disabled-channel and no-playout as Unavailable preview causes
GetPreview keyed only on StreamingMode + JWT, so a disabled channel or one
with no playout was declared Available and then failed confusingly (404 from
IptvController, or an indefinitely-blocking manifest request). Extend it to
take isEnabled + playoutCount and check JWT, then disabled, then no-playout,
before falling back to the existing mode-based rules; order is documented
in a comment. Also corrects a stale comment in ChannelPreviewResponseModel.cs
that claimed a C# string generates a TypeScript union (it does not).
2026-07-21 23:13:27 +02:00
timothy 26330cf04c fix(60): strip UTF-8 BOM from GetAllChannelsForApi.cs
Trips the CI Formatting gate (CHARSET).

Refs #60
2026-07-21 23:13:27 +02:00
timothy 22d4023263 docs(60): record the channel-preview capability decision 2026-07-21 23:13:27 +02:00
timothy 7b21cd98ff feat(60): activate channel preview on the channels list
Wires the ChannelPreviewPanel (Task 4) into ChannelsScreen: a single
panel instance is rendered per screen and its `channel` prop is swapped
via previewChannelId state rather than remounting per row. The Play
button now reads the server-derived channel.preview.availability
(Task 2) instead of being permanently disabled -- Unavailable stays
disabled with the server's unavailableReason surfaced in the title;
Available and ForcedHlsOnly both enable it, since the panel itself
handles the forced-HLS opt-in and caveat.

Also fixes App.test.tsx's #244 channel fixture, which lacked the now-
required preview field and crashed once ChannelTableRow started
reading it.
2026-07-21 23:13:08 +02:00
timothyandClaude Opus 4.8 4c0f80cfe1 fix(chicorytv): test render-phase channel-switch reset; add HlsPlayer onPlaying
Two review findings on the channel preview panel (#60):
- ChannelPreviewPanel's synchronous render-phase reset (started/state/error/playToken
  on channel.id change) was reachable in prod (the channels screen keeps one panel
  mounted and swaps the channel prop) but untested. Added tests proving no auto-start
  switching into a ForcedHlsOnly channel, error clearing on switch between Available
  channels, and no playToken leak across the switch.
- PlaybackState included 'playing' but nothing ever set it. Added HlsPlayer onPlaying,
  fired from Hls.Events.MANIFEST_PARSED and the native-HLS <video> 'playing' event,
  mirroring onError's optional/stable-callback contract; ChannelPreviewPanel now wires
  it to reach 'playing'. Also fixed a latent bug hit while exercising this path:
  video.play().catch(...) assumed a Promise, but jsdom's play() returns undefined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:13:08 +02:00
timothy 8c76986239 feat(60): add ChannelPreviewPanel
Renders an in-browser HLS preview of a channel (operator diagnostic).
Deviates from the plan brief per updated requirements: an explicit
user-initiated Retry control replaces the hardcoded playToken, the
forced-HLS caveat renders both before and after opting in, and the
caveat string is exported verbatim as FORCED_HLS_CAVEAT.
2026-07-21 23:13:08 +02:00
timothy e848fb30b9 feat(60): report fatal HLS errors from HlsPlayer 2026-07-21 23:13:08 +02:00
timothy 11fb60469a docs(60): correct generated-artifact paths in the plan
The real paths are ErsatzTV/wwwroot/openapi/v1.json and
web/src/api/generated/v1.d.ts.

Refs #60
2026-07-21 23:13:08 +02:00
timothy f1de436ca7 feat(60): expose channel preview capability on GET /api/v1/channels 2026-07-21 23:13:08 +02:00
timothy e781bd03be fix(60): relocate ChannelPreviewTests to ErsatzTV.Tests, fix call sites
Review findings on the Task 1 commit (f6ff3b63):

1. Mapper.GetPreview is internal to ErsatzTV.Application, granted only to
   ErsatzTV.Tests by convention (one-assembly-one-test-project). Move
   ChannelPreviewTests.cs from ErsatzTV.Core.Tests to
   ErsatzTV.Tests/Application/Channels, and revert the second
   InternalsVisibleTo entry added to ErsatzTV.Application.csproj for
   ErsatzTV.Core.Tests.

2. GetAllChannelsForApiHandlerTests.cs constructed `new
   GetAllChannelsForApi()` at three sites, which no longer compiles now
   that the record requires IptvJwtEnabled. Pass IptvJwtEnabled: false at
   each site (all three tests are about plain channel listing/logo
   mapping, not JWT).

ChannelController.GetAll's missing argument remains, deliberately, for a
later task.
2026-07-21 23:13:07 +02:00
timothy e4aa28b127 feat(60): server-declared channel preview capability
Adds ChannelPreviewResponseModel + ChannelPreviewAvailability constants
and Mapper.GetPreview(streamingMode, channelNumber, iptvJwtEnabled),
threaded through ProjectToResponseModel's new iptvJwtEnabled parameter
and GetAllChannelsForApi's new IptvJwtEnabled property.

Also adds ErsatzTV.Core.Tests to ErsatzTV.Application's
InternalsVisibleTo list (was ErsatzTV.Tests only) so the new
ChannelPreviewTests can call the internal Mapper methods it's testing.

ChannelController.GetAll is left failing to compile (Task 2 wires
JwtHelper.IsEnabled in at the controller).
2026-07-21 23:13:07 +02:00
timothy 2595a134f8 docs(60): implementation plan for in-browser channel preview
Also simplifies the spec's guide correlation: ChannelStateResponseModel
already carries NowPlaying and ChannelsScreen already holds it, so the
panel needs no /api/v1/guide fetch and no timer.

Refs #60
2026-07-21 23:13:07 +02:00
timothy b892911400 docs(60): design spec for in-browser channel preview
Verification-oriented HLS preview on the channels list: server-declared
per-channel Preview capability (Availability/ManifestUrl/UnavailableReason),
a SlideOver panel over the existing HlsPlayer, and an onError extension so
playback failures stop being silent.

Refs #60
2026-07-21 23:13:07 +02:00
timothy 41f5701722 Merge pull request 'fix(135): from-lineup advanced overrides can express "clear to none"' (#550) from issue-135-clear-to-none 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 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m47s
fix(135): from-lineup advanced overrides can express "clear to none" (#550)

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

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

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

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

fixes #135

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:25:58 +02:00
timothy 44d9e47e1c Merge pull request 'fix(539): WorkAheadSlots.Release never publishes a negative count' (#546) from fix/539-workaheadslots-hardening 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 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 30s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 10m53s
2026-07-21 19:20:42 +00:00
timothyandClaude Opus 4.8 1ce5743bc1 fix(539): WorkAheadSlots.Release clamps before decrementing, reports unbalance in-band
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m31s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three Low findings from the #536 clamp re-review, unreachable today (one
guarded release site) but filed against the day a second release site is added.

- §1: Release() now reads the count and CAS-decrements only when current > 0,
  so it never publishes a negative count even transiently. The prior
  decrement-first-then-clamp shape dipped to -1, which a concurrent TryAcquire
  could read as phantom room and over-admit at the limit (re-opening the #529
  QSV pool exhaustion). It records the unbalanced release synchronously on the
  offending thread rather than blaming a later innocent release.
- §3: Release() returns bool; HlsSessionWorker logs a warning on the false
  (unbalanced) return — the one in-band signal a future second release site
  would need. WorkAheadSlots stays logger-free by design.
- §2: UnbalancedReleases doc-comment corrected — it can under-count (an
  over-release while count > 0 cancels a coexisting leak and goes unrecorded);
  no false positives, but zero does not prove correctness.

Test: Release_Unbalanced_NeverPublishesNegativeCount (2M unbalanced releases vs
4 count-samplers) with a documented, verified negative control (reverting to
the decrement-first body makes readers observe the transient -1).

Adds a decisions.md entry (ffmpeg.work-ahead-slot-release-never-negative).

fixes #539

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:59:32 +02:00
timothy 04d3eb2dbc Merge pull request 'ci(545): require a **Signals:** line on decision records' (#547) from fix/545-signals-required into main
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 7m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20m50s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m23s
2026-07-21 18:58:03 +00:00
timothy c05fdd3be1 Merge pull request 'docs(skill): correct the ersatztv skill for the fork' (#549)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20s
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 35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 37s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 15s
2026-07-21 18:51:47 +00:00
timothy 86fe69d7fb docs(skill): correct the ersatztv skill for the fork — it described upstream
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 30s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 24s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 23s
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 13s
The skill still described archived upstream v26.3.0: "MediatR + Blazor (not
REST for mutations)", "No REST CRUD for channels/collections/schedules —
must use SQLite DB directly", and the ghcr.io image. All false for this
fork — Blazor was removed in #91b and /api/v1 has full write paths. A
session trusting it would hand-edit SQLite for something the API does.

Also adds what this session had to discover by hand while driving the API:
the key is root-owned so an unsudo'd read fails SILENTLY (empty header ->
401 body that parses as a dict -> a naive script reports "0 channels"
rather than an auth error); settings live at /api/v1/settings/ffmpeg, not
ffmpeg/settings; test is port 8410 and the service-scoped manual refresh
command for validating before the 03:00 auto-update.

Two measurement traps recorded with them: OCI labels are inherited from the
linuxserver base image and lie about what is running (compare .Image to the
registry Docker-Content-Digest), and container log brackets are local time
while `docker logs -t` is UTC, so --since windows mis-slice.

Endpoint lists now defer to docs/endpoint-index.md rather than being
re-maintained here, since a hand-copied list is what drifted in the first
place.
2026-07-21 20:49:33 +02:00
timothy 9713bae498 Merge pull request 'docs(542): record the workflow lore, then prune the kickoff doc to instructions' (#544)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m17s
2026-07-21 18:43:05 +00:00
timothy dc130f2526 Merge pull request 'chore(541): fast-forward the shared checkout at session end (H13)' (#543)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 11s
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
2026-07-21 18:23:17 +00:00
timothyandClaude Opus 4.8 0ef053a209 ci(545): require a **Signals:** line on decision records
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m21s
The decisions-guard validator enforced lifecycle metadata (key/status/since/
supersedes/superseded-by + reciprocal links) but not the **Signals:** line —
which is exactly what MemPalace's keyword recall matches on. A record without
it ingests with weak recall metadata and produces confident false-negatives
for the "MemPalace to find, file to confirm" retrieval workflow.

Add "signals" to REQUIRED_META so a migrated record with a missing or empty
**Signals:** line fails the same way a missing key does. All 114 active + 2
archive records already carry a Signals line, so this is non-breaking on the
current corpus. Archive records are intentionally out of scope (recall targets
the active corpus).

- scripts/decisions_validate.py: signals in REQUIRED_META (+ rationale comment)
- scripts/tests/test_decisions_validate.py: _rec() default + missing/empty/present cases
- docs/decisions.md: header + Enforcement note the requirement and why

fixes #545

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:19:10 +02:00
timothy 12e5c3d26f docs(542): record the workflow lore, then prune the kickoff doc to instructions
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
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 13s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Timothy asked why the kickoff handoff doc stores historical narrative when
it should be instructions. It shouldn't — its own lore section is chartered
as "STANDING workflow/orchestration rules only" with the why belonging in
docs/decisions.md. But an inventory of every bullet against the decision
corpus inverted the premise: only ~8 of ~38 were actually covered. 19 had
no record anywhere and 11 more were half-covered, so that single file was
the ONLY copy of the mandatory review rubric, the whole CI-triage
vocabulary, the build-concurrency policy, the H12 session-end audit, and
the plumbing-merge recipe. Pruning first would have destroyed them.

So the records come first. New topic file docs/decisions/workflow-process.md
carries 32 records (ci.*, process.*, testing.*) covering every NONE and
PARTIAL the inventory found, including the Gitea `?milestones=` no-op bug
whose only copy was the archived selector section this prune deletes.

Only then the prune: HARD CONSTRAINTS and the lore section become one- or
two-line rules, each citing the decision key that holds its evidence, and
the 40-line "Archived — do not follow" section is gone. 636 -> 353 lines,
with every cited key verified to resolve against the corpus.

The aggregate corpus budget is re-baselined 4800 -> 5600 with the reason in
the code: the corpus grew because knowledge MOVED into it, which is the
system working, not drift.

refs #542
2026-07-21 20:04:17 +02:00
timothy 92bf8b083d chore(541): fast-forward the shared checkout at session end (H13)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
A session was handed docs/handoffs/chicorytv-issue-queue.md pasted out of
/Users/timothy/ersatztv while that tree was 81 commits behind, so it still
described the queue protocol #520 retired the day before (read tracker
command was ever run against that tree, so every existing "never read its
HEAD" guard was irrelevant: a stale checkout serves stale FILES, and docs
are what a kickoff depends on. Nothing broke only because selection went
through scripts/select-queue.sh.

The lore bullet on that tree already prescribed the shape of the fix for
its earlier failure modes — "a design flaw, not a discipline failure; a
check does not stay true" — so this removes the stale condition instead of
adding another check.

scripts/refresh-shared-checkout.sh fast-forwards the tree to origin/main
and reinstalls web/node_modules when the lockfile moved. It is deliberately
timid: it refuses and changes nothing when the tree is not on main, is
dirty, is ahead, or is mid-rebase/merge, and it never switches branches,
stashes or discards. A NO-OP is a normal outcome.

Uses npm ci rather than npm install — the first version used install,
which rewrote package-lock.json and left the tree dirty, i.e. the exact
state the next run refuses on, so it would have disabled itself after one
use. Asserts the tree is clean at exit.

refs #541
2026-07-21 20:02:57 +02:00
timothy d9eb307d71 Merge pull request 'fix(68): rebuild on-demand channel guide (and mirrors) on thaw' (#540) from fix/68-ondemand-guide-refresh into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 11s
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 31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 32s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m6s
2026-07-21 17:49:14 +00:00
timothyandClaude Opus 4.8 dfed9a393b fix(68): rebuild on-demand channel guide (and mirrors) on thaw
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 27s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m26s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m52s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
An on-demand channel (`PlayoutMode.OnDemand`) already is the "resume where I
left off" feature: `Playout.OnDemandCheckpoint` persists the viewer's position
and `PlayoutTimeShifter.TimeShift` slides the materialized timeline forward on
tune-in so the paused item is active again. Because it rewrites `GuideStart`/
`GuideFinish` alongside `Start`/`Finish`, guide and playback freeze together —
structurally avoiding the free-running-wall-clock desync #68 was filed about.

The one gap: `TimeShift` rewrote the stored `PlayoutItem` rows but the XMLTV
guide is served from a cached fragment that only `RefreshChannelData` rebuilds,
and the tune-in path never enqueued it. So an external EPG client polling after
a thaw could see a stale timeline until the next incidental rebuild.

Fix: `IPlayoutTimeShifter.TimeShift` now returns the channel numbers whose cached
guide is stale — the shifted channel plus any channels that mirror it (the same
fan-out `BuildPlayoutHandler` already does) — and `TimeShiftOnDemandPlayoutHandler`
enqueues a `RefreshChannelData` for each on `CancellationToken.None` (post-commit
side effect must not be abandoned if the session token cancels).

Tests: handler enqueues a rebuild per stale channel (+ mirror + no-shift cases);
`PlayoutTimeShifter` reports source+mirrors on a shift, empty on Continuous /
zero-offset / active-unforced, and correctly seeds+shifts a never-watched playout.
Non-vacuity of the enqueue proven by a compiling negative control.

Docs: channels.md (On-demand resume section), domain-model.md, decisions.md
(scheduling.ondemand-guide-refresh-on-thaw). Per-viewer resume is out of scope
(single per-channel checkpoint; #68 says per-channel suffices).

fixes #68

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:12:42 +02:00
timothy 5e5da9dbd1 Merge pull request 'fix(536): enforce workAheadSegmenterLimit with an atomic slot claim' (#538)
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 7m37s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 17m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
2026-07-21 16:44:11 +00:00
timothy c21d227132 Merge pull request 'ci(535): split PR-only git gates into pr-checks.yml so release tags don't red' (#537) from fix/535-pr-only-workflow-split into main
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 13s
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 29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m22s
2026-07-21 16:31:41 +00:00
timothy 5db0836d41 fix(536): clamp an unbalanced work-ahead release instead of going negative
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 13s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 14s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 18s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Pre-push adversarial review, Low finding. The pool is process-wide and
lives for the life of the app, so a `Release()` not matched by a
successful `TryAcquire` would drive the count to -1 permanently: with a
limit of 1 that silently admits two unthrottled transcodes forever, which
is exactly the #529 QSV pool exhaustion with nothing in the logs to find
it by. Clamp at zero and record the breakage in `UnbalancedReleases`
rather than throwing — the sole caller releases from a `finally`, where a
throw would swallow the real exception.

The hammer tests now also assert `UnbalancedReleases == 0`, so the clamp
cannot mask drift it was added to survive.

Also moves the #536 index line to the end of the in-file decisions index
(it was inserted in the 2026-07-11 block while its body appends at the
end) — review nit, anchors were already correct.

refs #536
2026-07-21 18:23:12 +02:00
timothy c0d3dab190 fix(536): enforce workAheadSegmenterLimit with an atomic slot claim [decisions-edit]
The slot check and its increment straddled an await: `Run` compared
`Volatile.Read(ref _workAheadCount)` against a DB-backed limit, and the
increment happened later inside `Transcode`. Every simultaneous tune-in
therefore observed `0 < limit` and started unthrottled — three concurrent
tunes on prod with a limit of 1 all ran with no `-readrate`. `Interlocked`
on the write side alone buys nothing when the read side is a separate,
earlier load (same class as #231/#250).

Extract the counter into a `WorkAheadSlots` pool whose `TryAcquire(limit)`
claims via compare-exchange, so the count never even transiently exceeds
the limit that the QSV hardware-frame pool sizing (#529) is derived from.
`Run` claims the slot and passes ownership in; `Transcode(bool
ownsWorkAheadSlot, ...)` derives `realtime` from it and releases it in its
existing `finally`, keeping acquire/release one-for-one. Acquisition stays
in the caller because `Run` sets `_state` from the outcome and `Transcode`
reads that state on entry to pick the item start time.

Tests hammer 8 threads x 20k rounds (a single Barrier round does not
collide on this hardware); the documented negative control reinstates the
check-then-act body and produces 15912 over-claiming rounds of 20000.

Also annotates the #350 decision record, whose "every concurrent tune-in
falls back to the throttled path" bullet described the intent rather than
the behaviour.

fixes #536
2026-07-21 18:16:13 +02:00
timothyandClaude Opus 4.8 b4ac46fce1 ci(535): split PR-only git gates into pr-checks.yml so release tags don't red
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m39s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard)
lived in docker-build.yml, which also triggers on push to main and v* tags.
Gitea dispatches a job as a runner task even when its `if` skips it, so on
every tag/main push these three were dispatched to the `small` lane just to
evaluate the skip. On the v26.12.0 tag those dispatched skip-tasks wedged in
act's setup phase and were killed by a runner restart mid-setup, reporting
`failure` (no logs) and reddening the tag's overall commit status even though
the release built, scanned, and deployed fine. The two identical-`if:` jobs on
ubuntu-latest (api-docs, format) skipped cleanly — the job logic was never the
problem; the kill lands in the dispatch window before any step or skip runs, so
tweaking the `if:`/step logic could not fix it.

Relocate exactly those three (pure checkout + git-diff, no container:, no image
pin) verbatim into a dedicated pr-checks.yml that triggers `on: pull_request`
only. Gitea evaluates a workflow's trigger before creating any job, so on a
tag/main push this workflow produces zero jobs: no dispatch, no kill, no
spurious red — for the whole class, permanently.

- ci-image-pin carries no pin and still greps docker-build.yml, where all five
  pin-bearing jobs (test/migrations/functional-e2e/api-docs/format) remain, so
  its validation is unchanged.
- None of the three are required checks (only Build & test + EF migration
  integrity are), so the status-context prefix change (Build ErsatzTV Image / ...
  -> PR Gates / ...) does not affect merges; the merge-consent hook reads the
  prefix-agnostic combined status.
- pr-checks.yml declares `defaults: run: shell: bash` (ci-image-pin uses
  mapfile / set -o pipefail).

docs/ci-cd.md: new "PR gates workflow" section + cross-references.

fixes #535

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:07:56 +02:00
timothy b432892d35 Merge pull request 'docs(524): triage #237's 111 comments — no decision retrofit is owed' (#534) from docs/524-237-retrofit 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 lifecycle (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 15s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7s
Merge pull request 'docs(524): triage #237's 111 comments — no decision retrofit is owed' (#534) from docs/524-237-retrofit into main
2026-07-21 15:35:39 +00:00
timothy f355a4a96b docs(524): triage #237's 111 comments — no decision retrofit is owed
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 10s
Build ErsatzTV Image / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 37s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Exhaustive triage of the closed tracker ersatztv#237, whose 111 comments
server-management#642 excludes from MemPalace ingestion (over the per-file
cap) and #520 removes from startup. #524's premise was that facts living only
in those comments would be orphaned and need curating into lifecycle records.

Result: zero decision-shaped orphans. Every durable decision-shaped fact is
already held by the decision corpus or by the individual issue the comment
narrates -- which the exporter does ingest. The tracker was always the lossy
copy, because the session protocol required the fuller closing record on the
worked issue first.

- docs/decisions.md: new active record docs.tracker-comment-retrofit, leading
  with the reusable rule (check the worked issue BEFORE the decision corpus)
  and the consequence for #642's benchmark row, which has no valid subject.
- docs/tracker-retrofit-triage-237.md: the audit trail -- method, per-comment
  classification of all 111, totals, and the one candidate raised and
  disproved (#497's Guids/Directors scope, stated more fully on its own issue).
- docs/handoffs/chicorytv-issue-queue.md: sweeps the two genuinely orphaned
  LORE facts the triage surfaced (e2e-local.sh readiness probe hanging on a
  reused config dir; troubleshooting playback cannot exercise channel branding).
- docs/README.md: index the new doc.

The claim is deliberately narrow: no *decision-shaped* orphans. The lore bucket
was classified but not coverage-checked, and it was not empty -- hence the sweep.

fixes #524
2026-07-21 17:31:43 +02:00
timothy b52c938888 Merge pull request 'docs(release): v26.12.0 headline — ErsatzTV MCP server (#58) + external-logo download (#525)' (#531) from release/v26.12.0-notes into main
Build ErsatzTV Image / Functional E2E (curl contracts) (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 CI Toolchain Image / Build & push CI image (push) Successful in 2m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m20s
Build ErsatzTV Image / decisions lifecycle (push) Failing after 13m1s
Build ErsatzTV Image / Docs update reminder (push) Failing after 14m13s
Build ErsatzTV Image / CI image pin matches docker/ci (push) Failing after 14m14s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m15s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m25s
2026-07-21 14:49:42 +00:00
285 changed files with 81237 additions and 2108 deletions
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# PreToolUse / Agent — ask when an agent is dispatched without an explicit `model`.
#
# The kickoff prompt (docs/handoffs/chicorytv-issue-queue.md) says to route by capability: cheap/fast
# for bounded recon, mid tier for a mechanical slice against a documented contract, orchestrator tier
# for judgment-heavy work. That rule lived only in prose, and on 2026-07-25 an orchestrator dispatched
# two implementers with `model` omitted — both silently inherited the Opus orchestrator tier. Nothing
# in the session report revealed it; the operator had to ask.
#
# WHY a hook: omitting `model` is the SILENT path. Every other constraint in that kickoff has a hook,
# a CI job or a script behind it, and those were all followed in the same session — the one rule with
# no forcing function was the one that got defaulted. A check that runs beats a rule you must remember
# (the same reasoning as pretooluse-bom-guard.sh).
#
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. The first cut
# tried to be clever: it fired only when the prompt text matched implementer signals (`git commit`,
# `worktree`, `fixes #`…). Review of that version (#583) confirmed the heuristic both over- and
# under-fired — a read-only recon brief mentioning "worktree" nagged, while "author the change and
# open a PR", "land this on the branch" and "make the changes and commit them" all sailed through
# silently, i.e. it missed the exact case it existed to catch. Prompt prose is not a reliable signal
# for authority, and a gate with an unreliable catch rate is worse than an honest one.
#
# Two further reasons the broad form is correct here:
# - The HARD CONSTRAINT itself says "every dispatched agent". A narrower hook contradicted the rule
# it was built to enforce.
# - Routing matters MOST for the cheap cases. The old exemption list ("read-only, so routing barely
# matters") had it backwards: bounded recon is precisely what should be explicitly routed DOWN to
# a fast tier, and that review also showed the premise was false — Explore, Plan and
# claude-code-guide all carry Bash, so none of them provably "cannot commit".
#
# The prompt costs nothing to avoid: name a tier and this never fires. That is the habit being built.
#
# Exempt: `fork` only — a fork ALWAYS inherits the parent model and the tool IGNORES a `model`
# override, so asking would demand something unachievable.
#
# "ask", never "deny": routing is a judgment call with no derivable right answer, unlike the
# merge-consent gate (H6/H10) which derives a verifiable state. This gate exists to make an invisible
# default visible, not to impose a tier.
#
# Fail-open by design: any parse trouble -> allow (exit 0, no output).
set -uo pipefail
input=$(cat)
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null || true)
[ "$tool" = "Agent" ] || exit 0
# An explicit choice was made — nothing to surface. This is the path to prefer.
model=$(printf '%s' "$input" | jq -r '.tool_input.model // ""' 2>/dev/null || true)
[ -z "$model" ] || exit 0
subagent=$(printf '%s' "$input" | jq -r '.tool_input.subagent_type // ""' 2>/dev/null || true)
# A fork's model is fixed to the parent's by the tool; a prompt here could not be acted on.
[ "$subagent" = "fork" ] && exit 0
label="${subagent:-general-purpose}"
reason="Dispatching an agent (subagent_type: ${label}) with no explicit \`model\`.
It will silently inherit this session's model — which may be right, but it is a default, not a choice.
Name the tier (and say so in the dispatch message), per the kickoff routing rule
\`process.per-agent-model-routing\`:
- bounded recon / inventory / log triage -> cheapest fast tier (haiku)
- mechanical slice against a documented contract -> mid tier (sonnet)
- judgment-heavy: design, compiler/parser, security,
migrations, review arbitration -> orchestrator tier (opus)
Independent review should also prefer a DIFFERENT model family than the implementer — a cold
same-family review is worth less than a cross-family one.
Pass \`model\` on the Agent call and this never fires. Approve as-is only if inheriting the
orchestrator tier is the deliberate call."
jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'
exit 0
+5
View File
@@ -39,6 +39,11 @@
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-agent-ram.sh\"",
"timeout": 10
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-agent-model.sh\"",
"timeout": 10
}
]
},
+37
View File
@@ -0,0 +1,37 @@
---
name: closing-an-issue
description: The ersatztv task-completion protocol — the mandatory steps and the `## Closing record` comment template for closing a Gitea issue. Use when finishing a task that closes an issue, or when writing a closing comment. The `/done` command runs this automatically.
---
# Task Completion Protocol
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done.
Use `/done <issue>` to run through this automatically.
Merge consent is a separate, hook-enforced concern — see the `## Done-when` convention in the
root `CLAUDE.md`, which stays always-loaded.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
## `## Closing record` template
Step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for
retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval
contract this feeds.
```markdown
## Closing record
**Outcome:** <what shipped / what didn't; PR link>
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
**Verification:** <tests run, live-E2E, CI status>
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
```
+59 -11
View File
@@ -5,19 +5,55 @@ description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Je
# ErsatzTV Channel Management
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
Web UI: internal only (`http://localhost:8409` via SSH)
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
Host: **jazz (192.168.1.29)**. Prod container `ersatztv` port **8409**; test `ersatztv-test` port
**8410** (tracks `:latest` via Komodo auto-update, daily 03:00 — a same-day validation needs the
manual pull below).
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` (owned by root — use `sudo sqlite3`)
Image: **our fork**, `192.168.1.95:3000/timothy/ersatztv` (`:prod` / `:latest`). Upstream
`ghcr.io/ersatztv/ersatztv` was archived at v26.3.0 and is NOT what runs here.
## Architecture
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
- **POST endpoints**: library scan, playout reset, show scan
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
**This section described upstream v26.3.0 and was wrong for the fork — corrected 2026-07-21.**
## REST API
- The **Blazor UI is gone** (#91 phase b). The only UI is the ChicoryTV React SPA at `/app`; legacy
routes 302 there.
- There **is** a full versioned REST API under **`/api/v1`**, write paths included — channels,
collections, schedules, playouts and media sources have CRUD. **Do not hand-edit SQLite for
something the API can do.** The DB-scripting recipes below survive only for gaps with no endpoint.
- Controllers stay thin and delegate to MediatR handlers; the SPA talks to `/api/v1` only.
- Authoritative endpoint list: `docs/endpoint-index.md` (generated) + `docs/api-conventions.md`.
Prefer those over any list in this file — a hand-maintained copy drifts.
## REST API access (auth-gated — read before curling)
Calls need **`X-Api-Key`** (machine clients) or a browser session. An unauthenticated call returns a
401 JSON body that is easy to mistake for real data — see the silent-401 trap in Gotchas.
The key file is **root-owned `0600`**, so `cat` as `timothy` fails *silently* and yields an empty
header. Read it with `sudo`, inline, so the value is never printed:
```bash
# prod (8409); test is identical with .../ersatztv-test/api.key and port 8410
ssh timothy@192.168.1.29 'K=$(sudo -n cat /home/timothy/downloadswarm/ersatztv/api.key); \
curl -s -H "X-Api-Key: $K" http://localhost:8409/api/v1/channels'
```
Settings live under `/api/v1/settings/*``settings/ffmpeg` (`workAheadSegmenterLimit`,
`qsvExtraHardwareFrames`) and `settings/logging` (`streamingMinimumLogLevel`). Note the order: it is
`settings/ffmpeg`, **not** `ffmpeg/settings`.
Refresh test to the newest `:latest` without waiting for 03:00 — scope it to the service, since a
bare `up -d` would recreate everything else in the compose project:
```bash
D=/etc/komodo/stacks/ersatztv/docker/jazz/stacks/ersatztv
docker compose -f $D/compose.yaml pull ersatztv-test
docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
```
The unversioned `/api/*` endpoints below predate the `/api/v1` surface — verify one against
`docs/endpoint-index.md` before relying on it.
```bash
# Via docker exec
@@ -148,13 +184,25 @@ After creating: `POST /api/channels/{number}/playout/reset`
## Gotchas
- DB owned by root — always use `sudo sqlite3`
- **The api.key file is root-owned too, and an unsudo'd read fails SILENTLY.** `cat` returns nothing,
the header goes out empty, and the 401 body parses as a dict — so a naive script reports "0
channels" rather than an auth error. If a query returns a suspiciously empty result, check auth
before believing it. (Cost a wrong reading on 2026-07-21.)
- WAL mode: reads OK while running, stop container for writes
- No REST API for channel/collection/schedule CRUD — DB scripting only
- ~~No REST API for channel/collection/schedule CRUD~~**false since the fork's `/api/v1`**; use the
API, not DB scripting, wherever an endpoint exists
- **A container's OCI labels lie about what is running** — they are inherited from the linuxserver
base image (they claimed `2026-06-27` on an image built minutes earlier). To prove which build is
live, compare `docker inspect <c> --format '{{.Image}}'` to the registry's `Docker-Content-Digest`
for that tag
- **Container log lines carry a LOCAL-time bracket (`[18:48:13 DBG]`) while `docker logs -t` emits
UTC**, so `--since` windows silently mis-slice. For before/after measurements capture by line
offset instead (`wc -l` before, `tail -n +N` after)
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
- `/api/health` predates the Blazor removal; verify the API with an authenticated `/api/v1/channels` instead
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
+16 -1
View File
@@ -97,7 +97,22 @@ Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/Virt
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
- Music videos are typed as "Movie" in Jellyfin
- **Music videos are typed `MusicVideo`, NOT `Movie`** (corrected 2026-07-21, ersatztv#177). The old
"typed as Movie" note described a deliberate DB reclassification workaround that existed only because
ErsatzTV could not consume `MusicVideo` items — ersatztv#42 shipped that sync, so the workaround's
premise is gone. Verified live: the `Music Videos` library (`/data/music`, collection type
`musicvideos`) holds 1437 items typed `MusicVideo` and **zero** typed `Movie`. Query with
`includeItemTypes=MusicVideo`. (Reclassification to `Movie` may still apply to concert/standup content
in the `movies`/`mixed` libraries — that is a different set; see the server-management jellyfin skill.)
- **`Album` is not an `ItemFields` value.** It is a plain `BaseItemDto` property serialized whenever set,
so it comes back regardless of the `fields=` query param — do NOT add it to `fields` (verified: 111 of
1437 music videos returned `Album` with `fields=Path` alone). Contrast `Genres`/`People`/`Chapters`,
which ARE `ItemFields` and must be requested. Check the enum before extending `fields`.
- **`IndexNumber` is the track number; `ParentIndexNumber` is the disc/season axis.** Frequency misleads
here — on the live music video library `ParentIndexNumber` is populated on 66 items vs 4 for
`IndexNumber`, but where both exist `ParentIndexNumber` is `1` while `IndexNumber` holds the real
ordinal, and where only `ParentIndexNumber` exists it is a collection grouping tracking the album
(`Glastonbury: 2022` -> 230). `AlbumId` is always null on these items.
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
- Items return 404 on stream if source volume is unmounted
- Jellyfin preserves item IDs across restarts unless files are renamed
+32 -120
View File
@@ -6,6 +6,10 @@ name: Build ErsatzTV Image
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
#
# The PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard) live in the sibling
# .gitea/workflows/pr-checks.yml (`on: pull_request`). They were split out of this file so they
# are not dispatched-and-killed on a tag/main push (ersatztv#535 — see that file's header).
#
# Runner + registry provisioned in server-management#172. The Gitea registry is
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
# inherit the host daemon's insecure-registries setting).
@@ -25,7 +29,7 @@ name: Build ErsatzTV Image
# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md ->
# "CI toolchain image" for the two-step procedure.
#
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
#
# 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
@@ -103,7 +107,7 @@ jobs:
name: Build & test (.NET)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -258,7 +262,7 @@ jobs:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -405,7 +409,7 @@ jobs:
echo "::endgroup::"
functional-e2e:
name: Functional E2E (curl contracts)
name: Functional E2E (curl + UI contracts)
runs-on: ubuntu-latest
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
# flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
@@ -417,7 +421,7 @@ jobs:
# v* tag builds.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -494,6 +498,27 @@ jobs:
trap 'kill "$PID" 2>/dev/null || true' EXIT
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
# ersatztv#445: the UI-interactive flows the curl harness structurally CANNOT express —
# client-side form validation, AuthGate's rendered states, the session cookie authenticating the
# SPA's own /api XHRs, and sign-out through the UserMenu.
#
# Why in THIS job rather than its own: the dominant cost here is `npm ci` + the Release build,
# which are already done above. A separate job would duplicate both to add ~5s of browser work.
# The browser itself is baked into the toolchain image (docker/ci/Dockerfile —
# chromium-headless-shell), so this step installs nothing.
#
# It boots its OWN fresh instance on a DIFFERENT port: the first spec asserts the one-shot Setup
# gate, which the curl harness's auth section has already claimed on its own config dir, and a
# separate port keeps this independent of the previous step's teardown timing.
- name: Run UI-E2E Playwright flows (headless)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8410
# e2e-ui.sh owns the whole lifecycle: fresh config dir, boot, run specs, always kill the
# server. Its exit status is Playwright's.
scripts/e2e-ui.sh
build:
name: Build & push image (amd64)
# Moved back off `small` (server-management#639). This is the one HEAVY job that
@@ -642,119 +667,6 @@ jobs:
exit 1
fi
# BLOCKING (ersatztv#390): the CI toolchain image pin in this file must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
# vanishing from the active set without an archive copy) and that the generated active catalog
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
decisions-guard:
name: decisions lifecycle
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Validate decision lifecycle
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- name: Kickoff guard
run: bash scripts/check-kickoff-guard.sh
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
# surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated
@@ -784,7 +696,7 @@ jobs:
# 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source.
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -879,7 +791,7 @@ jobs:
# move to a lighter lane is a server-management capacity call (#604).
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
+188
View File
@@ -0,0 +1,188 @@
name: PR Gates
# Fast, git-only PR gates split out of docker-build.yml into a dedicated `on: pull_request`
# workflow (ersatztv#535) so they are NEVER created on a tag/main push.
#
# WHY THIS FILE EXISTS. These three checks are pure `checkout + git diff` gates: they carry no
# `container:`, run on the `small` lane (git-only, 1 GiB; server-management#639), and are PR-only.
# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags —
# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because
# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the
# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase
# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened
# the tag's overall commit status even though the release built, scanned, and deployed fine
# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical
# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens
# in the dispatch window before any step or `if:`-skip runs.
#
# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow
# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole
# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders
# (they also encode "these steps need a PR base_ref"; harmless given the trigger).
#
# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of
# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/
# api-docs/format) that remain there. None of these three are required checks — branch protection
# requires only `Build & test (.NET)` and `EF migration integrity` — so relocating them (which
# changes their status-context prefix from "Build ErsatzTV Image / …" to "PR Gates / …") does not
# affect merges. See docs/ci-cd.md -> "PR gates workflow".
on:
pull_request:
# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but
# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash.
defaults:
run:
shell: bash
# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel.
concurrency:
group: ersatztv-pr-gates-${{ github.ref }}
cancel-in-progress: true
jobs:
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -eq 0 ]; then
echo "::error::No ersatztv-ci pin found in docker-build.yml at all. Every container: job must pin ersatztv-ci:<7-char-sha>; if the grep pattern stopped matching, fix it here too (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
# LENGTH is a separate invariant from CORRECTNESS, and only this check covers it
# (ersatztv#594). The resolve + staleness checks below compare RESOLVED shas, so a
# 8/9/10-char abbreviation of the right commit sails through them green — while
# matching NO tag in the registry, because ci-image.yml tags with
# `git rev-parse --short HEAD` under `fetch-depth: 1`, which always yields exactly 7.
# The failure would otherwise surface far downstream as all five `container:` jobs
# dying at image-pull with `manifest unknown`, which reads like a registry outage.
# This is an easy mistake to make: the natural local command prints 8 chars.
#
# Deliberately a literal 7, not a derived `git rev-parse --short=7`: in this full
# clone git may widen an ambiguous abbreviation past 7, which would demand a pin
# ci-image.yml can never publish — the exact clone-depth asymmetry noted above.
# `${expected:0:7}` is plain string truncation, so it is safe to suggest.
#
# ESCAPE HATCH, if you are ever stuck: this makes 7 mandatory, so if `${expected:0:7}` ever
# became an AMBIGUOUS prefix (two objects sharing it), the resolve check below would fail
# and a longer pin — previously the workaround — is now rejected here first. There is no
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
# empirical property of today's shallow clone, not an enforced invariant. Making the
# publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of
# scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs
# the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') —
# ci-image.yml's push trigger has no branches: filter, so a feature branch does publish.
if [ "${#pins[0]}" -ne 7 ]; then
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
# vanishing from the active set without an archive copy) and that the generated active catalog
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
decisions-guard:
name: decisions lifecycle
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Validate decision lifecycle
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- name: Kickoff guard
run: bash scripts/check-kickoff-guard.sh
+17 -1
View File
@@ -46,7 +46,19 @@ msbuild.wrn
.vs/
*.sqlite3*
core
# Core dumps. MUST stay anchored/qualified (ersatztv#485): a bare `core` matches any path
# component named `core`, and on a case-insensitive filesystem (macOS default) that includes
# every `*/Core/` source directory — silently excluding NEW files under e.g.
# ErsatzTV.Scanner/Core/ from `git add -A`. Tracked files are unaffected, so the symptom is a
# clean local build and a CI checkout that fails to compile.
#
# Both patterns are anchored to the repo root ON PURPOSE — an unanchored `core.[0-9]*` would
# re-introduce exactly the silent-exclusion class this fixes. Tradeoff, accepted: a dump written
# into a SUBdirectory is no longer ignored (the old bare `core` did catch those). In practice the
# processes that could drop one, run from the repo root or from `bin/` — and `[Bb]in/` already covers
# the latter. An un-ignored dump is visible noise; a wrongly-ignored source file is not.
/core
/core.[0-9]*
scripts/generate-api-sdk/swagger.json
scripts/download-test-content.sh
@@ -61,6 +73,10 @@ web/node_modules
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
/*.png
.playwright-mcp/
# UI-E2E run artifacts: traces/screenshots Playwright writes on failure (outputDir in
# web/playwright.config.ts), plus the report dir it would use if a reporter is ever added (#445).
web/e2e/.output/
web/playwright-report/
# Per-session worktree-ownership marker (H7, ersatztv#303) — local, never committed
.claude-worktree-owner
+3 -42
View File
@@ -4,25 +4,9 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture
- **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root `/` and every legacy route now 302 to `/app`, either via an explicit redirect in `ErsatzTV/LegacyUiRedirects.cs` or the Startup catch-all fallback (any unmatched non-`/api`/`/artwork`/`/docs`/`/openapi` path → `/app`). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE.
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
- **Functional C#**: Language Ext (Option, Either monads throughout)
### Project Layout
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, DI setup |
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
| `ErsatzTV.Scanner/` | Media library scanning |
### Key Files
@@ -43,12 +27,6 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Development
```bash
# Build
dotnet build ErsatzTV.sln
# Run locally (needs FFmpeg in PATH)
dotnet run --project ErsatzTV
# Docker build
docker build -f docker/Dockerfile -t ersatztv:dev .
```
@@ -88,26 +66,9 @@ Every task that closes a Gitea issue MUST complete ALL of these before it is con
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
**`## Closing record` template** (step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval contract this feeds):
```markdown
## Closing record
**Outcome:** <what shipped / what didn't; PR link>
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
**Verification:** <tests run, live-E2E, CI status>
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
```
**The 7 mandatory completion steps and the `## Closing record` comment template** live in the
`closing-an-issue` skill (`.claude/skills/closing-an-issue/SKILL.md`) — invoke it (or `/done`)
when finishing a task that closes an issue.
## Project Boundaries
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
@@ -43,7 +43,8 @@ public record CreateChannelFromLineupAdvancedOptions(
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
IReadOnlyList<CreateChannelFromLineupClearField> Clear = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
@@ -190,11 +190,21 @@ public class CreateChannelFromLineupHandler(
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
// "clear to none" (#135): a field named in advanced.Clear is forced to none even when the
// template sets one; both setting and clearing the same field is contradictory.
Either<BaseError, Unit> clearValidation = ValidateClear(advanced);
foreach (BaseError error in clearValidation.LeftToSeq())
{
return error;
}
ResolvedClearableOptions resolved = ResolveClearable(advanced, template);
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
int? fallbackFillerId = resolved.FallbackFillerId;
int? preRollFillerId = resolved.PreRollFillerId;
int? midRollFillerId = resolved.MidRollFillerId;
int? postRollFillerId = resolved.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
@@ -207,8 +217,8 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
advanced,
template,
ffmpegProfileId,
resolved,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
@@ -272,6 +282,7 @@ public class CreateChannelFromLineupHandler(
request,
template,
advanced,
resolved,
name,
number,
group,
@@ -291,6 +302,7 @@ public class CreateChannelFromLineupHandler(
playbackOrder,
advanced,
template,
resolved,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
@@ -383,6 +395,7 @@ public class CreateChannelFromLineupHandler(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
ResolvedClearableOptions resolved,
string name,
string number,
string group,
@@ -421,16 +434,14 @@ public class CreateChannelFromLineupHandler(
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
WatermarkId = resolved.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
@@ -439,7 +450,8 @@ public class CreateChannelFromLineupHandler(
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
ShowInEpg = request.IsEnabled && request.ShowInEpg,
Origin = ChannelOrigin.AutoTuned
};
}
@@ -462,6 +474,7 @@ public class CreateChannelFromLineupHandler(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
ResolvedClearableOptions resolved,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
@@ -478,11 +491,9 @@ public class CreateChannelFromLineupHandler(
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
@@ -526,20 +537,21 @@ public class CreateChannelFromLineupHandler(
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
int ffmpegProfileId,
ResolvedClearableOptions resolved,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
// Validate the post-clear effective ids: a cleared reference resolves to null and skips the
// existence check (there is nothing to point at).
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
resolved.WatermarkId,
resolved.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
@@ -548,9 +560,9 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
resolved.PreRollFillerId,
resolved.MidRollFillerId,
resolved.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
@@ -560,6 +572,80 @@ public class CreateChannelFromLineupHandler(
return Unit.Default;
}
// A field named in advanced.Clear must not also carry a set value: that request is contradictory.
// A null/empty set value alongside a clear is fine (redundant, not conflicting). (#135)
private static Either<BaseError, Unit> ValidateClear(CreateChannelFromLineupAdvancedOptions advanced)
{
if (advanced.Clear is null || advanced.Clear.Count == 0)
{
return Unit.Default;
}
var cleared = advanced.Clear.ToHashSet();
(CreateChannelFromLineupClearField Field, bool HasSetValue)[] checks =
[
(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId.HasValue),
(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId.HasValue),
(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId.HasValue),
(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId.HasValue),
(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId.HasValue),
(CreateChannelFromLineupClearField.PreferredAudioLanguage,
!string.IsNullOrEmpty(advanced.PreferredAudioLanguageCode)),
(CreateChannelFromLineupClearField.PreferredAudioTitle,
!string.IsNullOrEmpty(advanced.PreferredAudioTitle)),
(CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
!string.IsNullOrEmpty(advanced.PreferredSubtitleLanguageCode))
];
foreach ((CreateChannelFromLineupClearField field, bool hasSetValue) in checks)
{
if (cleared.Contains(field) && hasSetValue)
{
return BaseError.New(
$"Advanced option '{field}' cannot be both set and cleared in the same request");
}
}
return Unit.Default;
}
// Compute the effective value of every clearable field once: cleared -> none, else the advanced
// override coalesced with the template value (the historical omitted=inherit contract). (#135)
private static ResolvedClearableOptions ResolveClearable(
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template)
{
System.Collections.Generic.HashSet<CreateChannelFromLineupClearField> cleared = advanced.Clear is null
? []
: advanced.Clear.ToHashSet();
int? Id(CreateChannelFromLineupClearField field, int? adv, int? tmpl) =>
cleared.Contains(field) ? null : adv ?? tmpl;
string Str(CreateChannelFromLineupClearField field, string adv, string tmpl) =>
cleared.Contains(field) ? string.Empty : adv ?? tmpl ?? string.Empty;
return new ResolvedClearableOptions(
Id(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId, template.WatermarkId),
Id(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId, template.FallbackFillerId),
Id(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId, template.PreRollFillerId),
Id(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId, template.MidRollFillerId),
Id(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId, template.PostRollFillerId),
Str(
CreateChannelFromLineupClearField.PreferredAudioLanguage,
advanced.PreferredAudioLanguageCode,
template.PreferredAudioLanguageCode),
Str(
CreateChannelFromLineupClearField.PreferredAudioTitle,
advanced.PreferredAudioTitle,
template.PreferredAudioTitle),
Str(
CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
advanced.PreferredSubtitleLanguageCode,
template.PreferredSubtitleLanguageCode));
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
@@ -803,4 +889,16 @@ public class CreateChannelFromLineupHandler(
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
// Effective values for the clearable advanced fields after applying advanced.Clear + template
// coalescing (#135). Strings coalesce to string.Empty (never null); ids stay nullable.
private sealed record ResolvedClearableOptions(
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode);
}
@@ -152,7 +152,8 @@ public class CreateChannelHandler(
TranscodeMode = request.TranscodeMode,
IdleBehavior = request.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
ShowInEpg = request.IsEnabled && request.ShowInEpg,
Origin = ChannelOrigin.UserCreated
};
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -32,4 +32,5 @@ public record UpdateChannel(
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
bool ShowInEpg,
List<int> GraphicsElementIds) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -34,6 +34,7 @@ public class UpdateChannelHandler(
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.Playouts)
.Include(c => c.ChannelGraphicsElements)
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
return await maybeChannel.Match(
@@ -173,6 +174,14 @@ public class UpdateChannelHandler(
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
c.ChannelGraphicsElements ??= [];
var desired = update.GraphicsElementIds?.Distinct().ToList() ?? [];
c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId));
foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id)))
{
c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id });
}
await dbContext.SaveChangesAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
+170 -4
View File
@@ -29,6 +29,106 @@ internal static class Mapper
return result;
}
internal static ChannelHealthResponseModel GetHealth(
Channel channel,
int playoutCount,
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
{
if (playoutCount == 0)
{
return new ChannelHealthResponseModel(
ChannelHealthStatus.Problems,
[ChannelFault.NoPlayout],
0,
0);
}
var faults = new System.Collections.Generic.HashSet<string>();
var brokenSourceItemCount = 0;
var sawAssessable = false;
foreach ((Playout playout, ChannelPlayoutMode ownerMode) in ContributingPlayoutsWithOwnerMode(channel))
{
bool isOnDemand = ownerMode == ChannelPlayoutMode.OnDemand;
upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u);
brokenSourceItemCount += u.BrokenUpcoming;
bool built = playout.BuildStatus is not null && playout.BuildStatus.LastBuild != default;
// Presence signals — always live.
if (built && playout.BuildStatus.Success == false)
{
faults.Add(ChannelFault.BuildFailed);
}
if (u.BrokenUpcoming > 0)
{
faults.Add(ChannelFault.BrokenSource);
}
// Absence signals — suppressed for on-demand (drains between tune-ins).
if (!isOnDemand)
{
if (!built)
{
faults.Add(ChannelFault.NeverBuilt);
}
else if (u.TotalUpcoming == 0)
{
faults.Add(ChannelFault.EmptyUpcoming);
}
else
{
sawAssessable = true;
}
}
else if (built && u.TotalUpcoming > 0)
{
sawAssessable = true;
}
}
string status = faults.Count > 0
? ChannelHealthStatus.Problems
: sawAssessable
? ChannelHealthStatus.Healthy
: ChannelHealthStatus.Unknown;
return new ChannelHealthResponseModel(
status,
faults.ToArray(),
playoutCount,
brokenSourceItemCount);
}
internal static IEnumerable<Playout> ContributingPlayouts(Channel channel) =>
ContributingPlayoutsWithOwnerMode(channel).Select(x => x.Playout);
// Mirror channels are forced Continuous (UpdateChannelHandler), but a mirror of an on-demand SOURCE relays
// playouts that legitimately drain between tune-ins. Absence-signal suppression must key off the mode of the
// channel that OWNS each playout, not the mirror's own (always-Continuous) mode — so pair each playout with
// its owner's mode here, once, rather than re-deriving it at each call site.
private static IEnumerable<(Playout Playout, ChannelPlayoutMode OwnerMode)> ContributingPlayoutsWithOwnerMode(
Channel channel)
{
if (channel.Playouts is not null)
{
foreach (Playout p in channel.Playouts)
{
yield return (p, channel.PlayoutMode);
}
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
{
foreach (Playout p in channel.MirrorSourceChannel.Playouts)
{
yield return (p, channel.MirrorSourceChannel.PlayoutMode);
}
}
}
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
new(
channel.Id,
@@ -61,7 +161,10 @@ internal static class Mapper
channel.IsEnabled,
channel.ShowInEpg);
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(Channel channel, int playoutCount)
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(
Channel channel,
int playoutCount,
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
{
ArtworkContentTypeModel logo = GetLogo(channel);
return new ChannelDetailResponseModel(
@@ -93,10 +196,15 @@ internal static class Mapper
channel.TranscodeMode,
channel.IdleBehavior,
channel.IsEnabled,
channel.ShowInEpg);
channel.ShowInEpg,
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? [],
GetHealth(channel, playoutCount, upcoming));
}
internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) =>
internal static ChannelResponseModel ProjectToResponseModel(
Channel channel,
int playoutCount,
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming) =>
new(
channel.Id,
channel.Number,
@@ -110,7 +218,10 @@ internal static class Mapper
channel.IsEnabled,
channel.ShowInEpg,
playoutCount,
GetLogoUrl(channel));
GetLogoUrl(channel),
GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount),
channel.Origin,
GetHealth(channel, playoutCount, upcoming));
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -174,4 +285,59 @@ internal static class Mapper
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};
#nullable enable
internal static ChannelPreviewResponseModel GetPreview(
StreamingMode streamingMode,
string channelNumber,
bool isEnabled,
int playoutCount)
{
// Precedence among the two Unavailable causes (checked in this order; the first match wins):
// 1. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
// preview must not even try.
// 2. no playout — the channel could theoretically play once scheduled, but a manifest
// request against it blocks indefinitely today; catch it before that happens.
//
// IPTV JWT auth (ConditionalIptvAuthorizeFilter, active only when JWT:IssuerSigningKey is set) is no
// longer an Unavailable cause: the SPA mints a short-lived token via GET /api/v1/auth/iptv-token and
// appends it as ?access_token= to the manifest URL below (issue #552). The token is global and the
// ManifestUrl is identical with or without JWT, so this projection is JWT-agnostic.
if (!isEnabled)
{
return new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Unavailable,
null,
"Channel is disabled");
}
if (playoutCount == 0)
{
return new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Unavailable,
null,
"Channel has no playout");
}
return streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter or StreamingMode.HttpLiveStreamingDirect =>
new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Available,
$"/iptv/channel/{channelNumber}.m3u8",
null),
// A browser cannot play video/mp2t. Forcing ?mode=segmenter yields a playable stream,
// but one that does not exercise the channel's configured Transport Stream pipeline —
// the SPA labels this result accordingly.
StreamingMode.TransportStream or StreamingMode.TransportStreamHybrid =>
new ChannelPreviewResponseModel(
ChannelPreviewAvailability.ForcedHlsOnly,
$"/iptv/channel/{channelNumber}.m3u8?mode=segmenter",
null),
_ => throw new ArgumentOutOfRangeException(nameof(streamingMode))
};
}
#nullable restore
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
@@ -12,7 +12,13 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
GetAllChannelsForApi request,
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
List<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten().ToList();
var playoutIds = channels
.SelectMany(c => ContributingPlayouts(c).Select(p => p.Id))
.Distinct()
.ToList();
Dictionary<int, PlayoutUpcoming> upcoming =
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList();
}
}
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
@@ -7,9 +8,20 @@ namespace ErsatzTV.Application.Channels;
public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByIdForApi, Option<ChannelDetailResponseModel>>
{
public Task<Option<ChannelDetailResponseModel>> Handle(
public async Task<Option<ChannelDetailResponseModel>> Handle(
GetChannelByIdForApi request,
CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel)));
CancellationToken cancellationToken)
{
Option<Channel> maybeChannel = await channelRepository.GetChannel(request.Id);
foreach (Channel channel in maybeChannel)
{
var playoutIds = ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList();
Dictionary<int, PlayoutUpcoming> upcoming =
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming);
}
return Option<ChannelDetailResponseModel>.None;
}
}
@@ -60,10 +60,11 @@ public partial class GetChannelGuideHandler(
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(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)}";
// The token lands in a URL query value inside an XMLTV attribute, so it needs BOTH layers:
// percent-encode first (#421 — a token with '&' would otherwise split the query and truncate
// the token once the consumer URL-decodes the attribute; mirrors the M3U fix), then XML-escape
// the result so it can't malform the guide (#376). Both are no-ops for an opaque base64url token.
accessTokenUri += $"&amp;access_token={SecurityElement.Escape(Uri.EscapeDataString(request.AccessToken))}";
}
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Graphics.Mapper;
@@ -18,10 +19,14 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
.AsNoTracking()
.ToListAsync(cancellationToken);
return graphicsElements
.Map(ProjectToViewModel)
.OrderBy(e => e.Name == e.FileName)
.ThenBy(e => e.Name)
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
.Select(e => new
{
Vm = ProjectToViewModel(e),
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
})
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
.ThenBy(x => x.Vm.Name)
.Select(x => new GraphicsElementResponseModel(x.Vm.Id, x.Vm.Name, x.BuiltIn))
.ToList();
}
}
@@ -35,7 +35,8 @@ public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFa
CancellationToken cancellationToken) =>
PlaylistGroupMustExist(dbContext, request, cancellationToken)
.BindT(PlaylistGroupMustNotBeSystem)
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup));
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup))
.BindT(playlistGroup => NameMustBeUnique(dbContext, request, playlistGroup));
private static Task<Validation<BaseError, PlaylistGroup>> PlaylistGroupMustExist(
TvContext dbContext,
@@ -57,4 +58,23 @@ public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFa
private static Validation<BaseError, string> ValidateName(RenamePlaylistGroup request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
// Issue #458: PlaylistGroup.Name carries a global unique index, but CreatePlaylistGroupHandler
// has no explicit duplicate guard (it relies on the DB constraint). Add one on rename so a
// collision surfaces as a clean 422 rather than a raw DbUpdateException. Excludes the group
// itself so a no-op rename to its own name still succeeds.
private static async Task<Validation<BaseError, PlaylistGroup>> NameMustBeUnique(
TvContext dbContext,
RenamePlaylistGroup request,
PlaylistGroup playlistGroup)
{
Option<PlaylistGroup> maybeExisting = await dbContext.PlaylistGroups
.AsNoTracking()
.FirstOrDefaultAsync(pg => pg.Id != request.PlaylistGroupId && pg.Name == request.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"A playlist group named \"{request.Name}\" already exists")
: Success<BaseError, PlaylistGroup>(playlistGroup);
}
}
@@ -75,12 +75,33 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
.BindT(playlist => ValidateName(request).Map(_ => playlist));
.BindT(playlist => ValidateName(request).Map(_ => playlist))
.BindT(playlist => PlaylistNameMustBeUnique(dbContext, playlist, request));
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
// Issue #458: mirror CreatePlaylistHandler's duplicate-name guard on rename. Uniqueness is scoped
// to the loaded playlist's group (rename cannot move groups) and excludes the playlist itself, so
// a no-op rename to its own name still succeeds. Backstopped by the (PlaylistGroupId, Name) unique
// index; this pre-check turns the common collision into a clean 422 instead of a DbUpdateException.
private static async Task<Validation<BaseError, Playlist>> PlaylistNameMustBeUnique(
TvContext dbContext,
Playlist playlist,
ReplacePlaylistItems request)
{
Option<Playlist> maybeExisting = await dbContext.Playlists
.AsNoTracking()
.FirstOrDefaultAsync(p =>
p.Id != request.PlaylistId && p.PlaylistGroupId == playlist.PlaylistGroupId && p.Name == request.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"A playlist named \"{request.Name}\" already exists in that playlist group")
: Success<BaseError, Playlist>(playlist);
}
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
ReplacePlaylistItems request,
Playlist playlist) =>
@@ -1,16 +1,35 @@
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Application.Playouts;
public class TimeShiftOnDemandPlayoutHandler(IPlayoutTimeShifter playoutTimeShifter)
public class TimeShiftOnDemandPlayoutHandler(
IPlayoutTimeShifter playoutTimeShifter,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<TimeShiftOnDemandPlayout, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
{
try
{
await playoutTimeShifter.TimeShift(request.PlayoutId, request.Now, request.Force, cancellationToken);
List<string> staleGuideChannels = await playoutTimeShifter.TimeShift(
request.PlayoutId,
request.Now,
request.Force,
cancellationToken);
// the time shift rewrote stored PlayoutItem timestamps but not the cached XMLTV
// fragment; rebuild the guide for the shifted channel (and any mirrors of it) so a
// client tuning in doesn't see a stale timeline. this is a post-commit side effect
// (TimeShift already saved) so it runs on CancellationToken.None — a session token that
// cancels between the DB commit and this enqueue must not leave the guide stale
// (decisions.md api.postcommit-cancellation-none)
foreach (string channelNumber in staleGuideChannels)
{
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), CancellationToken.None);
}
}
catch (Exception ex)
{
@@ -58,6 +58,7 @@ public class
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
@@ -50,6 +50,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
@@ -53,6 +53,7 @@ public class
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
@@ -58,6 +58,7 @@ public class
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
+1
View File
@@ -11,6 +11,7 @@ internal static class Mapper
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramScheduleId == null ? string.Empty : playout.ProgramSchedule.Name,
playout.ScheduleFile,
@@ -7,6 +7,7 @@ public record PlayoutNameViewModel(
PlayoutScheduleKind ScheduleKind,
string ChannelName,
string ChannelNumber,
int ChannelId,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string ScheduleFile,
@@ -24,6 +24,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
p.ScheduleKind,
p.Channel.Name,
p.Channel.Number,
p.Channel.Id,
p.Channel.PlayoutMode,
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
p.ScheduleFile,
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -9,4 +9,5 @@ public record CreateProgramSchedule(
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -40,7 +40,8 @@ public class CreateProgramScheduleHandler(IDbContextFactory<TvContext> dbContext
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
ShuffleScheduleItems = request.ShuffleScheduleItems,
RandomStartPoint = request.RandomStartPoint,
FixedStartTimeBehavior = request.FixedStartTimeBehavior
FixedStartTimeBehavior = request.FixedStartTimeBehavior,
PadToNearestMinute = request.PadToNearestMinute is int m && m > 0 ? m : null
};
});
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -10,4 +10,5 @@ public record UpdateProgramSchedule(
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
@@ -40,12 +40,15 @@ public class UpdateProgramScheduleHandler(
CancellationToken cancellationToken)
{
// we need to refresh playouts if the playback order or keep multi-episodes has been modified
int? normalizedPad = request.PadToNearestMinute is int upm && upm > 0 ? upm : null;
bool needToRefreshPlayout =
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
programSchedule.RandomStartPoint != request.RandomStartPoint ||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior;
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior ||
programSchedule.PadToNearestMinute != normalizedPad;
programSchedule.Name = request.Name;
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
@@ -54,6 +57,7 @@ public class UpdateProgramScheduleHandler(
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
programSchedule.RandomStartPoint = request.RandomStartPoint;
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
programSchedule.PadToNearestMinute = normalizedPad;
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253).
// Force-write past a concurrent Version bump (e.g. a parallel schedule-items replace) instead of
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -13,6 +13,7 @@ internal static class Mapper
programSchedule.ShuffleScheduleItems,
programSchedule.RandomStartPoint,
programSchedule.FixedStartTimeBehavior,
programSchedule.PadToNearestMinute,
programSchedule.Version);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -10,4 +10,5 @@ public record ProgramScheduleViewModel(
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute,
int Version);
@@ -1,4 +1,4 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -20,6 +20,7 @@ public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContex
ps.ShuffleScheduleItems,
ps.RandomStartPoint,
ps.FixedStartTimeBehavior,
ps.PadToNearestMinute,
ps.Version))
.ToListAsync(cancellationToken);
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public record GetSearchFieldValues(string Name, string Query, int Limit)
: IRequest<Option<SearchFieldValuesResponseModel>>;
@@ -0,0 +1,120 @@
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSearchFieldValues, Option<SearchFieldValuesResponseModel>>
{
private const int DefaultLimit = 50;
private const int MaxLimit = 50;
public async Task<Option<SearchFieldValuesResponseModel>> Handle(
GetSearchFieldValues request,
CancellationToken cancellationToken)
{
SearchFieldResponseModel field = SearchFieldCatalog.Fields
.FirstOrDefault(f => f.Name == request.Name);
if (field is null || field.Type != "text")
{
return Option<SearchFieldValuesResponseModel>.None;
}
int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit);
string qLower = (request.Query ?? string.Empty).ToLower();
// in-memory special cases (no DB query needed)
switch (request.Name)
{
case "state":
return new SearchFieldValuesResponseModel(
FilterSortTake(Enum.GetNames<MediaItemState>(), qLower, limit));
case "video_dynamic_range":
return new SearchFieldValuesResponseModel(
FilterSortTake(["hdr", "sdr"], qLower, limit));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
if (request.Name == "content_rating")
{
return new SearchFieldValuesResponseModel(
await GetContentRatingValues(dbContext, qLower, limit, cancellationToken));
}
IQueryable<string> source = GetSource(dbContext, request.Name);
if (source is null)
{
return Option<SearchFieldValuesResponseModel>.None;
}
List<string> values = await source
.Where(v => v != null && v.ToLower().StartsWith(qLower))
.Distinct()
.OrderBy(v => v)
.Take(limit)
.ToListAsync(cancellationToken);
return new SearchFieldValuesResponseModel(values);
}
private static IQueryable<string> GetSource(TvContext dbContext, string name) => name switch
{
"genre" or "show_genre" => dbContext.Set<Genre>().Select(g => g.Name),
"studio" => dbContext.Set<Studio>().Select(s => s.Name),
"director" => dbContext.Set<Director>().Select(d => d.Name),
"writer" => dbContext.Set<Writer>().Select(w => w.Name),
"actor" => dbContext.Actors.Select(a => a.Name),
// entity artists only; free-text music-video/song artist credits are not included (known limitation)
"artist" => dbContext.ArtistMetadata.Select(m => m.Title),
"tag" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId != Tag.NfoCountryTypeId && t.ExternalTypeId != Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"network" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"collection" => dbContext.Collections.Select(c => c.Name),
"video_codec" => dbContext.MediaStreams
.Where(s => s.MediaStreamKind == MediaStreamKind.Video && s.Codec != null)
.Select(s => s.Codec),
"album" => dbContext.MusicVideoMetadata
.Where(m => m.Album != null)
.Select(m => m.Album)
.Concat(dbContext.SongMetadata.Where(m => m.Album != null).Select(m => m.Album)),
_ => null
};
private static async Task<List<string>> GetContentRatingValues(
TvContext dbContext,
string qLower,
int limit,
CancellationToken cancellationToken)
{
List<string> raw = await dbContext.MovieMetadata
.Where(m => m.ContentRating != null)
.Select(m => m.ContentRating)
.Concat(dbContext.ShowMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.OtherVideoMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.RemoteStreamMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Distinct()
.ToListAsync(cancellationToken);
IEnumerable<string> split = raw
.SelectMany(cr => cr.Split('/'))
.Select(cr => cr.Trim())
.Where(cr => !string.IsNullOrEmpty(cr))
.Distinct();
return FilterSortTake(split, qLower, limit);
}
private static List<string> FilterSortTake(IEnumerable<string> values, string qLower, int limit) =>
values
.Where(v => v.ToLower().StartsWith(qLower))
.OrderBy(v => v)
.Take(limit)
.ToList();
}
@@ -26,7 +26,9 @@ namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
// process-wide, shared by every session — the work-ahead limit is a global resource budget
private static readonly WorkAheadSlots _workAheadSlots = new();
private readonly OutputFormatKind _outputFormatKind;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = [];
@@ -243,10 +245,12 @@ public class HlsSessionWorker : IHlsSessionWorker
cancellationToken);
}
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
// claim the slot here rather than checking here and claiming inside Transcode: the check
// and the claim have to be one atomic step or every simultaneous tune-in wins (#536)
bool initialWorkAhead = _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
_state = initialWorkAhead ? HlsSessionState.SeekAndWorkAhead : HlsSessionState.SeekAndRealtime;
if (!await Transcode(!initialWorkAhead, cancellationToken))
if (!await Transcode(initialWorkAhead, cancellationToken))
{
return;
}
@@ -269,8 +273,8 @@ public class HlsSessionWorker : IHlsSessionWorker
// only use realtime encoding when we're at least 30 seconds ahead
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
bool subsequentWorkAhead =
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
if (!await Transcode(!subsequentWorkAhead, cancellationToken))
!realtime && _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
if (!await Transcode(subsequentWorkAhead, cancellationToken))
{
return;
}
@@ -456,15 +460,23 @@ public class HlsSessionWorker : IHlsSessionWorker
return result;
}
private async Task<bool> Transcode(bool realtime, CancellationToken cancellationToken)
/// <summary>
/// Runs one transcode. The caller is the one that races for a work-ahead slot, so ownership is
/// passed IN: <paramref name="ownsWorkAheadSlot" /> means the caller already claimed a slot from
/// <see cref="_workAheadSlots" />, and this method releases it in its <c>finally</c> — acquire
/// and release stay one-for-one (#536).
/// </summary>
private async Task<bool> Transcode(bool ownsWorkAheadSlot, CancellationToken cancellationToken)
{
// a session works ahead exactly when it holds a slot; everything else runs realtime (throttled)
bool realtime = !ownsWorkAheadSlot;
try
{
bool wasSeekAndWorkAhead = _state is HlsSessionState.SeekAndWorkAhead;
if (!realtime)
{
Interlocked.Increment(ref _workAheadCount);
_logger.LogDebug("HLS segmenter will work ahead for channel {Channel}", _channelNumber);
HlsSessionState nextState = _state switch
@@ -747,9 +759,15 @@ public class HlsSessionWorker : IHlsSessionWorker
// do nothing
}
if (!realtime)
if (ownsWorkAheadSlot && !_workAheadSlots.Release())
{
Interlocked.Decrement(ref _workAheadCount);
// Release() reports false only when the pool was already empty, i.e. this slot was
// released more than once. Nothing reaches here in a correct program, but if a
// future second release site breaks the ownership contract this is the one in-band
// signal that the unthrottled-transcode budget is inflated (ersatztv#536/#539 §3).
_logger.LogWarning(
"Released a work-ahead slot that was not held for channel {Channel} - the unthrottled-transcode budget may be inflated",
_channelNumber);
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -60,6 +60,8 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
.ThenInclude(p => p.Resolution)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
foreach (var channel in maybeChannel)
@@ -0,0 +1,102 @@
namespace ErsatzTV.Application.Streaming;
/// <summary>
/// The process-wide pool of work-ahead slots shared by every HLS session (ersatztv#536).
/// </summary>
/// <remarks>
/// <para>
/// <c>workAheadSegmenterLimit</c> is a resource guarantee, not a tuning knob: it bounds how many
/// transcodes may run unthrottled (no <c>-readrate</c>) at once, and the QSV hardware-frame pool
/// sizing from ersatztv#529 assumes that bound holds.
/// </para>
/// <para>
/// Acquisition must therefore be atomic. The previous shape — <c>Volatile.Read(count) &lt; limit</c>
/// in the caller, <c>Interlocked.Increment</c> later inside the transcode — is a check-then-act
/// TOCTOU separated by at least one <c>await</c> (the limit is a DB-backed config read), so N
/// simultaneous tune-ins all observed <c>0 &lt; limit</c> and all ran unthrottled. Same class as
/// ersatztv#231 / #250.
/// </para>
/// </remarks>
public sealed class WorkAheadSlots
{
private int _count;
private int _unbalancedReleases;
/// <summary>
/// Gets the number of slots currently held. For diagnostics and tests only — never branch on
/// this to decide whether to work ahead; that is exactly the race <see cref="TryAcquire" /> exists to close.
/// </summary>
public int Count => Volatile.Read(ref _count);
/// <summary>
/// Atomically claims one slot if fewer than <paramref name="limit" /> are held.
/// </summary>
/// <returns><c>true</c> when a slot was claimed; the caller then owns it and MUST
/// <see cref="Release" /> it exactly once.</returns>
public bool TryAcquire(int limit)
{
while (true)
{
int current = Volatile.Read(ref _count);
if (current >= limit)
{
return false;
}
// only the thread whose compare-exchange observes the value it read wins the slot, so
// the count can never transiently exceed the limit and two racers can never both claim
if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
{
return true;
}
}
}
/// <summary>
/// Gets the number of releases that were not matched by a successful acquire. Non-zero always
/// means the ownership contract was broken somewhere (no false positives), so the value is a
/// reliable "something is wrong" signal — but it can UNDER-count and zero does not prove
/// correctness. It only increments when a release finds the pool already empty; an over-release
/// that happens while the count is positive — e.g. one cancelling out a coexisting leak —
/// decrements a real-looking slot and is never recorded, so the two bugs hide each other. This
/// is inherent to a single counter; exact accounting would need per-owner tokens (ersatztv#539 §2).
/// </summary>
public int UnbalancedReleases => Volatile.Read(ref _unbalancedReleases);
/// <summary>
/// Returns a slot claimed by <see cref="TryAcquire" />. Only ever called by the owner of that slot.
/// </summary>
/// <returns>
/// <c>true</c> when a held slot was returned; <c>false</c> when the pool was already empty, i.e.
/// the release was unbalanced (also counted in <see cref="UnbalancedReleases" />). Callers should
/// log the <c>false</c> case: it is the only in-band signal that the budget contract was broken.
/// </returns>
/// <remarks>
/// Ownership is a discipline, not a token — the same call-once contract as `EntityLocker` (#231).
/// The one failure this defends against is an unbalanced release inflating the budget: this pool
/// is process-wide and lives for the life of the app, so a leaked slot would silently and
/// permanently admit one extra unthrottled transcode, re-opening the #529 QSV pool exhaustion.
/// It clamps at zero rather than throwing — the single caller releases from a `finally`, where a
/// throw would swallow the real exception. Unlike a decrement-first-then-clamp shape, this never
/// publishes a negative count even transiently, so a concurrent <see cref="TryAcquire" /> can
/// never read the pool as having phantom room and over-admit (ersatztv#539 §1); and it records
/// the unbalanced release synchronously here, rather than blaming a later, innocent release.
/// </remarks>
public bool Release()
{
while (true)
{
int current = Volatile.Read(ref _count);
if (current <= 0)
{
Interlocked.Increment(ref _unbalancedReleases);
return false;
}
if (Interlocked.CompareExchange(ref _count, current - 1, current) == current)
{
return true;
}
}
}
}
@@ -187,11 +187,14 @@ 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.)
// The access-token value is HTTP-request-derived (?access_token=) and interpolated into the
// {AccessTokenUri} placeholder, which sits in a URL query value inside an XML attribute. It is
// percent-encoded FIRST (#421 — URL-correct: a token '&' becomes %26 so it can't split the query and
// truncate the token once a consumer URL-decodes the attribute) and XML-escaped SECOND (#376 — so the
// guide stays well-formed). For this token every char percent-encodes to an XML-safe %XX, so the emitted
// value is the percent-encoded form with no '&amp;'/'&lt;' introduced by the token.
[Test]
public async Task Guide_xml_escapes_access_token()
public async Task Guide_encodes_and_xml_escapes_access_token()
{
MockFileSystem fileSystem = BuildCacheFileSystem();
var localFileSystem = Substitute.For<ILocalFileSystem>();
@@ -223,9 +226,12 @@ public class ChannelGuideGoldenTests
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&<");
// Percent-encoded, therefore already XML-safe: '&'->%26, '<'->%3C, '>'->%3E, '"'->%22.
xml.ShouldContain("access_token=tok%26%3C%3E%22");
// The token must not introduce a bare '&' NOR an '&amp;' — either would truncate the query on decode.
xml.ShouldNotContain("access_token=tok&");
// And the raw special chars must never reach the output.
xml.ShouldNotContain("tok&<>\"");
}
// --- harness ---
@@ -0,0 +1,90 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Iptv;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Iptv;
// #421: a token containing M3U-structural characters (a double-quote or ampersand) must not be able to
// break out of the quoted url-tvg="..."/tvg-logo="..." attributes or the query string it is placed into.
[TestFixture]
public class ChannelPlaylistAccessTokenTests
{
[Test]
public void Access_token_with_structural_chars_is_percent_encoded()
{
const string nastyToken = "aa\"bb&cc dd";
var playlist = new ChannelPlaylist(
"https",
"tv.example.com",
baseUrl: string.Empty,
[
new Channel(new Guid("00000000-0000-0000-0000-000000000001"))
{
Number = "1",
Name = "News",
Group = "ErsatzTV",
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
Artwork = [],
FFmpegProfile = new FFmpegProfile
{
VideoFormat = FFmpegProfileVideoFormat.H264,
AudioFormat = FFmpegProfileAudioFormat.Aac
}
}
],
userAgent: "VLC/3.0",
accessToken: nastyToken);
string m3u = playlist.ToM3U();
// The raw token characters must never appear in the token value...
m3u.ShouldNotContain("access_token=aa\"");
m3u.ShouldNotContain("access_token=aa\"bb&cc");
// ...they are percent-encoded instead (" -> %22, & -> %26, space -> %20).
m3u.ShouldContain("access_token=aa%22bb%26cc%20dd");
// No line's url-tvg attribute value contains a bare double-quote that could terminate it early.
foreach (string line in m3u.Split('\n'))
{
if (line.StartsWith("#EXTM3U", StringComparison.Ordinal))
{
// url-tvg="<url>" x-tvg-url="<url>" — exactly the attribute-delimiting quotes, no stray ones.
line.Count(c => c == '"').ShouldBe(4);
}
}
}
[Test]
public void Normal_jwt_token_is_unchanged()
{
// A base64url JWT is entirely RFC 3986 unreserved, so encoding is a no-op (goldens stay stable).
const string jwt = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEyM30.abc-DEF_123";
var playlist = new ChannelPlaylist(
"https",
"tv.example.com",
baseUrl: string.Empty,
[
new Channel(new Guid("00000000-0000-0000-0000-000000000001"))
{
Number = "1",
Name = "News",
Group = "ErsatzTV",
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
Artwork = [],
FFmpegProfile = new FFmpegProfile
{
VideoFormat = FFmpegProfileVideoFormat.H264,
AudioFormat = FFmpegProfileAudioFormat.Aac
}
}
],
userAgent: "VLC/3.0",
accessToken: jwt);
playlist.ToM3U().ShouldContain($"access_token={jwt}");
}
}
@@ -0,0 +1,119 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
// Direct unit coverage for the enumerator-construction helper extracted from the Scripted
// (SchedulingEngine.EnumeratorForContent) and Sequential/YAML (EnumeratorCache.GetEnumeratorForContent)
// engines (#395). These pin the two behaviors the issue flags as traps:
// - Shuffle must build the *block* enumerator, NOT Classic's ShuffledMediaCollectionEnumerator (a
// different algorithm keyed on the same PlaybackOrder), and
// - every order the two engines don't support returns None, so each caller logs its own #70 warning
// instead of silently scheduling nothing.
// The Scripted engine has no golden (its external-process/HTTP transport is integration-only, #563), so
// this direct helper test is the in-process regression net for the shared construction it drives.
[TestFixture]
public class ContentEnumeratorBuilderTests
{
[Test]
public void Chronological_Builds_Chronological_Enumerator()
{
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1), FakeMovie(2)],
new CollectionEnumeratorState(),
PlaybackOrder.Chronological,
multiPart: false);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
enumerator.ShouldBeOfType<ChronologicalMediaCollectionEnumerator>();
}
}
[Test]
public void Shuffle_Builds_Block_Shuffle_Enumerator_Not_Classic()
{
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1), FakeMovie(2)],
new CollectionEnumeratorState(),
PlaybackOrder.Shuffle,
multiPart: false);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
// The documented trap: Shuffle here is the block algorithm, never Classic's shuffle.
enumerator.ShouldBeOfType<BlockPlayoutShuffledMediaCollectionEnumerator>();
enumerator.ShouldNotBeOfType<ShuffledMediaCollectionEnumerator>();
}
}
[Test]
public void Shuffle_MultiPart_Also_Builds_Block_Shuffle_Enumerator()
{
// "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together; multiPart routes the
// items through it before the block enumerator. Grouping mechanics are covered by
// MultiPartEpisodeGrouper's / ShuffleSourceBuilder's own tests; here we pin that the flag path still
// produces the block enumerator (and doesn't throw on the grouped list).
List<MediaItem> parts =
[
NamedEpisode("Episode 1 (1)", 1),
NamedEpisode("Episode 2 (2)", 2)
];
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
parts,
new CollectionEnumeratorState(),
PlaybackOrder.Shuffle,
multiPart: true);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
enumerator.ShouldBeOfType<BlockPlayoutShuffledMediaCollectionEnumerator>();
}
}
[TestCase(PlaybackOrder.None)]
[TestCase(PlaybackOrder.Random)]
[TestCase(PlaybackOrder.ShuffleInOrder)]
[TestCase(PlaybackOrder.MultiEpisodeShuffle)]
[TestCase(PlaybackOrder.SeasonEpisode)]
[TestCase(PlaybackOrder.RandomRotation)]
[TestCase(PlaybackOrder.Marathon)]
[TestCase(PlaybackOrder.WeightedShuffle)]
public void Unsupported_Order_Returns_None(PlaybackOrder order)
{
// #70: these two engines support only Chronological + Shuffle; every other order returns None so the
// caller logs a "not supported" warning instead of silently scheduling nothing.
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1)],
new CollectionEnumeratorState(),
order,
multiPart: false);
result.IsNone.ShouldBeTrue();
}
private static Episode NamedEpisode(string title, int id) => new()
{
Id = id,
EpisodeMetadata = [new EpisodeMetadata { Title = title, EpisodeNumber = id }],
Season = new Season { SeasonNumber = 1, Show = new Show { Id = 1 }, ShowId = 1 }
};
private static Movie FakeMovie(int id) => new()
{
Id = id,
MediaVersions = [],
MovieMetadata =
[
new MovieMetadata { ReleaseDate = new DateTime(2020, 1, id) }
]
};
}
@@ -0,0 +1,18 @@
# Deterministic Sequential (YAML) schedule fixture for
# PlayoutBuildGoldenTests.Sequential_yaml (ersatztv#381).
#
# Two `count` instructions over ONE chronological collection. The content enumerator is cached by key,
# so it continues across the two instructions: items 1-2 come from the first `count`, items 3-4 from the
# second. `order: chronological` + literal integer counts keep the build free of shuffle-seed, RNG, and
# wall-clock/local-time dependence, so the snapshot of raw UTC Start/Finish is machine-timezone-independent
# (no Assume guard needed, unlike the Block golden). Do not introduce `wait_until` / `pad_to_next` /
# `pad_until` (local-time-of-day) or a `shuffle` order without revisiting that determinism claim.
content:
- collection: Sequential Test Collection
key: movies
order: chronological
playout:
- count: 2
content: movies
- count: 2
content: movies
@@ -0,0 +1,144 @@
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Schedule Padded Movie Fallback 01
001 | 2026-01-15 00:22:00 - 2026-01-15 00:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
002 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Schedule Padded Movie Fallback 02
003 | 2026-01-15 01:07:00 - 2026-01-15 01:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
004 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Schedule Padded Movie Fallback 03
005 | 2026-01-15 02:07:00 - 2026-01-15 02:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
006 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Schedule Padded Movie Fallback 01
007 | 2026-01-15 02:37:00 - 2026-01-15 02:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
008 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Schedule Padded Movie Fallback 02
009 | 2026-01-15 03:22:00 - 2026-01-15 03:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
010 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Schedule Padded Movie Fallback 03
011 | 2026-01-15 04:22:00 - 2026-01-15 04:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
012 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Schedule Padded Movie Fallback 01
013 | 2026-01-15 04:52:00 - 2026-01-15 05:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
014 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Schedule Padded Movie Fallback 02
015 | 2026-01-15 05:37:00 - 2026-01-15 05:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
016 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Schedule Padded Movie Fallback 03
017 | 2026-01-15 06:37:00 - 2026-01-15 06:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
018 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Schedule Padded Movie Fallback 01
019 | 2026-01-15 07:07:00 - 2026-01-15 07:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
020 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Schedule Padded Movie Fallback 02
021 | 2026-01-15 07:52:00 - 2026-01-15 08:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
022 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Schedule Padded Movie Fallback 03
023 | 2026-01-15 08:52:00 - 2026-01-15 09:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
024 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Schedule Padded Movie Fallback 01
025 | 2026-01-15 09:22:00 - 2026-01-15 09:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
026 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Schedule Padded Movie Fallback 02
027 | 2026-01-15 10:07:00 - 2026-01-15 10:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
028 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Schedule Padded Movie Fallback 03
029 | 2026-01-15 11:07:00 - 2026-01-15 11:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
030 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Schedule Padded Movie Fallback 01
031 | 2026-01-15 11:37:00 - 2026-01-15 11:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
032 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Schedule Padded Movie Fallback 02
033 | 2026-01-15 12:22:00 - 2026-01-15 12:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
034 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Schedule Padded Movie Fallback 03
035 | 2026-01-15 13:22:00 - 2026-01-15 13:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
036 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Schedule Padded Movie Fallback 01
037 | 2026-01-15 13:52:00 - 2026-01-15 14:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
038 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Schedule Padded Movie Fallback 02
039 | 2026-01-15 14:37:00 - 2026-01-15 14:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
040 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Schedule Padded Movie Fallback 03
041 | 2026-01-15 15:37:00 - 2026-01-15 15:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
042 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Schedule Padded Movie Fallback 01
043 | 2026-01-15 16:07:00 - 2026-01-15 16:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
044 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Schedule Padded Movie Fallback 02
045 | 2026-01-15 16:52:00 - 2026-01-15 17:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
046 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Schedule Padded Movie Fallback 03
047 | 2026-01-15 17:52:00 - 2026-01-15 18:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
048 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Schedule Padded Movie Fallback 01
049 | 2026-01-15 18:22:00 - 2026-01-15 18:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
050 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Schedule Padded Movie Fallback 02
051 | 2026-01-15 19:07:00 - 2026-01-15 19:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
052 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Schedule Padded Movie Fallback 03
053 | 2026-01-15 20:07:00 - 2026-01-15 20:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
054 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Schedule Padded Movie Fallback 01
055 | 2026-01-15 20:37:00 - 2026-01-15 20:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
056 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Schedule Padded Movie Fallback 02
057 | 2026-01-15 21:22:00 - 2026-01-15 21:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
058 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Schedule Padded Movie Fallback 03
059 | 2026-01-15 22:22:00 - 2026-01-15 22:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
060 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Schedule Padded Movie Fallback 01
061 | 2026-01-15 22:52:00 - 2026-01-15 23:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
062 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Schedule Padded Movie Fallback 02
063 | 2026-01-15 23:37:00 - 2026-01-15 23:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
064 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Schedule Padded Movie Fallback 03
065 | 2026-01-16 00:37:00 - 2026-01-16 00:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
066 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Schedule Padded Movie Fallback 01
067 | 2026-01-16 01:07:00 - 2026-01-16 01:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
068 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Schedule Padded Movie Fallback 02
069 | 2026-01-16 01:52:00 - 2026-01-16 02:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
070 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Schedule Padded Movie Fallback 03
071 | 2026-01-16 02:52:00 - 2026-01-16 03:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
072 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Schedule Padded Movie Fallback 01
073 | 2026-01-16 03:22:00 - 2026-01-16 03:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
074 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Schedule Padded Movie Fallback 02
075 | 2026-01-16 04:07:00 - 2026-01-16 04:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
076 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Schedule Padded Movie Fallback 03
077 | 2026-01-16 05:07:00 - 2026-01-16 05:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
078 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Schedule Padded Movie Fallback 01
079 | 2026-01-16 05:37:00 - 2026-01-16 05:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
080 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Schedule Padded Movie Fallback 02
081 | 2026-01-16 06:22:00 - 2026-01-16 06:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
082 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Schedule Padded Movie Fallback 03
083 | 2026-01-16 07:22:00 - 2026-01-16 07:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
084 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Schedule Padded Movie Fallback 01
085 | 2026-01-16 07:52:00 - 2026-01-16 08:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
086 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Schedule Padded Movie Fallback 02
087 | 2026-01-16 08:37:00 - 2026-01-16 08:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
088 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Schedule Padded Movie Fallback 03
089 | 2026-01-16 09:37:00 - 2026-01-16 09:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
090 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Schedule Padded Movie Fallback 01
091 | 2026-01-16 10:07:00 - 2026-01-16 10:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
092 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Schedule Padded Movie Fallback 02
093 | 2026-01-16 10:52:00 - 2026-01-16 11:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
094 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Schedule Padded Movie Fallback 03
095 | 2026-01-16 11:52:00 - 2026-01-16 12:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
096 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Schedule Padded Movie Fallback 01
097 | 2026-01-16 12:22:00 - 2026-01-16 12:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
098 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Schedule Padded Movie Fallback 02
099 | 2026-01-16 13:07:00 - 2026-01-16 13:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
100 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Schedule Padded Movie Fallback 03
101 | 2026-01-16 14:07:00 - 2026-01-16 14:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
102 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Schedule Padded Movie Fallback 01
103 | 2026-01-16 14:37:00 - 2026-01-16 14:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
104 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Schedule Padded Movie Fallback 02
105 | 2026-01-16 15:22:00 - 2026-01-16 15:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
106 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Schedule Padded Movie Fallback 03
107 | 2026-01-16 16:22:00 - 2026-01-16 16:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
108 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Schedule Padded Movie Fallback 01
109 | 2026-01-16 16:52:00 - 2026-01-16 17:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
110 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Schedule Padded Movie Fallback 02
111 | 2026-01-16 17:37:00 - 2026-01-16 17:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
112 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Schedule Padded Movie Fallback 03
113 | 2026-01-16 18:37:00 - 2026-01-16 18:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
114 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Schedule Padded Movie Fallback 01
115 | 2026-01-16 19:07:00 - 2026-01-16 19:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
116 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Schedule Padded Movie Fallback 02
117 | 2026-01-16 19:52:00 - 2026-01-16 20:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
118 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Schedule Padded Movie Fallback 03
119 | 2026-01-16 20:52:00 - 2026-01-16 21:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
120 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Schedule Padded Movie Fallback 01
121 | 2026-01-16 21:22:00 - 2026-01-16 21:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
122 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Schedule Padded Movie Fallback 02
123 | 2026-01-16 22:07:00 - 2026-01-16 22:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
124 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Schedule Padded Movie Fallback 03
125 | 2026-01-16 23:07:00 - 2026-01-16 23:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
126 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Schedule Padded Movie Fallback 01
127 | 2026-01-16 23:37:00 - 2026-01-16 23:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
128 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Schedule Padded Movie Fallback 02
129 | 2026-01-17 00:22:00 - 2026-01-17 00:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
130 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Schedule Padded Movie Fallback 03
131 | 2026-01-17 01:22:00 - 2026-01-17 01:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
132 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Schedule Padded Movie Fallback 01
133 | 2026-01-17 01:52:00 - 2026-01-17 02:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
134 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Schedule Padded Movie Fallback 02
135 | 2026-01-17 02:37:00 - 2026-01-17 02:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
136 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Schedule Padded Movie Fallback 03
137 | 2026-01-17 03:37:00 - 2026-01-17 03:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
138 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Schedule Padded Movie Fallback 01
139 | 2026-01-17 04:07:00 - 2026-01-17 04:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
140 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Schedule Padded Movie Fallback 02
141 | 2026-01-17 04:52:00 - 2026-01-17 05:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
142 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Schedule Padded Movie Fallback 03
143 | 2026-01-17 05:52:00 - 2026-01-17 06:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
@@ -0,0 +1,72 @@
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Schedule Padded Movie Offline 01
001 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Schedule Padded Movie Offline 02
002 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Schedule Padded Movie Offline 03
003 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Schedule Padded Movie Offline 01
004 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Schedule Padded Movie Offline 02
005 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Schedule Padded Movie Offline 03
006 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Schedule Padded Movie Offline 01
007 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Schedule Padded Movie Offline 02
008 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Schedule Padded Movie Offline 03
009 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Schedule Padded Movie Offline 01
010 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Schedule Padded Movie Offline 02
011 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Schedule Padded Movie Offline 03
012 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Schedule Padded Movie Offline 01
013 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Schedule Padded Movie Offline 02
014 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Schedule Padded Movie Offline 03
015 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Schedule Padded Movie Offline 01
016 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Schedule Padded Movie Offline 02
017 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Schedule Padded Movie Offline 03
018 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Schedule Padded Movie Offline 01
019 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Schedule Padded Movie Offline 02
020 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Schedule Padded Movie Offline 03
021 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Schedule Padded Movie Offline 01
022 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Schedule Padded Movie Offline 02
023 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Schedule Padded Movie Offline 03
024 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Schedule Padded Movie Offline 01
025 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Schedule Padded Movie Offline 02
026 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Schedule Padded Movie Offline 03
027 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Schedule Padded Movie Offline 01
028 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Schedule Padded Movie Offline 02
029 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Schedule Padded Movie Offline 03
030 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Schedule Padded Movie Offline 01
031 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Schedule Padded Movie Offline 02
032 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Schedule Padded Movie Offline 03
033 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Schedule Padded Movie Offline 01
034 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Schedule Padded Movie Offline 02
035 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Schedule Padded Movie Offline 03
036 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Schedule Padded Movie Offline 01
037 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Schedule Padded Movie Offline 02
038 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Schedule Padded Movie Offline 03
039 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Schedule Padded Movie Offline 01
040 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Schedule Padded Movie Offline 02
041 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Schedule Padded Movie Offline 03
042 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Schedule Padded Movie Offline 01
043 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Schedule Padded Movie Offline 02
044 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Schedule Padded Movie Offline 03
045 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Schedule Padded Movie Offline 01
046 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Schedule Padded Movie Offline 02
047 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Schedule Padded Movie Offline 03
048 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Schedule Padded Movie Offline 01
049 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Schedule Padded Movie Offline 02
050 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Schedule Padded Movie Offline 03
051 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Schedule Padded Movie Offline 01
052 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Schedule Padded Movie Offline 02
053 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Schedule Padded Movie Offline 03
054 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Schedule Padded Movie Offline 01
055 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Schedule Padded Movie Offline 02
056 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Schedule Padded Movie Offline 03
057 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Schedule Padded Movie Offline 01
058 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Schedule Padded Movie Offline 02
059 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Schedule Padded Movie Offline 03
060 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Schedule Padded Movie Offline 01
061 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Schedule Padded Movie Offline 02
062 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Schedule Padded Movie Offline 03
063 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Schedule Padded Movie Offline 01
064 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Schedule Padded Movie Offline 02
065 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Schedule Padded Movie Offline 03
066 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Schedule Padded Movie Offline 01
067 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Schedule Padded Movie Offline 02
068 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Schedule Padded Movie Offline 03
069 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Schedule Padded Movie Offline 01
070 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Schedule Padded Movie Offline 02
071 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Schedule Padded Movie Offline 03
@@ -0,0 +1,4 @@
000 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Sequential Movie 01
001 | 2026-01-15 06:30:00 - 2026-01-15 07:15:00 | None | Sequential Movie 02
002 | 2026-01-15 07:15:00 - 2026-01-15 08:15:00 | None | Sequential Movie 03
003 | 2026-01-15 08:15:00 - 2026-01-15 08:45:00 | None | Sequential Movie 04
@@ -6,10 +6,12 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
@@ -117,6 +119,138 @@ public class PlayoutBuildGoldenTests
await CompareGolden("classic-clock-padded.txt", items, titles);
}
// Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to
// the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances
// the build clock to the boundary even when nothing fills the gap.
[Test]
public async Task Classic_schedule_clock_padded_offline()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: false);
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
content.Count.ShouldBeGreaterThan(2);
// No filler of any kind is emitted (offline gaps only).
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
await CompareGolden("classic-schedule-clock-padded-offline.txt", items, titles);
}
// Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to
// the :15 boundary (no offline gap).
[Test]
public async Task Classic_schedule_clock_padded_fallback()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: true);
items.ShouldContain(i => i.FillerKind == FillerKind.Fallback);
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
await CompareGolden("classic-schedule-clock-padded-fallback.txt", items, titles);
}
// #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad).
[Test]
public async Task Classic_item_pad_wins_over_schedule_pad()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout(schedulePadMinutes: 30);
await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden
}
// #392: the schedule-level pad + OFFLINE advance is shared machinery — AddFiller records the offline
// target and every scheduler honors it. The offline goldens only exercise PlayoutModeSchedulerOne, so
// these invariant tests cover Flood / Duration / Multiple across a 2-day window (two midnight crossings)
// to prove the offline advance AND its day-seam anchor survival are not One-specific. No golden files:
// the invariants (boundary alignment of every content item, zero filler emitted, and resumption on a
// boundary on both later days) fully pin the behavior and are the exact thing the day-seam clamp fix
// must preserve. If the day-boundary anchor clamp wrongly discarded an offline advance, the first
// content item after a midnight would land mid-interval and fail here.
[TestCase("Flood")]
[TestCase("Duration")]
[TestCase("Multiple")]
public async Task Schedule_clock_padded_offline_multimode(string mode)
{
List<PlayoutItem> items = await BuildSchedulePaddedModePlayout(mode);
List<PlayoutItem> content = items
.Where(i => i.FillerKind == FillerKind.None)
.OrderBy(i => i.Start)
.ToList();
// Sanity: a 2-day window over sub-hour content must yield many items spanning >1 day.
content.Count.ShouldBeGreaterThan(10);
content.Select(i => i.Start.Date).Distinct().Count().ShouldBeGreaterThan(2);
// Offline variant: NO filler of any kind is emitted (gaps up to the boundary are left offline).
items.ShouldNotContain(i => i.FillerKind != FillerKind.None, $"[{mode}] offline pad must emit no filler");
// Every content item begins on a :15 boundary. The very first item is the raw anchor at the pinned
// Start (06:00, itself a :15 boundary); every later item — including the first of day 2 and day 3 —
// is on a boundary only because the preceding item's offline pad advanced the clock to it AND the
// day-boundary anchor clamp preserved that advance across each midnight seam.
foreach (PlayoutItem item in content)
{
(item.Start.Minute % 15).ShouldBe(
0,
$"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0, $"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not second-aligned");
}
// Explicit day-seam assertion: the first content item on each day after the first still lands on a
// boundary (this is precisely what regressed before the clamp fix, once per simulated midnight).
List<PlayoutItem> firstOfEachDay = content
.GroupBy(i => i.Start.Date)
.OrderBy(g => g.Key)
.Select(g => g.OrderBy(i => i.Start).First())
.ToList();
foreach (PlayoutItem dayStart in firstOfEachDay.Skip(1))
{
(dayStart.Start.Minute % 15).ShouldBe(
0,
$"[{mode}] first content item of {dayStart.Start:yyyy-MM-dd} at {dayStart.Start:HH:mm:ss} resumed mid-interval");
}
}
// Regression for the whole-branch-review defect: Fill-With-Group schedule items (FillWithGroupMode
// .FillWithOrderedGroups / FillWithShuffledGroups) are scheduled via a FAKE ProgramScheduleItem that
// PlayoutBuilder synthesizes with DeepCopy() (Newtonsoft serialization). ProgramScheduleItem
// .ProgramSchedule is [JsonIgnore]'d there, so without reassigning it on the copy, the schedule-level
// PadToNearestMinute silently no-ops for fill-with-group items only — the normal (non-group) path
// reads the schedule nav that Build() populates centrally and was never broken. No golden: this is an
// invariant-only regression test (boundary alignment + zero filler), the same assertions
// Classic_schedule_clock_padded_offline uses for the non-group path.
[Test]
public async Task Schedule_clock_padded_fill_with_group_offline()
{
List<PlayoutItem> items = await BuildSchedulePaddedFillWithGroupPlayout();
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
content.Count.ShouldBeGreaterThan(2);
// No filler of any kind is emitted (offline gaps only).
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(
0,
$"fill-with-group content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
}
// Classic + PlaybackOrder.Shuffle: exercises PlayoutBuilder's call into the shuffle-source helper
// (GetGroupedMediaItemsForShuffle) that #380 moves to ShuffleSourceBuilder, plus the wiring into
// ShuffledMediaCollectionEnumerator. Unlike the chronological fixture, shuffle output depends on the
@@ -134,6 +268,36 @@ public class PlayoutBuildGoldenTests
[Test]
public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);
// Sequential (YAML) builder (#381, follow-up to #163). SequentialPlayoutBuilder reads a YAML schedule
// file (Playout.ScheduleFile) instead of a ProgramSchedule/Block calendar. The committed fixture
// Goldens/Fixtures/sequential-schedule.yml schedules two `count: 2` instructions over one chronological
// collection, so the builder lays exactly four items back-to-back from the pinned start (the enumerator
// is cached by content key and continues across the two instructions). Besides the golden we assert the
// contiguity invariant: it is what "sequential" means here, and a deliberate change to a count or a
// duration flips both the assertion and the golden (#12 non-vacuity). The count/all/duration handlers do
// pure UTC arithmetic off the caller-supplied start (no TimeZoneInfo.Local / ToLocalTime), so this case
// is TZ-independent and needs no Assume guard — unlike Block, and unlike the wait_until/pad_* handlers
// the fixture deliberately avoids.
[Test]
public async Task Sequential_yaml()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSequentialPlayout();
List<PlayoutItem> ordered = items.OrderBy(i => i.Start).ToList();
ordered.Count.ShouldBe(4);
ordered.ShouldAllBe(i => i.FillerKind == FillerKind.None);
ordered[0].Start.ShouldBe(Start.UtcDateTime);
for (var i = 1; i < ordered.Count; i++)
{
ordered[i].Start.ShouldBe(
ordered[i - 1].Finish,
$"sequential item {i} at {ordered[i].Start:HH:mm:ss} is not contiguous with the previous finish");
}
await CompareGolden("sequential-yaml.txt", items, titles);
}
[Test]
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
public async Task Regenerate_goldens()
@@ -142,7 +306,11 @@ public class PlayoutBuildGoldenTests
try
{
foreach (Func<Task> regen in new Func<Task>[]
{ Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle })
{
Classic_chronological, Block_playout, Classic_clock_padded,
Classic_schedule_clock_padded_offline, Classic_schedule_clock_padded_fallback,
Classic_shuffle, Classic_weighted, Sequential_yaml
})
{
try
{
@@ -689,11 +857,12 @@ public class PlayoutBuildGoldenTests
// --- Clock-boundary pad builder (issue #77) ---
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout()
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout(
int? schedulePadMinutes = null)
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedPaddedData(cancellationToken);
var (playoutId, titles) = await SeedPaddedData(cancellationToken, schedulePadMinutes);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
@@ -733,11 +902,17 @@ public class PlayoutBuildGoldenTests
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedPaddedData(
CancellationToken cancellationToken)
CancellationToken cancellationToken,
int? schedulePadMinutes = null)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Padded LibraryPath" };
// Suffix distinguishes this call from the base (schedulePadMinutes: null) call so unique-name/guid
// constraints don't collide when both are seeded into the same shared in-memory database. It never
// touches a Movie/MovieMetadata title, so the golden snapshot (which only records those) is unaffected.
string suffix = schedulePadMinutes.HasValue ? $" SchedulePad{schedulePadMinutes}" : string.Empty;
var path = new LibraryPath { Path = $"Padded LibraryPath{suffix}" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
@@ -786,12 +961,12 @@ public class PlayoutBuildGoldenTests
var contentCollection = new Collection
{
Name = "Padded Content Collection",
Name = $"Padded Content Collection{suffix}",
MediaItems = movies.Cast<MediaItem>().ToList()
};
var fillerCollection = new Collection
{
Name = "Padded Filler Collection",
Name = $"Padded Filler Collection{suffix}",
MediaItems = new List<MediaItem> { fillerClip }
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
@@ -806,7 +981,7 @@ public class PlayoutBuildGoldenTests
// increment (e.g. 10) — it would become machine-TZ dependent and need the Block-style Assume guard.
var padFiller = new FillerPreset
{
Name = "Pad To Quarter Hour",
Name = $"Pad To Quarter Hour{suffix}",
FillerKind = FillerKind.PostRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 15,
@@ -830,21 +1005,29 @@ public class PlayoutBuildGoldenTests
}
};
var ffmpegProfile = new FFmpegProfile { Name = "Padded FFmpeg Profile" };
var ffmpegProfile = new FFmpegProfile { Name = $"Padded FFmpeg Profile{suffix}" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000003"))
var channel = new Channel(
schedulePadMinutes.HasValue
? Guid.Parse("00000000-0000-0000-0000-000000000007")
: Guid.Parse("00000000-0000-0000-0000-000000000003"))
{
Name = "Padded Test Channel",
Number = "3",
Name = $"Padded Test Channel{suffix}",
Number = schedulePadMinutes.HasValue ? "7" : "3",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = "Padded Test Schedule", Items = scheduleItems };
var schedule = new ProgramSchedule { Name = $"Padded Test Schedule{suffix}", Items = scheduleItems };
if (schedulePadMinutes.HasValue)
{
schedule.PadToNearestMinute = schedulePadMinutes.Value;
}
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
@@ -891,6 +1074,529 @@ public class PlayoutBuildGoldenTests
TimeSpan.Zero);
}
// #392: schedule-level PadToNearestMinute (no item-level Pad filler). Mirrors BuildPaddedPlayout/
// SeedPaddedData/GetPaddedReferenceData above, but the item has no PostRollFiller and the schedule
// itself carries the pad divisor.
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSchedulePaddedPlayout(
bool withFallback)
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedSchedulePaddedData(cancellationToken, withFallback);
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 GetSchedulePaddedReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
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)> SeedSchedulePaddedData(
CancellationToken cancellationToken,
bool withFallback)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
// Suffix distinguishes the offline/fallback variants so unique-name/guid constraints don't
// collide when both fixtures are seeded into the same shared in-memory database.
string suffix = withFallback ? "Fallback" : "Offline";
var path = new LibraryPath { Path = $"Schedule Padded LibraryPath {suffix}" };
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);
// Content: three movies with OFF-boundary durations (22/37/52 min) so padding to :15 is visible.
int[] durationsMinutes = [22, 37, 52];
var movies = new List<Movie>();
for (var i = 1; i <= 3; i++)
{
movies.Add(new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Schedule Padded Movie {suffix} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
// Filler: a SINGLE 1-minute clip, only used for the fallback variant.
var fillerClip = new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Schedule Fallback Filler Clip {suffix}", ReleaseDate = new DateTime(2019, 1, 1) }
},
LibraryPath = path,
LibraryPathId = path.Id
};
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.Movies.AddAsync(fillerClip, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
titles[fillerClip.Id] = fillerClip.MovieMetadata[0].Title;
var contentCollection = new Collection
{
Name = $"Schedule Padded Content Collection {suffix}",
MediaItems = movies.Cast<MediaItem>().ToList()
};
var fillerCollection = new Collection
{
Name = $"Schedule Padded Fallback Collection {suffix}",
MediaItems = new List<MediaItem> { fillerClip }
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
await context.Collections.AddAsync(fillerCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
FillerPreset fallbackFiller = null;
if (withFallback)
{
fallbackFiller = new FillerPreset
{
Name = $"Schedule Pad Fallback {suffix}",
FillerKind = FillerKind.Fallback,
FillerMode = FillerMode.None,
CollectionType = CollectionType.Collection,
Collection = fillerCollection,
CollectionId = fillerCollection.Id
};
await context.FillerPresets.AddAsync(fallbackFiller, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
var scheduleItem = new ProgramScheduleItemOne
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
PlaybackOrder = PlaybackOrder.Chronological
};
if (withFallback)
{
scheduleItem.FallbackFiller = fallbackFiller;
scheduleItem.FallbackFillerId = fallbackFiller.Id;
}
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded FFmpeg Profile {suffix}" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse(withFallback ? "00000000-0000-0000-0000-000000000009" : "00000000-0000-0000-0000-000000000008"))
{
Name = $"Schedule Padded Test Channel {suffix}",
Number = withFallback ? "9" : "8",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule
{
Name = $"Schedule Padded Test Schedule {suffix}",
Items = scheduleItems,
PadToNearestMinute = 15
};
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
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetSchedulePaddedReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
.AsNoTracking()
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
.Include(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.FallbackFiller)
.FirstOrDefaultAsync();
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
programSchedule,
[],
[],
TimeSpan.Zero);
}
// #392: schedule-level pad + offline advance for a NON-One scheduler (Flood / Duration / Multiple).
// Same shape as SeedSchedulePaddedData (no item-level Pad filler, no FallbackFiller → offline), only the
// ProgramScheduleItem subtype differs. Reuses GetSchedulePaddedReferenceData for the build query.
private async Task<List<PlayoutItem>> BuildSchedulePaddedModePlayout(string mode)
{
var cancellationToken = CancellationToken.None;
int playoutId = await SeedSchedulePaddedModeData(cancellationToken, mode);
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 GetSchedulePaddedReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return buildResult.AddedItems;
}
private async Task<int> SeedSchedulePaddedModeData(CancellationToken cancellationToken, string mode)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
// Per-mode suffix + GUID keep unique constraints from colliding across fixtures in the shared DB.
string guid = mode switch
{
"Flood" => "00000000-0000-0000-0000-00000000000a",
"Duration" => "00000000-0000-0000-0000-00000000000b",
"Multiple" => "00000000-0000-0000-0000-00000000000c",
_ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
};
var path = new LibraryPath { Path = $"Schedule Padded Mode LibraryPath {mode}" };
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);
// Sub-15-min durations (7/11/13) so each padded item occupies exactly one :15 slot. This makes a
// whole :15-multiple block (Duration's 3h) tile exactly with no leftover — Duration therefore never
// packs a final item unpadded to fill the block (its fill-the-duration contract legitimately
// overrides per-item clock-pad when content straddles a :15, which would obscure the pad/seam signal
// this test is pinning). Every mode then holds the same strict boundary invariant.
int[] durationsMinutes = [7, 11, 13];
var movies = new List<Movie>();
for (var i = 1; i <= 3; i++)
{
movies.Add(new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Schedule Padded Mode Movie {mode} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var contentCollection = new Collection
{
Name = $"Schedule Padded Mode Content Collection {mode}",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
ProgramScheduleItem scheduleItem = mode switch
{
"Flood" => new ProgramScheduleItemFlood
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
PlaybackOrder = PlaybackOrder.Chronological
},
"Duration" => new ProgramScheduleItemDuration
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
PlayoutDuration = TimeSpan.FromHours(3),
TailMode = TailMode.Offline,
PlaybackOrder = PlaybackOrder.Chronological
},
"Multiple" => new ProgramScheduleItemMultiple
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
MultipleMode = MultipleMode.Count,
Count = "3",
PlaybackOrder = PlaybackOrder.Chronological
},
_ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
};
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded Mode FFmpeg Profile {mode}" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse(guid))
{
Name = $"Schedule Padded Mode Channel {mode}",
Number = mode switch { "Flood" => "10", "Duration" => "11", _ => "12" },
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule
{
Name = $"Schedule Padded Mode Schedule {mode}",
Items = scheduleItems,
PadToNearestMinute = 15
};
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
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return playout.Id;
}
// Regression harness for the Fill-With-Group DeepCopy defect: same shape as
// SeedSchedulePaddedModeData (off-boundary durations, no item-level Pad filler, no FallbackFiller
// -> offline), except the single ProgramScheduleItemMultiple sets FillWithGroupMode so PlayoutBuilder
// schedules it via a synthesized (DeepCopy'd) fake schedule item instead of the original.
private async Task<int> SeedSchedulePaddedFillWithGroupData(CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Schedule Padded FillWithGroup 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);
// Off-boundary durations (22/37/52 min), same as SeedSchedulePaddedData, so padding to :15 is visible.
int[] durationsMinutes = [22, 37, 52];
var movies = new List<Movie>();
for (var i = 1; i <= 3; i++)
{
movies.Add(new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Schedule Padded FillWithGroup Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var contentCollection = new Collection
{
Name = "Schedule Padded FillWithGroup Content Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItem = new ProgramScheduleItemMultiple
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
MultipleMode = MultipleMode.Count,
Count = "3",
PlaybackOrder = PlaybackOrder.Chronological,
FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups
};
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
var ffmpegProfile = new FFmpegProfile { Name = "Schedule Padded FillWithGroup FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-00000000000d"))
{
Name = "Schedule Padded FillWithGroup Channel",
Number = "13",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule
{
Name = "Schedule Padded FillWithGroup Schedule",
Items = scheduleItems,
PadToNearestMinute = 15
};
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
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return playout.Id;
}
private async Task<List<PlayoutItem>> BuildSchedulePaddedFillWithGroupPlayout()
{
var cancellationToken = CancellationToken.None;
int playoutId = await SeedSchedulePaddedFillWithGroupData(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 GetSchedulePaddedReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return buildResult.AddedItems;
}
// --- Block builder ---
//
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
@@ -1141,6 +1847,166 @@ public class PlayoutBuildGoldenTests
TimeSpan.Zero);
}
// --- Sequential (YAML) builder (issue #381) ---
//
// SequentialPlayoutBuilder reads its schedule from a YAML file at Playout.ScheduleFile. It checks the
// file's existence through the injected IFileSystem but reads the bytes with the static System.IO.File,
// so the test writes a REAL committed fixture on disk (Goldens/Fixtures/sequential-schedule.yml) and
// only stubs IFileSystem.File.Exists -> true. The schema validator is stubbed (the real one loads a JSON
// schema from a runtime cache folder that a unit test has no reason to populate); this golden locks the
// BUILDER's PlayoutItem output, not the validator, which is a separate surface.
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSequentialPlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedSequentialData(cancellationToken);
var fileSystem = Substitute.For<System.IO.Abstractions.IFileSystem>();
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
var validator = Substitute.For<ISequentialScheduleValidator>();
validator.ValidateSchedule(Arg.Any<string>(), Arg.Any<bool>()).Returns(Task.FromResult(true));
var builder = new SequentialPlayoutBuilder(
fileSystem,
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
Substitute.For<IChannelRepository>(),
Substitute.For<IGraphicsElementRepository>(),
validator,
NullLogger<SequentialPlayoutBuilder>.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 GetSequentialReferenceData(context, playoutId);
// Reset over the pinned window: with no prior Anchor, Reset avoids the YamlPlayoutContext.Reset path
// (its ToLocalTime() only runs on a saved-anchor Continue), keeping the build TZ-independent.
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
Start,
playout,
referenceData,
PlayoutBuildMode.Reset,
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)> SeedSequentialData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Sequential 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);
// Six movies, distinct release dates (so chronological order is unambiguous) and varied durations so
// item boundaries are visible. Only the first four are scheduled (2 + 2 counts).
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Sequential Movie {i:D2}",
ReleaseDate = new DateTime(2005, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
// Name must match the fixture YAML's `collection:` value — EnumeratorCache resolves content by
// Collection.Name.
var collection = new Collection
{
Name = "Sequential Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var ffmpegProfile = new FFmpegProfile { Name = "Sequential 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-000000000006"))
{
Name = "Sequential Test Channel",
Number = "6",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Sequential playout: no ProgramSchedule; content comes from the YAML ScheduleFile.
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ScheduleKind = PlayoutScheduleKind.Sequential,
ScheduleFile = Path.Combine(FixtureDir(), "sequential-schedule.yml")
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetSequentialReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
// Sequential reads content from the YAML file, not a ProgramSchedule; empty history + a fresh build.
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
null,
[],
[],
TimeSpan.Zero);
}
// One line per PlayoutItem, ordered by Start then MediaItemId (stable tiebreak). Raw UTC Start/Finish
// serialized invariant — NOT the *Offset properties (those localize). Title resolved from the seed map.
private static string Snapshot(List<PlayoutItem> items, Dictionary<int, string> titles)
@@ -1176,6 +2042,12 @@ public class PlayoutBuildGoldenTests
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
// Committed YAML input fixtures (not golden outputs) for builders that read a schedule file. Note
// GoldenDir nests as Goldens/Goldens (this test file already lives under Goldens/), so fixtures sit in a
// sibling Goldens/Fixtures to keep inputs and snapshot outputs visually separate.
private static string FixtureDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() =>
@@ -66,7 +66,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
},
new List<MediaChapter>(),
new PlayoutBuildWarnings(),
_cancellationToken);
_cancellationToken).Items;
playoutItems.Count.ShouldBe(1);
}
@@ -125,7 +125,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
},
new List<MediaChapter> { new() },
new PlayoutBuildWarnings(),
_cancellationToken);
_cancellationToken).Items;
playoutItems.Count.ShouldBe(1);
}
@@ -192,7 +192,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
},
new PlayoutBuildWarnings(),
_cancellationToken);
_cancellationToken).Items;
playoutItems.Count.ShouldBe(3);
playoutItems[0].MediaItemId.ShouldBe(1);
@@ -284,7 +284,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
},
new PlayoutBuildWarnings(),
_cancellationToken);
_cancellationToken).Items;
playoutItems.Count.ShouldBe(5);
@@ -392,7 +392,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
],
new PlayoutBuildWarnings(),
_cancellationToken);
_cancellationToken).Items;
playoutItems.Count.ShouldBe(5);
@@ -501,7 +501,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
],
new PlayoutBuildWarnings(),
_cancellationToken);
_cancellationToken).Items;
playoutItems.Count.ShouldBe(6);
@@ -611,7 +611,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
},
new PlayoutBuildWarnings(),
_cancellationToken);
_cancellationToken).Items;
playoutItems.Count.ShouldBe(5);
@@ -719,7 +719,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
],
new PlayoutBuildWarnings(),
_cancellationToken);
_cancellationToken).Items;
playoutItems.Count.ShouldBe(5);
@@ -0,0 +1,263 @@
using ErsatzTV.Application.Streaming;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Streaming;
// Verifies the atomic-claim contract of the work-ahead slot pool (ersatztv#536): at most
// `limit` sessions may hold a slot at once, no matter how many race for one simultaneously.
//
// NEGATIVE CONTROL (per the ersatztv#231/#250 lesson — a one-shot Barrier + Task.WhenAll does NOT
// catch this class on this hardware): to prove these tests are non-vacuous, temporarily replace the
// compare-exchange in WorkAheadSlots.TryAcquire with the check-then-act shape this issue fixed —
//
// int current = Volatile.Read(ref _count);
// if (current >= limit) return false;
// Interlocked.Increment(ref _count);
// return true;
//
// — and TryAcquire_ParallelCallers_NeverExceedsLimit must FAIL (badRounds > 0). Do NOT "break" it by
// stubbing `if (true)`: that leaves values assigned-but-never-read, and CS0219 under
// warnings-as-errors fails the build silently, so `dotnet test --no-build` then runs the STALE
// (fixed) dll and the control falsely passes. Always grep the build output for `error CS` first.
[TestFixture]
public class WorkAheadSlotsTests
{
[Test]
public void TryAcquire_BelowLimit_Succeeds()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(2).ShouldBeTrue();
slots.TryAcquire(2).ShouldBeTrue();
slots.TryAcquire(2).ShouldBeFalse();
slots.Count.ShouldBe(2);
}
[Test]
public void TryAcquire_DoesNotConsumeASlotWhenItFails()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(1).ShouldBeTrue();
slots.TryAcquire(1).ShouldBeFalse();
slots.Count.ShouldBe(1);
// the failed attempt must not have leaked a slot: releasing the one real holder frees the pool
slots.Release();
slots.Count.ShouldBe(0);
slots.TryAcquire(1).ShouldBeTrue();
}
[Test]
public void TryAcquire_WithNonPositiveLimit_NeverSucceeds()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(0).ShouldBeFalse();
slots.TryAcquire(-1).ShouldBeFalse();
slots.Count.ShouldBe(0);
}
// The pool is process-wide and never recreated, so a negative count would not self-heal: it would
// permanently admit more than `limit` unthrottled transcodes, with nothing in the logs to find it by.
[Test]
public void Release_WithoutAcquire_ClampsAtZeroAndIsRecorded()
{
var slots = new WorkAheadSlots();
slots.Release();
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(1);
// the budget is intact: a limit of 1 still admits exactly one holder, not two
slots.TryAcquire(1).ShouldBeTrue();
slots.TryAcquire(1).ShouldBeFalse();
}
[Test]
public void UnbalancedReleases_IsZeroUnderCorrectUse()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(1).ShouldBeTrue();
slots.Release();
slots.UnbalancedReleases.ShouldBe(0);
}
// Hammers the acquire race over many rounds rather than a single simultaneous burst: the
// read->increment window is far too narrow to collide reliably when threads release once.
[Test]
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void TryAcquire_ParallelCallers_NeverExceedsLimit(int limit)
{
const int threads = 8;
const int rounds = 20_000;
var slots = new WorkAheadSlots();
var winners = 0;
var badRounds = 0;
using var startRound = new Barrier(threads);
using var endRound = new Barrier(
threads,
_ =>
{
int held = Volatile.Read(ref winners);
if (held > limit || held != slots.Count)
{
Interlocked.Increment(ref badRounds);
}
// reset for the next round: release every slot claimed this round
for (var i = 0; i < held; i++)
{
slots.Release();
}
Volatile.Write(ref winners, 0);
});
var workers = new Thread[threads];
for (var t = 0; t < threads; t++)
{
workers[t] = new Thread(() =>
{
for (var r = 0; r < rounds; r++)
{
startRound.SignalAndWait();
if (slots.TryAcquire(limit))
{
Interlocked.Increment(ref winners);
}
endRound.SignalAndWait();
}
});
workers[t].Start();
}
foreach (Thread worker in workers)
{
worker.Join();
}
badRounds.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(0);
}
// §1 (ersatztv#539): an unbalanced Release() must never publish a NEGATIVE count, even
// transiently. The pre-#539 shape decremented FIRST (0 -> -1) and clamped afterwards, so a
// concurrent TryAcquire(limit) could read the -1, see "-1 < limit" as phantom room, and admit a
// holder the budget doesn't have (the second acquirer then reads 0 and admits another). Because
// TryAcquire is the sole, CAS-guarded increment path, a count that is provably never negative is
// exactly what forecloses that over-admit. Here one thread hammers unbalanced releases on an
// empty pool while readers sample the count; none may ever observe a value below zero.
//
// NEGATIVE CONTROL (per the class-level ersatztv#231/#250 lesson) — revert Release() to its
// pre-#539 decrement-first body to prove this test is non-vacuous:
//
// if (Interlocked.Decrement(ref _count) >= 0) return true;
// Interlocked.Increment(ref _unbalancedReleases);
// while (true) { int c = Volatile.Read(ref _count);
// if (c >= 0 || Interlocked.CompareExchange(ref _count, 0, c) == c) return false; }
//
// — and this test must FAIL (sawNegative > 0): the readers catch the transient -1. Do NOT stub
// `if (true)`: that leaves values assigned-but-never-read, and CS0219 under warnings-as-errors
// fails the build silently so `dotnet test --no-build` runs the STALE dll and the control falsely
// passes. Always grep the build output for `error CS` first.
[Test]
public void Release_Unbalanced_NeverPublishesNegativeCount()
{
const int releases = 2_000_000;
const int readers = 4;
var slots = new WorkAheadSlots();
var sawNegative = 0;
var done = false;
var readerThreads = new Thread[readers];
for (var i = 0; i < readers; i++)
{
readerThreads[i] = new Thread(() =>
{
while (!Volatile.Read(ref done))
{
if (slots.Count < 0)
{
Interlocked.Increment(ref sawNegative);
}
}
});
readerThreads[i].Start();
}
// every release finds the pool empty, so every one is unbalanced
for (var r = 0; r < releases; r++)
{
slots.Release();
}
Volatile.Write(ref done, true);
foreach (Thread reader in readerThreads)
{
reader.Join();
}
sawNegative.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(releases);
}
// The release path is the fiddly half: a slot freed by its owner must become available again,
// and a burst of acquire/release cycles must not drift the count in either direction.
[Test]
public void AcquireAndRelease_UnderContention_LeavesNoLeakedOrDoubleFreedSlots()
{
const int threads = 8;
const int rounds = 20_000;
const int limit = 3;
var slots = new WorkAheadSlots();
var overLimit = 0;
var live = 0;
var workers = new Thread[threads];
for (var t = 0; t < threads; t++)
{
workers[t] = new Thread(() =>
{
for (var r = 0; r < rounds; r++)
{
if (!slots.TryAcquire(limit))
{
continue;
}
if (Interlocked.Increment(ref live) > limit)
{
Interlocked.Increment(ref overLimit);
}
Interlocked.Decrement(ref live);
slots.Release();
}
});
workers[t].Start();
}
foreach (Thread worker in workers)
{
worker.Join();
}
overLimit.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(0);
}
}
@@ -38,7 +38,10 @@ public record ChannelDetailResponseModel(
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg);
bool ShowInEpg,
int[] GraphicsElementIds,
// Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel.
ChannelHealthResponseModel Health);
// Wire-compatible mirror of the Application-layer ArtworkContentTypeModel (which lives in
// ErsatzTV.Application and therefore can't be referenced from Core). Same serialized shape the
@@ -0,0 +1,36 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
// Server-derived per-channel health. The SPA and MCP both read Status/Faults rather than deriving a
// verdict themselves (the api.channel-health-object decision, superseding #72's raw-fact-only stance).
// Faults are plain strings (see ChannelFault) not a C# enum, to keep the OpenAPI schema simple — the
// SPA hand-maintains its own union, matching the ChannelPreviewAvailability pattern.
public record ChannelHealthResponseModel(
// One of ChannelHealthStatus's values.
string Status,
// The specific fault classes that fired (each a ChannelFault value); empty when Healthy/Unknown.
string[] Faults,
// Retained #72 fact: total playouts (mirror-aware).
int PlayoutCount,
// Count of upcoming built items pointing at a FileNotFound/Unavailable MediaItem; 0 when none.
int BrokenSourceItemCount);
public static class ChannelHealthStatus
{
public const string Healthy = "Healthy";
public const string Problems = "Problems";
public const string Unknown = "Unknown";
}
public static class ChannelFault
{
public const string NoPlayout = "NoPlayout";
public const string NeverBuilt = "NeverBuilt";
public const string BuildFailed = "BuildFailed";
public const string EmptyUpcoming = "EmptyUpcoming";
public const string BrokenSource = "BrokenSource";
}
// Per-playout upcoming-item aggregate (Task 2's repository query fills this). Lives in Core so both
// the repository interface (Core) and Mapper (Application) can reference it.
public readonly record struct PlayoutUpcoming(int TotalUpcoming, int BrokenUpcoming);
@@ -0,0 +1,32 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
/// <summary>Server-declared browser-preview capability for a channel.</summary>
/// <remarks>
/// The SPA renders and acts on this; it never derives preview eligibility itself (see
/// docs/decisions.md, api.healthcheck-remediation-dto for the same pattern). Deriving it in the
/// SPA would mean keying behavior off the human-readable StreamingMode label.
/// </remarks>
public record ChannelPreviewResponseModel(
// One of ChannelPreviewAvailability's values. A plain string (not a C# enum), which keeps the
// OpenAPI schema simple, but the tradeoff is that the generated TypeScript types this as a bare
// `string`, not a literal union — the SPA hand-maintains its own `ChannelPreviewAvailability`
// union (web/src/api/channels.ts) and narrows against it, rather than getting one for free.
string Availability,
// Rooted, directly-usable HLS manifest URL; null when Availability is Unavailable.
string? ManifestUrl,
// Human-readable reason; non-null only when Availability is Unavailable.
string? UnavailableReason);
public static class ChannelPreviewAvailability
{
/// <summary>The channel's configured mode is browser-playable; preview exercises the real pipeline.</summary>
public const string Available = "Available";
/// <summary>Configured for Transport Stream; preview must force an HLS session and is content-only.</summary>
public const string ForcedHlsOnly = "ForcedHlsOnly";
/// <summary>Preview cannot run at all (IPTV JWT auth is enabled and the SPA cannot mint a token).</summary>
public const string Unavailable = "Unavailable";
}
@@ -1,4 +1,5 @@
#nullable enable
using ErsatzTV.Core.Domain;
using Newtonsoft.Json;
namespace ErsatzTV.Core.Api.Channels;
@@ -19,4 +20,11 @@ public record ChannelResponseModel(
int PlayoutCount,
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
string? Logo);
string? Logo,
// Server-declared browser-preview capability; see ChannelPreviewResponseModel.
ChannelPreviewResponseModel Preview,
// Immutable creation-provenance (auto-tuned vs user-created). Raw fact; the SPA decides how to render it.
// Unknown for rows created before the origin column existed (never back-filled).
ChannelOrigin Origin,
// Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel.
ChannelHealthResponseModel Health);
@@ -0,0 +1,20 @@
namespace ErsatzTV.Core.Api.Channels;
// The "clear to none" signal for POST /api/v1/channels/from-lineup (#135). For these
// template-inheritable advanced fields a null/omitted override means INHERIT the template value;
// naming the field here forces it to NONE on the new channel even when the template sets one.
// Omitting the field entirely keeps the historical omitted=inherit behavior stable for existing
// clients. Sending both a set value and a clear for the same field is a validation error (see
// CreateChannelFromLineupHandler). Lives in Core so the OpenAPI string-enum scan (Startup
// UseStringEnumSchemas) renders it as a string enum, matching every sibling advanced-options enum.
public enum CreateChannelFromLineupClearField
{
Watermark,
FallbackFiller,
PreRollFiller,
MidRollFiller,
PostRollFiller,
PreferredAudioLanguage,
PreferredAudioTitle,
PreferredSubtitleLanguage
}
@@ -1,4 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Graphics;
public record GraphicsElementResponseModel(int Id, string Name);
public record GraphicsElementResponseModel(int Id, string Name, bool BuiltIn);
@@ -7,6 +7,7 @@ public record PlayoutListItemResponseModel(
int Id,
string ChannelNumber,
string ChannelName,
int ChannelId,
PlayoutScheduleKind ScheduleKind,
string ScheduleName,
TimeSpan? DailyRebuildTime,
@@ -13,4 +13,5 @@ public record ProgramScheduleResponseModel(
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior);
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
/// <summary>
/// Distinct term values for a single text field in the search index, used to power the visual rule
/// builder's facet-value typeahead.
/// </summary>
public record SearchFieldValuesResponseModel(List<string> Values);
+5
View File
@@ -25,6 +25,8 @@ public class Channel
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
public List<GraphicsElement> GraphicsElements { get; set; }
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; }
@@ -43,5 +45,8 @@ public class Channel
public ChannelIdleBehavior IdleBehavior { get; set; }
public bool IsEnabled { get; set; }
public bool ShowInEpg { get; set; }
// Immutable creation-provenance (auto-tuned vs user-created); stamped once at insert, never on edit.
public ChannelOrigin Origin { get; set; }
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class ChannelGraphicsElement
{
public int ChannelId { get; set; }
public Channel Channel { get; set; }
public int GraphicsElementId { get; set; }
public GraphicsElement GraphicsElement { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain;
// How a Channel row came to exist. This is immutable creation-provenance: it records how the channel
// was born and a later user edit never changes it. Unknown is the honest default for rows that predate
// this column — provenance was never recorded for them and is deliberately not back-filled (inferring it
// from the "Channel Lineups" system playlist group is the mislabeling heuristic #414 rejected).
public enum ChannelOrigin
{
Unknown = 0,
UserCreated = 1,
AutoTuned = 2
}
+1
View File
@@ -25,6 +25,7 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
+2
View File
@@ -16,6 +16,8 @@ public class GraphicsElement
public List<BlockItemGraphicsElement> BlockItemGraphicsElements { get; set; }
public List<Deco> Decos { get; set; }
public List<DecoGraphicsElement> DecoGraphicsElements { get; set; }
public List<Channel> Channels { get; set; }
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
// for unit testing
public override string ToString() => Path;
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public class JellyfinMusicVideo : MusicVideo
{
public string ItemId { get; set; }
public string Etag { get; set; }
}
+2 -1
View File
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Domain;
@@ -12,6 +12,7 @@ public class ProgramSchedule : IVersionedAggregate
public bool ShuffleScheduleItems { get; set; }
public bool RandomStartPoint { get; set; }
public FixedStartTimeBehavior FixedStartTimeBehavior { get; set; }
public int? PadToNearestMinute { get; set; }
public List<ProgramScheduleItem> Items { get; set; }
public List<Playout> Playouts { get; set; }
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
@@ -1043,9 +1043,11 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
// Percent-encode so a token containing '&'/'"' can't malform this internal segmenter URL (#421);
// a no-op for a normal base64url JWT.
string accessTokenQuery = string.IsNullOrWhiteSpace(accessToken)
? string.Empty
: $"&access_token={accessToken}";
: $"&access_token={Uri.EscapeDataString(accessToken)}";
var concatInputFile = new ConcatInputFile(
$"http://localhost:{Settings.StreamingPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter{accessTokenQuery}",
@@ -135,6 +135,15 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
// channel-level overlays are a base layer: merged with playout-item / Merge-deco elements,
// but suppressed by a deco in Override/Disable mode (which returns before reaching here).
if (channel.ChannelGraphicsElements is not null)
{
result.AddRange(
channel.ChannelGraphicsElements.Map(cge =>
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
}
return result;
}
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Graphics;
public static class GraphicsElementDefaults
{
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
public const string OnNowNextFileName = "on-now-next.yml";
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Emby;
@@ -8,22 +9,40 @@ public interface IEmbyApiClient
Task<Either<BaseError, EmbyServerInformation>> GetServerInformation(string address, string apiKey);
Task<Either<BaseError, List<EmbyLibrary>>> GetLibraries(string address, string apiKey);
IAsyncEnumerable<Tuple<EmbyMovie, int>> GetMovieLibraryItems(string address, string apiKey, EmbyLibrary library);
// #484: the four enumerations that feed a reconciliation sweep accept an optional per-enumeration
// counter -- the two library-level ones (movies, shows) AND the two nested ones (seasons per show,
// episodes per season), which feed the per-parent sweeps in MediaServerTelevisionLibraryScanner.
// The caller creates one instance per enumeration, scoped to the parent it sweeps, and reads it
// only after that enumeration completes; passing null (the default) opts out and costs existing
// callers nothing.
IAsyncEnumerable<Tuple<EmbyMovie, int>> GetMovieLibraryItems(
string address,
string apiKey,
EmbyLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<EmbyShow, int>> GetShowLibraryItems(string address, string apiKey, EmbyLibrary library);
IAsyncEnumerable<Tuple<EmbyShow, int>> GetShowLibraryItems(
string address,
string apiKey,
EmbyLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null);
// #484: the nested per-show season and per-season episode enumerations feed their own sweeps
// (FlagFileNotFoundSeasons / FlagFileNotFoundEpisodes), so they carry the same optional counter.
IAsyncEnumerable<Tuple<EmbySeason, int>> GetSeasonLibraryItems(
string address,
string apiKey,
EmbyLibrary library,
string showId);
string showId,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
string address,
string apiKey,
EmbyLibrary library,
string showId,
string seasonId);
string seasonId,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<EmbyCollection, int>> GetCollectionLibraryItems(string address, string apiKey);
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Jellyfin;
@@ -8,38 +9,52 @@ public interface IJellyfinApiClient
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string authorizationHeader);
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string authorizationHeader);
// #484: the six enumerations that feed a reconciliation sweep accept an optional per-enumeration
// counter -- the three library-level ones (movies, music videos, shows) AND the three nested ones
// (seasons per show, episodes per season, both episode variants), which feed the per-parent sweeps
// in MediaServerTelevisionLibraryScanner. The caller creates one instance per enumeration, scoped
// to the parent it sweeps, and reads it only after that enumeration completes; passing null (the
// default) opts out and costs existing callers nothing.
IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library);
JellyfinLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> GetMusicVideoLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library);
JellyfinLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
string address,
string authorizationHeader,
JellyfinLibrary library);
JellyfinLibrary library,
MediaServerProjectionFailureCounter projectionFailures = null);
// #484: the nested per-show season and per-season episode enumerations feed their own sweeps
// (FlagFileNotFoundSeasons / FlagFileNotFoundEpisodes), so they carry the same optional counter.
IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library,
string showId);
string showId,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
string address,
string authorizationHeader,
JellyfinLibrary library,
string seasonId);
string seasonId,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
string address,
string authorizationHeader,
JellyfinLibrary library,
string seasonId);
string seasonId,
MediaServerProjectionFailureCounter projectionFailures = null);
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
string address,
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
@@ -8,4 +9,9 @@ public interface IChannelRepository
Task<Option<Channel>> GetByNumber(string number);
Task<List<Channel>> GetAll(CancellationToken cancellationToken);
Task<Option<ChannelWatermark>> GetWatermarkByName(string name);
Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
IReadOnlyCollection<int> playoutIds,
DateTime nowUtc,
CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface
IJellyfinMusicVideoRepository : IMediaServerMusicVideoRepository<JellyfinLibrary, JellyfinMusicVideo,
JellyfinItemEtag>
{
}
@@ -0,0 +1,37 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IMediaServerMusicVideoRepository<in TLibrary, TMusicVideo, TEtag> where TLibrary : Library
where TMusicVideo : MusicVideo
where TEtag : MediaServerItemEtag
{
Task<List<TEtag>> GetExistingMusicVideos(TLibrary library);
Task<Option<int>> FlagNormal(TLibrary library, TMusicVideo musicVideo);
Task<Option<int>> FlagUnavailable(TLibrary library, TMusicVideo musicVideo);
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> musicVideoItemIds);
// Unlike the movie/other-video seams, GetOrAdd takes the resolved Artist and LibraryFolder: a music video is
// owned by an Artist (FK, not null) and ersatztv#488 requires the media file to carry its LibraryFolderId.
//
// `localPath` is the PATH-REPLACED local path and is the only path this seam may store or match on. The
// `item` argument still carries the path the media server reported, which for any install with configured
// path replacements is a DIFFERENT string — matching or storing that one would miss the row it is meant to
// find and duplicate it under a server-side path (ersatztv#496).
Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> GetOrAdd(
TLibrary library,
Artist artist,
LibraryFolder libraryFolder,
TMusicVideo item,
string localPath,
bool deepScan,
CancellationToken cancellationToken);
// ersatztv#496: music videos that predate per-item identity carry no TEtag row, so they are invisible to the
// itemId diff above. They are reconciled by their local path until a scan adopts them.
Task<List<string>> GetExistingLegacyMusicVideoPaths(TLibrary library);
Task<List<int>> FlagFileNotFoundByPaths(TLibrary library, List<string> localPaths);
Task<Unit> SetEtag(TMusicVideo musicVideo, string etag);
}
@@ -2,5 +2,14 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IPlayoutTimeShifter
{
Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
/// <summary>
/// Slides an on-demand playout's materialized timeline forward so the item the viewer had
/// reached is active again at <paramref name="now" />.
/// </summary>
/// <returns>
/// The channel numbers whose cached XMLTV guide is now stale and should be rebuilt — the shifted
/// channel plus any channels that mirror it — when a non-zero shift was persisted; otherwise an
/// empty list.
/// </returns>
Task<List<string>> TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
}
+7 -2
View File
@@ -37,8 +37,13 @@ public class ChannelPlaylist
string accessTokenUriAmp = string.Empty;
if (_accessToken != null)
{
accessTokenUri = $"?access_token={_accessToken}";
accessTokenUriAmp = $"&access_token={_accessToken}";
// Percent-encode the token: it is interpolated into URL query strings that land inside quoted
// M3U attributes (url-tvg="...", tvg-logo="..."). A token containing a double-quote or ampersand
// would otherwise terminate the attribute or the query early for strict parsers (#421). For a
// normal base64url JWT this is a no-op (all characters are RFC 3986 unreserved).
string encodedToken = Uri.EscapeDataString(_accessToken);
accessTokenUri = $"?access_token={encodedToken}";
accessTokenUriAmp = $"&access_token={encodedToken}";
}
var xmltv = $"{_scheme}://{_host}{_baseUrl}/iptv/xmltv.xml{accessTokenUri}";
@@ -0,0 +1,23 @@
namespace ErsatzTV.Core.Metadata;
// #484: the narrow seam that carries "how many items did the server return that we silently dropped
// because their projection threw?" out of a media-server API client and back to the scanner that owns
// the reconciliation sweep.
//
// Lifetime is deliberately PER ENUMERATION: the scanner that is about to run a sweep creates one
// instance, hands it to the single library-items call whose result it will diff, and reads Count only
// after that enumeration has completed. The API clients are long-lived singletons and scans for
// different libraries run concurrently, so the counter must never be a field on a client or any
// ambient/static state — that would leak one library's failures into another library's sweep decision.
// Increments are interlocked anyway so a paginator that ever fans out stays correct.
public sealed class MediaServerProjectionFailureCounter
{
private int _count;
/// <summary>
/// Number of items the server returned whose projection threw and was swallowed.
/// </summary>
public int Count => Volatile.Read(ref _count);
public void RecordFailure() => Interlocked.Increment(ref _count);
}
@@ -0,0 +1,49 @@
namespace ErsatzTV.Core.Metadata;
// #484: a media-server API client maps each item the server returned through a private projection. That
// projection has TWO reasons to produce nothing, and conflating them is dangerous:
//
// - Skipped — a deliberate guard clause (a virtual/non-FileSystem item, a STRM file, an unsupported
// item type). The item is permanently and expectedly absent from the incoming set; a
// library containing one STRM file produces a Skipped on every single scan, forever.
// - Failed — the projection threw and was swallowed by a `catch { LogWarning; }`. The server DID
// return the item; we simply could not build it. At the reconcile step that is
// indistinguishable from a deletion, so a projection regression can mass-flag healthy
// items FileNotFound (which EmptyTrash then deletes permanently).
//
// Only Failed may suppress the reconciliation sweep (see MediaServerReconciliationGuard). Treating
// Skipped as a failure would permanently disable reconciliation for any library holding a single STRM
// file, so stale rows would accumulate forever — a regression, not a safe default.
public readonly struct MediaServerProjectionResult<T>
{
private MediaServerProjectionResult(Option<T> item, bool isFailure)
{
Item = item;
IsFailure = isFailure;
}
public Option<T> Item { get; }
/// <summary>
/// True only when the projection threw. A deliberate guard-clause skip is NOT a failure.
/// </summary>
public bool IsFailure { get; }
public static MediaServerProjectionResult<T> Projected(T item) => new(item, false);
/// <summary>
/// The server returned an item we deliberately and permanently do not import (virtual item, STRM
/// file, unsupported type). Expected on every scan; never suppresses a sweep.
/// </summary>
public static MediaServerProjectionResult<T> Skipped() => new(Option<T>.None, false);
/// <summary>
/// The projection threw and the exception was swallowed. The item exists upstream but is missing
/// from the incoming set, so the sweep must not run.
/// </summary>
public static MediaServerProjectionResult<T> Failed() => new(Option<T>.None, true);
public static implicit operator MediaServerProjectionResult<T>(T item) => Projected(item);
public Option<T> ToOption() => Item;
}
@@ -0,0 +1,47 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// Builds the stateless-index <see cref="IMediaCollectionEnumerator" /> for a single content entry from its
/// <see cref="PlaybackOrder" /> and flat item list.
/// <para>
/// Extracted (issue #395) so the Scripted engine (<c>SchedulingEngine.EnumeratorForContent</c>) and the
/// Sequential/YAML engine (<c>EnumeratorCache.GetEnumeratorForContent</c>) share ONE construction switch
/// instead of two byte-identical copies. Kept a <c>public static</c> helper with dependencies passed as
/// parameters, matching the sibling <see cref="ShuffleSourceBuilder" /> / <see cref="MultiPartEpisodeGrouper" />
/// seams (issue #380).
/// </para>
/// <para>
/// Deliberately per-family and narrow: only <see cref="PlaybackOrder.Chronological" /> and
/// <see cref="PlaybackOrder.Shuffle" /> are supported, and Shuffle maps to the <em>block</em> variant
/// <see cref="BlockPlayoutShuffledMediaCollectionEnumerator" /> — NOT Classic's
/// <see cref="ShuffledMediaCollectionEnumerator" />, which is a different algorithm. Every other order returns
/// <see cref="Option{A}" />.<c>None</c> so each caller logs its own engine-specific "not supported" warning
/// (#70); folding the warning in here would lose that per-engine message.
/// </para>
/// </summary>
public static class ContentEnumeratorBuilder
{
public static Option<IMediaCollectionEnumerator> ForContent(
List<MediaItem> items,
CollectionEnumeratorState state,
PlaybackOrder playbackOrder,
bool multiPart)
{
switch (playbackOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
List<GroupedMediaItem> groupedMediaItems = multiPart
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
default:
return Option<IMediaCollectionEnumerator>.None;
}
}
}
@@ -1225,25 +1225,19 @@ public class SchedulingEngine(
PlaybackOrder playbackOrder,
bool multiPart = false)
{
switch (playbackOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
bool keepMultiPartEpisodesTogether = multiPart;
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
}
Option<IMediaCollectionEnumerator> enumerator =
ContentEnumeratorBuilder.ForContent(items, state, playbackOrder, multiPart);
// 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);
if (enumerator.IsNone)
{
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
playbackOrder);
}
return Option<IMediaCollectionEnumerator>.None;
return enumerator;
}
private void ApplyPlaylistHistory(
+54 -3
View File
@@ -115,13 +115,36 @@ public class PlayoutBuilder : IPlayoutBuilder
PlayoutBuildResult result,
PlayoutBuildMode mode,
PlayoutParameters parameters,
CancellationToken cancellationToken) =>
mode switch
CancellationToken cancellationToken)
{
// #392: the build query does not populate the ProgramScheduleItem.ProgramSchedule reverse nav
// (AsNoTracking). Populate it so schedule-level settings (PadToNearestMinute) are readable in AddFiller.
if (referenceData.ProgramSchedule?.Items is not null)
{
foreach (ProgramScheduleItem item in referenceData.ProgramSchedule.Items)
{
item.ProgramSchedule = referenceData.ProgramSchedule;
}
}
foreach (ProgramScheduleAlternate alternate in referenceData.ProgramScheduleAlternates)
{
if (alternate.ProgramSchedule?.Items is not null)
{
foreach (ProgramScheduleItem item in alternate.ProgramSchedule.Items)
{
item.ProgramSchedule = alternate.ProgramSchedule;
}
}
}
return mode switch
{
PlayoutBuildMode.Refresh => RefreshPlayout(playout, referenceData, result, parameters, cancellationToken),
PlayoutBuildMode.Reset => ResetPlayout(playout, referenceData, result, parameters, cancellationToken),
_ => ContinuePlayout(playout, referenceData, result, parameters, cancellationToken)
};
}
internal async Task<Either<BaseError, PlayoutBuildResult>> Build(
Playout playout,
@@ -702,6 +725,10 @@ public class PlayoutBuilder : IPlayoutBuilder
}
var copyScheduleItem = scheduleItem.DeepCopy();
// DeepCopy uses Newtonsoft serialization, and ProgramSchedule is [JsonIgnore]'d there,
// so the reverse nav is lost on the copy. Reassign it so schedule-level settings
// (e.g. PadToNearestMinute) remain readable in AddFiller for Fill-With-Group items.
copyScheduleItem.ProgramSchedule = scheduleItem.ProgramSchedule;
copyScheduleItem.CollectionType = key.CollectionType;
copyScheduleItem.MediaItemId = key.MediaItemId;
copyScheduleItem.FakeCollectionKey = key.FakeCollectionKey;
@@ -806,6 +833,12 @@ public class PlayoutBuilder : IPlayoutBuilder
var timeCount = new Dictionary<DateTimeOffset, int>();
// #392: exact clock-boundary target the LAST scheduled item advanced CurrentTime to when a
// schedule-level pad left an offline gap (no filler). The post-loop anchor clamp reads this by
// exact CurrentTime equality so the offline advance survives the day seam — precise (tied to the
// real offline-pad signal), not a magnitude heuristic.
DateTimeOffset? lastClockPadOfflineTarget = null;
// loop until we're done filling the desired amount of time
while (playoutBuilderState.CurrentTime < playoutFinish && !cancellationToken.IsCancellationRequested)
{
@@ -880,6 +913,10 @@ public class PlayoutBuilder : IPlayoutBuilder
(PlayoutBuilderState nextState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings warnings) =
schedulerResult;
// #392: remember the last scheduler's offline-pad target (null unless the final scheduled item
// advanced CurrentTime to a clock boundary with no filler); the anchor clamp below reads it.
lastClockPadOfflineTarget = schedulerResult.ClockPadOfflineTarget;
result.Warnings.Merge(warnings);
// if we completed a multiple/duration block, move to the next fill group
@@ -924,7 +961,21 @@ public class PlayoutBuilder : IPlayoutBuilder
DateTimeOffset maxStartTime = result.AddedItems.Max(i => i.FinishOffset);
if (maxStartTime < playoutBuilderState.CurrentTime)
{
playoutBuilderState = playoutBuilderState with { CurrentTime = maxStartTime };
// #392: a schedule-level clock pad can legitimately advance CurrentTime past the last added
// item with NO corresponding filler item (an intentional offline gap up to the next clock
// boundary). Exempt the clamp ONLY when CurrentTime is exactly the offline-pad target the
// final scheduled item recorded — a precise signal, not a magnitude heuristic. Any other
// advance (a Duration/Flood/Multiple block ending short of its natural end, a hard-stop, a
// tail-filler advance) changes CurrentTime away from that target, so it still clamps back to
// the real content end and persists the correct NextStart.
bool isOfflinePadAdvance =
lastClockPadOfflineTarget is { } offlinePadTarget &&
playoutBuilderState.CurrentTime == offlinePadTarget;
if (!isOfflinePadAdvance)
{
playoutBuilderState = playoutBuilderState with { CurrentTime = maxStartTime };
}
}
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using ErsatzTV.Core.Domain;
@@ -279,7 +279,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
PlayoutBuilder.DisplayTitle(mediaItem),
startTime);
internal List<PlayoutItem> AddFiller(
internal (List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget) AddFiller(
PlayoutBuilderState playoutBuilderState,
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
ProgramScheduleItem scheduleItem,
@@ -290,6 +290,8 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
{
var result = new List<PlayoutItem>();
DateTimeOffset? offlinePadTarget = null;
var allFiller = Optional(scheduleItem.PreRollFiller)
.Append(Optional(scheduleItem.MidRollFiller))
.Append(Optional(scheduleItem.PostRollFiller))
@@ -299,7 +301,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
if (allFiller.Count(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue) > 1)
{
Logger.LogError("Multiple pad-to-nearest-minute values are invalid; no filler will be used");
return [playoutItem];
return ([playoutItem], null);
}
// missing pad-to-nearest-minute value is invalid; use no filler
@@ -310,7 +312,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
Logger.LogError(
"Pad filler ({Filler}) without pad-to-nearest-minute value is invalid; no filler will be used",
invalidPadFiller.Name);
return [playoutItem];
return ([playoutItem], null);
}
List<MediaChapter> effectiveChapters = chapters;
@@ -580,6 +582,48 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
}
}
// #392: schedule-level clock padding. Applies only when the item has no own Pad filler preset (that
// wins) and the parent schedule declares a positive divisor. Reuses the existing pad boundary math +
// FallbackFillerForPad; when no fallback content exists, records an offline target so the caller
// advances the build clock to the boundary (leaving an offline gap).
bool itemHasPadFiller =
allFiller.Any(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue);
if (!itemHasPadFiller &&
scheduleItem.ProgramSchedule?.PadToNearestMinute is int schedulePadMinutes &&
schedulePadMinutes > 0)
{
TimeSpan totalDuration = result.Aggregate(
TimeSpan.Zero,
(acc, i) => acc + (i.FinishOffset - i.StartOffset));
DateTimeOffset targetTime = ComputePadBoundary(
playoutItem.StartOffset, totalDuration, schedulePadMinutes);
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
if (remainingToFill > TimeSpan.Zero)
{
Option<PlayoutItem> maybeFallback = FallbackFillerForPad(
playoutBuilderState,
enumerators,
scheduleItem,
remainingToFill,
cancellationToken);
if (maybeFallback.IsSome)
{
foreach (PlayoutItem fallbackItem in maybeFallback)
{
result.Add(fallbackItem);
}
}
else
{
// No fallback content: leave an offline gap up to the boundary.
offlinePadTarget = targetTime;
}
}
}
// after all non-padded filler has been added, figure out padding
foreach (FillerPreset padFiller in Optional(
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
@@ -601,29 +645,9 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
totalDuration += TimeSpan.FromTicks(filteredChapters.Sum(c => (c.EndTime - c.StartTime).Ticks));
}
int currentMinute = (playoutItem.StartOffset + totalDuration).Minute;
// ReSharper disable once PossibleInvalidOperationException
int targetMinute = (currentMinute + padFiller.PadToNearestMinute.Value - 1) /
padFiller.PadToNearestMinute.Value * padFiller.PadToNearestMinute.Value;
DateTimeOffset almostTargetTime = playoutItem.StartOffset + totalDuration -
TimeSpan.FromMinutes(currentMinute) +
TimeSpan.FromMinutes(targetMinute);
var targetTime = new DateTimeOffset(
almostTargetTime.Year,
almostTargetTime.Month,
almostTargetTime.Day,
almostTargetTime.Hour,
almostTargetTime.Minute,
0,
almostTargetTime.Offset);
// ensure filler works for content less than one minute
if (targetTime <= playoutItem.StartOffset + totalDuration)
{
targetTime = targetTime.AddMinutes(padFiller.PadToNearestMinute.Value);
}
DateTimeOffset targetTime = ComputePadBoundary(
playoutItem.StartOffset, totalDuration, padFiller.PadToNearestMinute.Value);
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
@@ -763,7 +787,40 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
currentTime = item.FinishOffset;
}
return result;
return (result, offlinePadTarget);
}
// #392: shared clock-boundary ceiling used by both the item-level Pad filler and the schedule-level pad.
// Returns the next `padToNearestMinute` boundary at or after (blockStart + totalDuration); when the block
// already ends exactly on a boundary it advances one full interval (matches the pre-#392 Pad behavior).
private static DateTimeOffset ComputePadBoundary(
DateTimeOffset blockStart,
TimeSpan totalDuration,
int padToNearestMinute)
{
int currentMinute = (blockStart + totalDuration).Minute;
int targetMinute = (currentMinute + padToNearestMinute - 1) / padToNearestMinute * padToNearestMinute;
DateTimeOffset almostTargetTime = blockStart + totalDuration -
TimeSpan.FromMinutes(currentMinute) +
TimeSpan.FromMinutes(targetMinute);
var targetTime = new DateTimeOffset(
almostTargetTime.Year,
almostTargetTime.Month,
almostTargetTime.Day,
almostTargetTime.Hour,
almostTargetTime.Minute,
0,
almostTargetTime.Offset);
// ensure filler works for content less than one interval (and for content already on a boundary)
if (targetTime <= blockStart + totalDuration)
{
targetTime = targetTime.AddMinutes(padToNearestMinute);
}
return targetTime;
}
private List<PlayoutItem> AddCountFiller(
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
@@ -36,6 +36,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
var willFinishInTime = true;
Option<DateTimeOffset> durationUntil = None;
var discardAttempts = 0;
DateTimeOffset? clockPadOfflineTarget = null;
IMediaCollectionEnumerator contentEnumerator =
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
@@ -200,7 +201,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
enumeratorStates.Add(key, enumerator.State.Clone());
}
List<PlayoutItem> maybePlayoutItems = AddFiller(
(List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
nextState,
collectionEnumerators,
scheduleItem,
@@ -219,6 +220,12 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
// }
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
DateTimeOffset? itemClockPadOfflineTarget = null;
if (clockPadTarget is { } durPadTarget && durPadTarget > itemEndTimeWithFiller)
{
itemEndTimeWithFiller = durPadTarget;
itemClockPadOfflineTarget = durPadTarget;
}
willFinishInTime = itemStartTime > durationFinish ||
itemEndTimeWithFiller <= durationFinish;
@@ -226,6 +233,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
{
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
playoutItems.AddRange(maybePlayoutItems);
clockPadOfflineTarget = itemClockPadOfflineTarget;
nextState = nextState with
{
@@ -350,7 +358,10 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
{
ClockPadOfflineTarget = clockPadOfflineTarget
};
}
protected override string SchedulingContextName => "Duration";
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
@@ -23,6 +23,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
PlayoutBuilderState nextState = playoutBuilderState;
var willFinishInTime = true;
DateTimeOffset? clockPadOfflineTarget = null;
IMediaCollectionEnumerator contentEnumerator =
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
@@ -112,7 +113,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
enumeratorStates.Add(key, enumerator.State.Clone());
}
List<PlayoutItem> maybePlayoutItems = AddFiller(
(List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
nextState,
collectionEnumerators,
scheduleItem,
@@ -122,6 +123,12 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
cancellationToken);
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
DateTimeOffset? itemClockPadOfflineTarget = null;
if (clockPadTarget is { } floodPadTarget && floodPadTarget > itemEndTimeWithFiller)
{
itemEndTimeWithFiller = floodPadTarget;
itemClockPadOfflineTarget = floodPadTarget;
}
// if the next schedule item is supposed to start during this item,
// don't schedule this item and just move on
@@ -131,6 +138,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
if (willFinishInTime)
{
playoutItems.AddRange(maybePlayoutItems);
clockPadOfflineTarget = itemClockPadOfflineTarget;
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
nextState = nextState with
@@ -204,7 +212,10 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
{
ClockPadOfflineTarget = clockPadOfflineTarget
};
}
protected override string SchedulingContextName => "Flood";
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
@@ -77,6 +77,8 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
}
}
DateTimeOffset? clockPadOfflineTarget = null;
while (contentEnumerator.Current.IsSome && nextState.MultipleRemaining > 0 &&
nextState.CurrentTime < hardStop)
{
@@ -135,19 +137,30 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
playoutItems.AddRange(
AddFiller(
nextState,
collectionEnumerators,
scheduleItem,
playoutItem,
itemChapters,
warnings,
cancellationToken));
(List<PlayoutItem> filled, DateTimeOffset? clockPadTarget) = AddFiller(
nextState,
collectionEnumerators,
scheduleItem,
playoutItem,
itemChapters,
warnings,
cancellationToken);
playoutItems.AddRange(filled);
DateTimeOffset multipleEnd = playoutItems.Max(pi => pi.FinishOffset);
if (clockPadTarget is { } mulPadTarget && mulPadTarget > multipleEnd)
{
multipleEnd = mulPadTarget;
clockPadOfflineTarget = mulPadTarget;
}
else
{
clockPadOfflineTarget = null;
}
nextState = nextState with
{
CurrentTime = playoutItems.Max(pi => pi.FinishOffset),
CurrentTime = multipleEnd,
MultipleRemaining = nextState.MultipleRemaining.Map(i => i - 1),
// only bump guide group if we don't have a custom title
@@ -205,7 +218,10 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
{
ClockPadOfflineTarget = clockPadOfflineTarget
};
}
protected override string SchedulingContextName => "Multiple";
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
@@ -82,7 +82,7 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
});
}
List<PlayoutItem> playoutItems = AddFiller(
(List<PlayoutItem> playoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
playoutBuilderState,
collectionEnumerators,
scheduleItem,
@@ -91,9 +91,17 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
warnings,
cancellationToken);
DateTimeOffset oneEnd = playoutItems.Max(pi => pi.FinishOffset);
DateTimeOffset? clockPadOfflineTarget = null;
if (clockPadTarget is { } onePadTarget && onePadTarget > oneEnd)
{
oneEnd = onePadTarget;
clockPadOfflineTarget = onePadTarget;
}
PlayoutBuilderState nextState = playoutBuilderState with
{
CurrentTime = playoutItems.Max(pi => pi.FinishOffset)
CurrentTime = oneEnd
};
nextState.ScheduleItemsEnumerator.MoveNext();
@@ -133,7 +141,10 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
{
ClockPadOfflineTarget = clockPadOfflineTarget
};
}
return new PlayoutSchedulerResult(playoutBuilderState, [], warnings);
@@ -5,4 +5,11 @@ namespace ErsatzTV.Core.Scheduling;
public record PlayoutSchedulerResult(
PlayoutBuilderState State,
List<PlayoutItem> PlayoutItems,
PlayoutBuildWarnings Warnings);
PlayoutBuildWarnings Warnings)
{
// #392: the exact clock-boundary target the last scheduled item advanced CurrentTime to when a
// schedule-level pad left an OFFLINE gap (no filler emitted). Non-null ONLY for such an advance; the
// day-boundary anchor clamp in PlayoutBuilder reads it (by exact CurrentTime equality) so an offline
// pad advance survives the seam. Transient/in-memory only — never serialized to the PlayoutAnchor.
public DateTimeOffset? ClockPadOfflineTarget { get; init; }
}
@@ -180,26 +180,20 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
}
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
switch (parsedOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
bool keepMultiPartEpisodesTogether = content.MultiPart;
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
}
Option<IMediaCollectionEnumerator> enumerator =
ContentEnumeratorBuilder.ForContent(items, state, parsedOrder, content.MultiPart);
// 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);
if (enumerator.IsNone)
{
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;
return enumerator;
}
private record PlaylistKey(string ContentKey, CollectionKey CollectionKey);
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Filter;
using ErsatzTV.FFmpeg.Filter.Cuda;
using ErsatzTV.FFmpeg.Filter.Qsv;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.FFmpeg.Tests.Filter;
[TestFixture]
public class OverlayWatermarkFilterTests
{
private static readonly FrameSize Resolution = new(1920, 1080);
private static readonly FrameSize SquarePixelFrameSize = new(1920, 1080);
// a pillarboxed source: 1440x1080 of real content inside a 1920x1080 frame, so
// SourceContentMargins() adds half the 480px horizontal padding to the margin
private static readonly FrameSize PillarboxedSquarePixelFrameSize = new(1440, 1080);
private static WatermarkState StateFor(WatermarkLocation location, bool placeWithinSourceContent = false) =>
new(
Option<List<WatermarkFadePoint>>.None,
location,
WatermarkSize.ActualSize,
10,
10,
10,
100,
placeWithinSourceContent);
private static readonly (WatermarkLocation Location, string Expected)[] ExpectedPositions =
[
(WatermarkLocation.BottomRight, "x=W-w-192:y=H-h-108"),
(WatermarkLocation.BottomLeft, "x=192:y=H-h-108"),
(WatermarkLocation.TopRight, "x=W-w-192:y=108"),
(WatermarkLocation.TopLeft, "x=192:y=108"),
(WatermarkLocation.TopMiddle, "x=(W-w)/2:y=108"),
(WatermarkLocation.RightMiddle, "x=W-w-192:y=(H-h)/2"),
(WatermarkLocation.BottomMiddle, "x=(W-w)/2:y=H-h-108"),
(WatermarkLocation.LeftMiddle, "x=192:y=(H-h)/2"),
(WatermarkLocation.MiddleCenter, "x=(W-w)/2:y=(H-h)/2")
];
private static IEnumerable<TestCaseData> AllLocations() =>
ExpectedPositions.Select(x => new TestCaseData(x.Location, x.Expected).SetName(
$"{{m}}({x.Location})"));
[TestCaseSource(nameof(AllLocations))]
public void Software_Overlay_Maps_Every_Location_To_Exact_Position(WatermarkLocation location, string expectedPosition)
{
var filter = new OverlayWatermarkFilter(
StateFor(location),
Resolution,
SquarePixelFrameSize,
new PixelFormatUnknown(),
NullLogger.Instance);
filter.Filter.ShouldBe($"overlay={expectedPosition}:format=0");
}
[TestCaseSource(nameof(AllLocations))]
public void Cuda_Overlay_Maps_Every_Location_To_Exact_Position(WatermarkLocation location, string expectedPosition)
{
var filter = new OverlayWatermarkCudaFilter(
StateFor(location),
Resolution,
SquarePixelFrameSize,
NullLogger.Instance);
filter.Filter.ShouldBe($"overlay_cuda={expectedPosition}");
}
[TestCaseSource(nameof(AllLocations))]
public void Qsv_Overlay_Maps_Every_Location_To_Exact_Position(WatermarkLocation location, string expectedPosition)
{
var filter = new OverlayWatermarkQsvFilter(
StateFor(location),
Resolution,
SquarePixelFrameSize,
NullLogger.Instance);
filter.Filter.ShouldBe($"overlay_qsv={expectedPosition}");
}
// #503: the position switch has a catch-all default that silently renders bottom-right, so a
// newly added WatermarkLocation would compile, pass every case above, and reintroduce the exact
// bug this fixture exists to prevent. Fail here instead, at the point the enum grows.
[Test]
public void Every_WatermarkLocation_Has_An_Expected_Position()
{
IEnumerable<WatermarkLocation> covered = ExpectedPositions.Select(x => x.Location);
Enum.GetValues<WatermarkLocation>().Except(covered).ShouldBeEmpty(
"every WatermarkLocation needs an explicit arm in OverlayWatermarkFilter.Position and a "
+ "matching entry in ExpectedPositions; otherwise it silently renders bottom-right");
}
[Test]
public void Source_Content_Margins_Offset_By_Half_The_Pillarbox_Padding()
{
var filter = new OverlayWatermarkFilter(
StateFor(WatermarkLocation.BottomRight, placeWithinSourceContent: true),
Resolution,
PillarboxedSquarePixelFrameSize,
new PixelFormatUnknown(),
NullLogger.Instance);
// horizontal: round(0.10 * 1440 + 480 / 2) = 384; vertical: round(0.10 * 1080 + 0) = 108
filter.Filter.ShouldBe("overlay=x=W-w-384:y=H-h-108:format=0");
}
[Test]
public void Unmapped_Location_Falls_Back_To_BottomRight_Position()
{
var filter = new OverlayWatermarkFilter(
StateFor((WatermarkLocation)999),
Resolution,
SquarePixelFrameSize,
new PixelFormatUnknown(),
NullLogger.Instance);
filter.Filter.ShouldBe("overlay=x=W-w-192:y=H-h-108:format=0");
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
@@ -36,18 +36,35 @@ public class OverlayWatermarkFilter : BaseFilter
? SourceContentMargins()
: NormalMargins();
return _watermarkState.Location switch
switch (_watermarkState.Location)
{
// TODO: can these be pre-calculated (and used with accelerated overlay filters)
WatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
WatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
WatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
WatermarkLocation.TopMiddle => $"x=(W-w)/2:y={verticalMargin}",
WatermarkLocation.RightMiddle => $"x=W-w-{horizontalMargin}:y=(H-h)/2",
WatermarkLocation.BottomMiddle => $"x=(W-w)/2:y=H-h-{verticalMargin}",
WatermarkLocation.LeftMiddle => $"x={horizontalMargin}:y=(H-h)/2",
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
};
case WatermarkLocation.BottomLeft:
return $"x={horizontalMargin}:y=H-h-{verticalMargin}";
case WatermarkLocation.TopLeft:
return $"x={horizontalMargin}:y={verticalMargin}";
case WatermarkLocation.TopRight:
return $"x=W-w-{horizontalMargin}:y={verticalMargin}";
case WatermarkLocation.TopMiddle:
return $"x=(W-w)/2:y={verticalMargin}";
case WatermarkLocation.RightMiddle:
return $"x=W-w-{horizontalMargin}:y=(H-h)/2";
case WatermarkLocation.BottomMiddle:
return $"x=(W-w)/2:y=H-h-{verticalMargin}";
case WatermarkLocation.LeftMiddle:
return $"x={horizontalMargin}:y=(H-h)/2";
case WatermarkLocation.MiddleCenter:
return $"x=(W-w)/2:y=(H-h)/2";
case WatermarkLocation.BottomRight:
return $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}";
default:
// unmapped enum value; log loudly and fall back to the BottomRight position
// rather than throwing, since this runs on the playback hot path
_logger.LogWarning(
"Unrecognized watermark location {Location}; falling back to bottom-right overlay position",
_watermarkState.Location);
return $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}";
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ChannelGraphicsElement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ChannelGraphicsElement",
columns: table => new
{
ChannelId = table.Column<int>(type: "int", nullable: false),
GraphicsElementId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelGraphicsElement", x => new { x.ChannelId, x.GraphicsElementId });
table.ForeignKey(
name: "FK_ChannelGraphicsElement_Channel_ChannelId",
column: x => x.ChannelId,
principalTable: "Channel",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId",
column: x => x.GraphicsElementId,
principalTable: "GraphicsElement",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ChannelGraphicsElement_GraphicsElementId",
table: "ChannelGraphicsElement",
column: "GraphicsElementId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelGraphicsElement");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ProgramSchedule_PadToNearestMinute : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PadToNearestMinute",
table: "ProgramSchedule",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PadToNearestMinute",
table: "ProgramSchedule");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_Channel_Origin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Origin",
table: "Channel",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Origin",
table: "Channel");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_JellyfinMusicVideo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JellyfinMusicVideo",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
ItemId = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Etag = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_JellyfinMusicVideo", x => x.Id);
table.ForeignKey(
name: "FK_JellyfinMusicVideo_MusicVideo_Id",
column: x => x.Id,
principalTable: "MusicVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_JellyfinMusicVideo_ItemId",
table: "JellyfinMusicVideo",
column: "ItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JellyfinMusicVideo");
}
}
}

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