Compare commits

..
Author SHA1 Message Date
timothy 81996709b0 chore(release): re-trigger PR CI for v26.14.0
Prior attempts on this branch produced no pull_request run: two CLI pushes, a
close/reopen, and a body edit all yielded nothing, and the workflow_dispatch I
fired (run 1564) went green but writes NO commit statuses, so it cannot satisfy
the required Build & test / review-verdict/h10 checks.

The queue is now idle (run 1560, main @ a2b3a56d9, completed success), which is
the condition docs/ci-cd.md says matters -- back-to-back pushes get silently
dropped, a spaced-out one does not.

No [skip ci] token -- this branch's merge commit is the v26.14.0 tag target.
2026-08-05 00:30:55 +02:00
timothy 7b20dbd360 docs(release): fold #674/#688 into the v26.14.0 notes after rebasing on main
main moved while this branch was open: #725 (fix/674-688-decisions-validator)
merged, so v26.14.0 now also carries the decisions-validator PyYAML cross-check.
Rebased onto a2b3a56d9 rather than merging main in, per
release.format-as-you-touch-rebase.

The added claim is verified, not assumed: re-breaking this release's own
readrate-catchup record (unquoting rule: so an unquoted " #350" truncates it)
now fails decisions_validate with

  frontmatter key 'rule' means different things to the two parsers ...
  PyYAML wrote this file, so its reading is the real value and the record is
  silently corrupt. Common cause: an unquoted ` #` ...

which is exactly the defect that reached CI earlier in this session (57ad5efb3)
because the pre-#725 validator could not see it. Local gate now catches it.

scripts/tests: 432 passed (was 411 before #725 added its cases).

No [skip ci] token -- this branch's merge commit is the v26.14.0 tag target.
2026-08-04 23:49:35 +02:00
timothy e997668ac4 chore(release): re-trigger CI for v26.14.0 release notes
The release/v26.14.0 push produced NO ActionRun records at all -- verified
against 7168 listed tasks, zero for b48f44dc7. This is the documented Gitea
behavior in docs/ci-cd.md: several pushes fired back-to-back (the #737 merge,
then this branch, then the PR open) and the later events were silently dropped.
The remedy is a spaced-out re-push, not assuming the runner is broken.

No [skip ci] token -- this branch's merge commit is the v26.14.0 tag target.
2026-08-04 23:47:11 +02:00
timothy 9a3371ecd9 docs(release): record the v26.14.0 release notes
Headline is ersatztv#726: Live TV stalled on any item carrying an embedded
PGS/DVD bitmap subtitle, because -readrate paces an input off its furthest-behind
stream and that sparse subtitle shares the video's own -i. Fixed with a
capability-gated -readrate_catchup rather than the structural change the issue
originally proposed, which would have removed the throttle from the
frame-producing path that ffmpeg.qsv-extra-hw-frames-floor depends on.

Also in this release: the H10 review-verdict gate's raced-sentinel false-open
(#706/#707/#711), and two Renovate dependency bumps.

No [skip ci] token -- this commit is the release tag target, and Gitea reads
skip-ci from the TAGGED commit, which would suppress the release build.

Refs #726
2026-08-04 23:47:11 +02:00
358 changed files with 2086 additions and 73141 deletions
-7
View File
@@ -4,13 +4,6 @@
# body-diff, CI). This shim runs the structural validator over the working tree; the body-diff/no-
# vanish checks run in CI where a base/head is available. Fail-open on any tooling trouble.
set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# git hook: decides by exit code, and its stdout is live progress text.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin decisions-guard "" stream || true
cd "$(git rev-parse --show-toplevel)" || exit 0
command -v python3 >/dev/null 2>&1 || exit 0 # no python -> fail-open
PYTHONPATH=. python3 scripts/decisions_validate.py
-7
View File
@@ -17,13 +17,6 @@
# This is a reminder, never a hard gate — `start` only injects context; `finish` is a one-shot Stop nudge.
set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin design-sync-reminder "${1:-}" capture || true
UI_RE='(^|/)web/src/.*\.(tsx|css)$'
TEST_RE='\.test\.(tsx|ts)$'
@@ -4,13 +4,6 @@
# a sibling worktree another session created apart from this session's own.
# Fail-safe: any parse trouble → do nothing (the guard stays fail-open without a marker).
set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin posttooluse-worktree-marker "" capture || true
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
@@ -15,13 +15,6 @@
# no origin/main, HEAD unresolved -> allow. Deliberate escape: ETV_ALLOW_DIRTY_PUSH=1.
set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# git hook: decides by exit code, and its stdout is live progress text.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin prepush-clean-worktree-check "" stream || true
[ "${ETV_ALLOW_DIRTY_PUSH:-}" = "1" ] && exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
-7
View File
@@ -12,13 +12,6 @@
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# git hook: decides by exit code, and its stdout is live progress text.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin prepush-donewhen "" stream || true
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
refs=$(cat || true)
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
-39
View File
@@ -9,48 +9,9 @@
# a positively-proven "behind origin/main". Deliberate exception: ETV_SKIP_REBASE_CHECK=1.
set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# git hook: decides by exit code, and its stdout is live progress text.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin prepush-rebase-check "" stream || true
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
# Tag-only push exemption (ersatztv#719): the release cut tags a commit on main while the local
# branch sits 1 commit behind origin/main, so H11 blocked EVERY release -- and its "rebase first"
# advice did not even apply, since no branch was being pushed. A tag push cannot revert anyone's
# merged work, which is the failure mode H11 exists to prevent, so skip the freshness check when
# EVERY ref being pushed is under refs/tags/. (See #719 for the observed flow.)
#
# Read pushed refs from stdin: git feeds pre-push hooks one line per ref, "<local ref> <local sha>
# <remote ref> <remote sha>" (.husky/pre-push forwards the lines it already captured). Ignore blank
# lines. VACUOUS-TRUTH GUARD: "all refs are tags" is trivially true when there are zero ref lines
# (hook run manually, stdin not forwarded, etc.) -- that would silently disable H11 for every push.
# Require at least one parsed ref line before granting the exemption; with zero lines, fall through
# to the existing branch-freshness check below (current behavior preserved).
#
# `[ -t 0 ] ||` so an interactive run does not hang waiting on a terminal: this script had no stdin
# reader before #719, and its own docs call "run by hand" a supported case. A TTY yields no ref
# lines, which is exactly the zero-line fall-through.
_h11_refs_seen=0
_h11_all_tags=1
[ -t 0 ] || while IFS=' ' read -r _h11_local_ref _h11_local_sha _h11_remote_ref _h11_remote_sha \
|| [ -n "${_h11_local_ref:-}" ]; do # `|| [ -n ... ]` also processes a final line with no trailing newline
[ -z "${_h11_local_ref:-}" ] && continue
_h11_refs_seen=1
case "${_h11_remote_ref:-}" in
refs/tags/*) ;;
*) _h11_all_tags=0 ;;
esac
_h11_local_ref=''
done
if [ "$_h11_refs_seen" = "1" ] && [ "$_h11_all_tags" = "1" ]; then
exit 0
fi
# Best-effort fetch of the latest main; offline / no network -> don't block.
git fetch origin main --quiet 2>/dev/null || exit 0
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
+3 -10
View File
@@ -12,9 +12,9 @@
# 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. A NARROWER
# cut was TRIED AND REJECTED: it fired only when the prompt text matched implementer signals (`git
# commit`, `worktree`, `fixes #`…). Measured (#583), the heuristic both over- and
# 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
@@ -40,13 +40,6 @@
# Fail-open by design: any parse trouble -> allow (exit 0, no output).
set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-agent-model "" capture || true
input=$(cat)
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null || true)
-7
View File
@@ -3,13 +3,6 @@
# The historic 8-9-way crash was RAM starvation, not CPU load; gate on FREE RAM.
# Fail-open: if memory_pressure is unavailable/unparsable → allow.
set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-agent-ram "" capture || true
free=$(memory_pressure -Q 2>/dev/null | grep -oE 'free percentage: [0-9]+' | grep -oE '[0-9]+' || true)
[ -z "${free:-}" ] && exit 0
-7
View File
@@ -2,13 +2,6 @@
# PreToolUse / Bash — deny commands that violate a HARD RULE.
# Fail-open: any parse trouble → allow (exit 0 with no output).
set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-bash-guard "" capture || true
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
+1 -12
View File
@@ -18,13 +18,6 @@
# the reason a commit can't happen; CI is still the backstop.
set -uo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-bom-guard "" capture || true
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
[ -n "$cmd" ] || exit 0
@@ -73,11 +66,7 @@ while IFS= read -r f; do
esac
p="$root/$f"
[ -f "$p" ] || continue
# `od`, NOT `xxd`. `xxd` ships with vim and is absent on plain Linux hosts including this repo's
# CI runner, where the command substitution yielded empty, never equalled `efbbbf`, and this guard
# therefore passed every BOM in silence. It has been fail-open on any host without vim since it
# was written. `od -A n -t x1 -N 3` is POSIX and produces byte-identical output on macOS and Linux.
if [ "$(od -A n -t x1 -N 3 < "$p" 2>/dev/null | tr -d ' \n')" = "efbbbf" ]; then
if [ "$(head -c3 "$p" 2>/dev/null | xxd -p 2>/dev/null)" = "efbbbf" ]; then
bad="${bad} ${f}"$'\n'
fi
done < /tmp/.bom-guard-files.$$
+15 -517
View File
@@ -8,12 +8,8 @@
# LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode:
# "re-review the fix commit, not just the initial PR diff").
#
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. The window is SMALL for an
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and calling this gate
# "sound" is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
# performs the merge, so a push can still land in between. The merge API accepts an optional
# `head_commit_id` that would make that call a true compare-and-set; a PreToolUse hook cannot add an
# argument, only refuse without one. With merge_when_checks_succeed, Gitea merges
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. That is sound for an
# immediate merge and UNSOUND for a scheduled one: with merge_when_checks_succeed, Gitea merges
# later, against whatever head is green then (ersatztv#622). So the sha-bound half of H10 is
# enforced by the SERVER, not here — `review-verdict/h10` is a required status check on `main`,
# written per-sha by scripts/post-review-verdict.sh, and a new commit cannot inherit it. This hook
@@ -22,7 +18,7 @@
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
# The H10 review-verdict convention: after reviewing a PR (or its latest fix commit), post a PR
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
#
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
# - state derivable and satisfied -> grant (auto-approve: permissionDecision "allow",
@@ -44,25 +40,6 @@
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
set -euo pipefail
# THE FIRE-LOG PATH BELOW IS SELF-LOCATED, not `${CLAUDE_PROJECT_DIR:-...}` — as is every other
# tracked hook's since ersatztv#891, byte-identically (`process.hook-resolves-inputs-from-repo-root`).
# Written here rather than beside the assignment because the instrumentation preamble that follows is
# machine-compared: `test_hook_fire_log.py::test_the_stripper_removes_EXACTLY_the_preamble_and_nothing_else`
# permits only its own recognised lines in that block, so a comment inside it fails the suite.
#
# That line is `. `-SOURCED, so whatever it names runs AS CODE inside this hook, before stdin is read
# and before `decide` exists. It is therefore not "telemetry" in any sense a gate can rely on.
# MEASURED 2026-08-30: with the env-var-first form, a `hook-fire-log.sh` in an env-var-named tree
# that prints an `allow` decision and exits 0 GRANTS THE MERGE outright, having bypassed every check
# below. Self-locating binds it to the tree this hook was loaded from and closes that.
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-merge-consent "" capture || true
input=$(cat)
decide() { # $1=grant|allow|deny|ask $2=reason
@@ -108,9 +85,8 @@ sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and checked for head/base
# movement across the paging round trips, or the exemption is unsafe. (That check detects ONE-WAY
# movement only — this said "bound to ONE head" until 2026-08-28, ersatztv#803.) ALL of that now lives in scripts/pr-changed-files.sh — the single shared
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and bound to ONE head, or the
# exemption is unsafe. ALL of that now lives in scripts/pr-changed-files.sh — the single shared
# implementation, also called by .gitea/workflows/review-verdict.yml (ersatztv#649).
#
# Why it moved: this logic was written twice. This copy is ADVISORY (a failure produces a human
@@ -186,87 +162,16 @@ fi
# posted before ersatztv#632 and gets NO opinion, rather than denying every in-flight PR the day
# this lands. The window closes on its own — verdicts are per-head and short-lived, so every verdict
# posted after this carries the field.
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded".
# Collapsing it into the latter is a false-open: an unreadable status response yields
# an empty `recorded_base`, which takes the graceful-adoption path and skips validation silently —
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded". Cold
# review found the first draft collapsing it into the latter: an unreadable status response yielded
# an empty `recorded_base`, which took the graceful-adoption path and skipped validation silently —
# after which a later, successful status read could still auto-grant. A transient failure would then
# produce a "merge gate: satisfied" message for a comparison that never happened. Every
# have produced a "merge gate: satisfied" message for a comparison that never happened. Every
# unreadable input here therefore falls through to a human (`ask`), never to silence.
# RE-READ THE BASE HERE, ONCE, FOR EVERY PATH BELOW (ersatztv#778).
#
# "Below" is literal, and the one consumer ABOVE is disclosed rather than implied: the docs-only
# enumeration still runs against the snapshot `$base_ref` and can `decide allow` before reaching
# this point. That is bounded and deliberate — a docs-only match is a PASSTHROUGH to the ordinary
# human prompt, never an auto-grant, so a stale base there costs a prompt someone was going to see
# anyway. Every path that can GRANT passes through the check below.
#
# `$base_ref` above comes from the PR snapshot taken at the top of this hook, and the docs-only
# enumeration between there and here is up to forty round trips. A PERSISTENT retarget in that gap
# needs no ABA and no force-push: every base-dependent decision below would be formed against a
# branch the PR no longer targets. Checking a stale identifier is not checking — which is the whole
# of `process.check-and-use-pins-a-version`, so the guard enforcing that rule must not break it.
#
# This re-read first landed inside the scheduled-auto-merge branch only, which fixed the branch-
# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot.
# Measured on this repo's own fixture: scheduled+retarget denied, while
# immediate+retarget auto-GRANTED. That is the twin-missed shape — a fix applied to the path where it
# was noticed — so the re-read is hoisted above every consumer rather than duplicated into each.
prjson_now=$(gq "repos/$owner/$repo/pulls/$pr")
if [ -z "${prjson_now//[[:space:]]/}" ] || ! printf '%s' "$prjson_now" | jq -e 'type == "object"' >/dev/null 2>&1; then
decide ask "H10 merge gate: could not re-read PR #$pr to confirm it still targets '$base_ref' before checking the verdict against it. Confirm the target branch, then merge."
fi
base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""' 2>/dev/null || true)
if [ -z "$base_now" ]; then
live_base=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
if [ -z "$live_base" ]; then
decide ask "H10 merge gate: PR #$pr reports no base branch (.base.ref), so the verdict cannot be checked against the branch it was formed for (ersatztv#632). Confirm the PR still targets the branch it was reviewed against before merging."
fi
if [ -n "$base_ref" ] && [ "$base_now" != "$base_ref" ]; then
decide deny "H6/H10 merge gate: BLOCKED — PR #$pr was retargeted from '$base_ref' to '$base_now' while this gate was evaluating. Every check formed against '$base_ref', including the changed-file enumeration and the review verdict, describes a merge that is no longer the one being requested (ersatztv#632). Re-review against '$base_now' and run: scripts/post-review-verdict.sh $pr MERGEABLE"
fi
# From here on both names are the freshly-confirmed base; they are equal by the check above.
base_ref=$base_now
live_base=$base_now
# THE HEAD IS RE-READ AT THE SAME HOIST, FROM THE SAME RESPONSE (ersatztv#803).
#
# `$sha` comes from the PR snapshot at the top of this hook, and until 2026-08-28 every later check
# consumed that captured value: the CI combined status, the `review-verdict/h10` status, and the
# verdict-comment classification were all evaluated against `/commits/$sha/status` and `--head $sha`.
# A push landing in the gap — which includes the docs-only enumeration's up-to-forty round trips —
# was therefore checked against the commit it had just replaced, and the hook would report "a
# positive Review-verdict references the current head" about a head that was no longer current.
#
# This is the SAME defect the base had until #778 hoisted the re-read above, and it is fixed the same
# way rather than a different way. Reading `.head.sha` off `$prjson_now` — the response the base
# check already fetched — costs NO extra round trip, and it keeps the two axes on ONE snapshot, so
# they cannot disagree about which moment they describe. Two separate reads would answer about two
# different instants while reading as one check.
#
# DENY, not ask, and for the same reason the `stale` verdict class denies: a head that moved means
# the verdict this hook is about to accept covers an OLDER commit, which is a state we have
# positively established rather than failed to establish. An UNREADABLE `.head.sha` is the different
# case and asks.
#
# WHAT THIS DOES NOT CLOSE, said here rather than left to be inferred. A push landing after this
# check still passes, exactly as a retarget does — the file's rule against a second re-read applies
# unchanged (see the branch-protection block below), because two reads only move the window rather
# than closing it. That residual is bounded server-side and this hook is not what bounds it: the new
# head has no `review-verdict/h10` status, and that context is REQUIRED on `main`, so Gitea refuses
# the merge (#622). The hook's job here is to stop CLAIMING a head is reviewed when it can see that
# it is not — an advisory gate that states something false is worse than one that asks.
if [ -n "$sha" ]; then
sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""' 2>/dev/null || true)
if [ -z "$sha_now" ]; then
decide ask "H10 merge gate: PR #$pr reports no head commit (.head.sha) on re-read, so whether the review verdict still covers the current head could not be confirmed. Check the PR, then merge."
fi
if [ "$sha_now" != "$sha" ]; then
decide deny "H6/H10 merge gate: BLOCKED — PR #$pr's head moved from ${sha:0:7} to ${sha_now:0:7} while this gate was evaluating. Every check formed against ${sha:0:7} — the changed-file enumeration, the CI status and the review verdict — describes a commit that is no longer the one being merged (ersatztv#803). Re-review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE"
fi
# From here on `$sha` is the freshly-confirmed head; the two are equal by the check above. Mirrors
# `base_ref=$base_now` a few lines up, and is written for the same reason that one is: it makes the
# value every later check consumes the one that was just re-read, so a future edit moving a
# consumer above this point fails visibly rather than silently reading the stale capture.
sha=$sha_now
fi
if [ -n "$sha" ]; then
# This is the THIRD read of this endpoint in a worst-case hook run (the ordinary-CI branch and the
# scheduled-auto-merge branch each do their own). Sharing one snapshot would close a narrow
@@ -333,50 +238,6 @@ for n in $issues; do
fi
done
# ONE branch-protection READ per run (ersatztv#859). Two arms consume this endpoint — the scheduled
# path's `review-verdict/h10` required-check test, and the guard-scope freshness check at the bottom
# — and they used to issue independent GETs, so a scheduled auto-merge hit it twice (measured: the
# test stub recorded 2 URLs).
#
# THE ROUND TRIP IS THE SMALLER HALF. What matters is that branch protection is MUTABLE config: two
# reads can return two different answers, and the gap between them is a gap in which the two arms
# decide about different repo states — one concluding `review-verdict/h10` is required on the base
# while the other classifies a rule list that no longer says so. Neither arm can detect that; both
# would report confidently. Caching makes a single run internally consistent BY CONSTRUCTION, which
# is a property no retry or ordering change can supply.
#
# WHY #787 DID NOT ALREADY SHARE IT, since the obvious question is why two reads existed at all: the
# arms ask genuinely different QUESTIONS — one about `$base_ref` and its required contexts, one about
# `main` and snapshot freshness — so their classifications must stay separate. But they ask those
# questions of the same URL with the same credentials, so the RESPONSE is shareable even though the
# verdicts are not. Cache the bytes; never cache a verdict.
#
# This does NOT pin anything: protection can still change after the read, and the honest ceiling is
# unchanged (`process.check-and-use-pins-a-version`). It removes a second window, it does not remove
# the first.
bp_fetched=no
bp_cache=""
bp_cache_code=""
fetch_branch_protections() {
# Idempotent by design: every caller invokes it unconditionally and the FIRST one pays. A caller
# that had to know whether it was first would be a second place for the two arms to disagree.
if [ "$bp_fetched" = yes ]; then return 0; fi
bp_fetched=yes
local f
# A temp-file failure gets its own sentinel rather than an HTTP-shaped one, so each caller can
# keep the distinct message it had before this was shared. Reporting a mktemp failure as HTTP
# '000 — Gitea unreachable' would state a cause that did not happen, which is the defect class
# this whole file is organised around.
f=$(mktemp) || { bp_cache=""; bp_cache_code=mktemp-failed; return 0; }
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
else
bp_cache_code=$(curl -s -o "$f" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
fi
bp_cache=$(cat "$f" 2>/dev/null || true)
rm -f "$f"
}
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
if [ "$mwcs" != "true" ]; then
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
@@ -440,19 +301,8 @@ else
# status decide this. Here the fallthrough happens to land on `vstate=""` -> deny (fail-CLOSED,
# so this was never a hole), but it would have surfaced the wrong message — a "BLOCKED, no
# verdict" deny instead of the "could not read the status" ask this branch exists to give.
# Validate the MEMBERS, not just the array. `.statuses | type == "array"` passes for
# `{"statuses":[1]}`, and the extraction below then errors with "Cannot index number with string"
# and exits 5 — which, under `set -e`, aborts this hook with NO JSON on stdout at all. A consent
# hook that emits nothing has violated its own contract: it neither grants, denies nor asks. Same
# one-level-down swallow as the #632 base-change guard and the branch-protection shape check
# below; the validation domain must match the CONSUMPTION domain (ersatztv#778).
if [ -z "${vjson//[[:space:]]/}" ] \
|| ! printf '%s' "$vjson" \
| jq -e '(.statuses | type == "array")
and all(.statuses[]; type == "object"
and ((.context | type) == "string")
and ((.status | type) == "string"))' >/dev/null 2>&1; then
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable, or a response whose status rows are not the expected shape). Confirm the current head is reviewed before scheduling an auto-merge."
if [ -z "${vjson//[[:space:]]/}" ] || ! printf '%s' "$vjson" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable or an unexpected response). Confirm the current head is reviewed before scheduling an auto-merge."
fi
vstate=$(printf '%s' "$vjson" | jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .status // ""')
case "$vstate" in
@@ -461,226 +311,6 @@ else
pending) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is still pending on PR #$pr head ${sha:0:7} (no verdict posted for this commit yet). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
*) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is '$vstate' on PR #$pr head ${sha:0:7}. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
esac
# --- The mitigation this path RESTS on, verified instead of asserted (ersatztv#778). -----------
# Everything above proves a property of the head that exists NOW. What makes that safe under
# merge_when_checks_succeed is stated in the paragraph opening this branch: `review-verdict/h10`
# is a REQUIRED status check on the base, a commit status belongs to exactly ONE sha, so a commit
# pushed after scheduling cannot inherit it and Gitea's own gate refuses the merge.
#
# That guarantee is branch-protection CONFIG. It lives outside this repo, no code here owned it,
# and until #778 nothing compared the two — so the grant reason handed to a human cited a
# protection that could have been switched off with no signal anywhere. The comment above and the
# grant string below are claims about the past; a dated claim is not a check.
#
# This is the hook's OWN defect class (#778 / `process.check-and-use-pins-a-version`): a check
# ("a later push clears the status") authorizes an action ("arm an auto-merge that Gitea completes
# later") over state that can change in between, with nothing pinning it. The read here does not
# pin anything either — branch protection can still be edited after this call — but it converts an
# ASSUMPTION that was never observed into a precondition that is, which is the honest ceiling for
# a config whose API offers no version, ETag or conditional read.
#
# Tri-state, matching this file's idiom throughout: unreadable -> ask (a human adjudicates),
# present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened,
# and the whole point of that issue is that the failure is silent from the merge caller's side.
# Belt-and-braces: `$base_ref` was proven non-empty and re-confirmed at the hoisted check above,
# so this cannot fire today. Kept because it is the precondition this block's URL depends on, and
# a future edit that moves either piece should fail loudly here rather than request a URL with an
# empty path segment.
[ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling."
# The base was re-read and confirmed unchanged above, for every path — see the hoist comment
# there. It is deliberately NOT re-read a second time here: two reads would create a window
# between them for no gain, and the hoisted check already covers the enumeration gap that made
# this necessary.
# A read failure here is NOT evidence about the branch. The deleted by-name endpoint answered 404
# for "no rule with this name", which was a finding; the LIST endpoint's 404 means the repo was not
# found or is invisible to this credential, which is a read failure. Absence is now established by
# the classifier returning `nomatch` over a list that WAS read, never by an HTTP status.
# ALWAYS enumerate the rule LIST; never look a rule up by name. The by-name endpoint
# (`branch_protections/{name}`) is an exact DB lookup — `GetProtectedBranchRuleByName` — which
# performs no matching and knows nothing about precedence, so a 200 from it means only "a rule
# with this NAME exists and lists this context", never "this context is required on this branch".
#
# It was used first, with the list consulted only on a 404, and that design left a false-open
# behind: the precedence argument below guarded the 404 path while the 200 path — the one this
# repo actually takes — granted without it. Given a rule `main` requiring `review-verdict/h10` and
# a rule `m*` with better Priority that does not, Gitea applies `m*`, and the by-name hit on
# `main` granted anyway. The hardened path was dead code and the unhardened one was live. Deleting
# the twin rather than documenting it is the point: one fetch, one classifier, one argument, and
# no second path to keep in step. The ref no longer reaches a URL segment, so it needs no
# encoding either.
fetch_branch_protections
if [ "$bp_cache_code" = "mktemp-failed" ]; then
decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
fi
bp_code=$bp_cache_code
bp_list=$bp_cache
bp=""
if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then
# DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would
# be the exact defect this PR records (a mitigation outside the code, asserted rather than
# verified). Gitea compiles a rule name with gobwas/glob and a `/` separator, so its `*` does
# NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards, and a plain name is folded case-
# insensitively. Reimplementing that here would be a second copy of somebody else's parser.
#
# So the classification is deliberately THREE-way, and each arm is safe without knowing the
# dialect:
# exact — no glob rule could apply, AND some rule name has no glob metacharacter and
# equals the base case-insensitively. Only then is a single rule decidable.
#
# UNDECIDABLE IS EVALUATED FIRST, and the order is the point. Gitea picks the
# governing rule with `GetFirstMatched` over a list sorted by Priority, THEN
# by plain-name-ness — so a glob rule with a better Priority outranks an
# exactly-named one. Preferring `exact` would therefore inspect a rule Gitea
# might not be applying: if the exact rule requires `review-verdict/h10` and a
# higher-priority glob rule does not, the gate auto-grants on a base where the
# check is not enforced. Asking whenever ANY glob rule could apply is sound
# without knowing the precedence rules at all, which is the only claim this
# code is entitled to make about somebody else's resolver.
#
# Case folding is ASCII-only here, while Gitea's `EqualFold` is
# Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there
# and not here. ASCII-fold equality implies EqualFold equality, so the gap can
# only MISS a match, never invent one; but a miss lands on `none`, which
# DENIES with the stated cause that no rule can govern the base. The backslash
# paragraph below rejects "nearly unreachable" as a standard for that arm, and
# the same standard has to apply here, so a rule name carrying any non-ASCII
# byte is `undecidable` rather than fold-compared. Two fold-equal plain names
# are undecidable too: this code picks by list order while Gitea picks by
# Priority, and guessing which one is enforced is the defect the arm order
# above exists to avoid.
# undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any
# glob dialect: literal prefix before the first metacharacter, `.*`, literal
# suffix after the last. If even that does not match, no dialect can, because
# every dialect requires the literal head and tail to match literally.
#
# BACKSLASH counts as a metacharacter for that purpose, and it is the one case that breaks the
# superset proof if it does not. gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b`
# governs the base `a{b` — while a superset that treated `\` as literal would build `a\.*b`,
# fail to match, and answer `none`, i.e. deny a base that IS protected. Git ref rules make this
# nearly unreachable (a branch name may not contain `*`, `?`, `[` or `\`, though it MAY contain
# `{`), but `none` is the arm that authorises a DENY on the stated grounds "nothing can govern
# this base", so its premise has to hold unconditionally rather than usually.
# none — nothing can possibly govern the base, so it is genuinely unprotected.
#
# `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base
# whose protection we never established (#622's hole, reached through the block written to
# close it); under-matching would deny with a stated cause that is false, which this block's
# own comment calls the worse outcome. Asking is the only answer that is honest in both
# directions, and it is rare in practice: as of 2026-08-19 this repo's only rule is the plain
# name `main`, which the classifier resolves to `exact` on every run. That is a dated
# observation about mutable remote config, not a property to rely on.
# The classifier is a FILE now (ersatztv#787), so its absence is a new failure mode: `jq -f` on a
# missing program exits 2 with empty stdout, which reaches the `*)` arm below and asks that "this
# repo's branch-protection rules came back in a shape this hook could not parse" — blaming the
# payload for a missing local file. That is precisely the states-a-cause-that-did-not-happen defect
# the two comments beside that arm were written to fix, so it is checked here rather than inherited.
classifier="$repo_root/scripts/lib/branch-rule-classifier.jq"
if [ ! -r "$classifier" ]; then
decide ask "H6/H10 merge gate: the shared branch-protection rule classifier is missing or unreadable at $classifier, so which rule governs '$base_ref' — and therefore whether 'review-verdict/h10' is required on it — could not be derived (ersatztv#787). Restore the file, or confirm the required checks manually."
fi
bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c -f "$classifier" 2>/dev/null || true)
case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;;
undecidable) decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
none) bp_code=nomatch; bp="" ;;
# A DECLARED class of the classifier's contract (ersatztv#859), with its OWN sentinel — not
# merely its own arm. Giving it an arm that set `unreadable-rules`, the same value
# the catch-all sets, was measured to be a no-op: deleting that arm left the WHOLE suite
# green, because nothing downstream could tell the two apart. An arm no observation can
# distinguish is not a fix, it is a comment with syntax. (The invariant is "no test reddens",
# not a test count — a count goes stale the next time anyone adds one.)
#
# They are different findings and now say so. `unnamed-rule` means the list was READ and a rule
# in it carries no usable name; `unreadable-rules` means jq died or answered a word this hook
# does not know. Same decision (ask), different cause — and naming the cause accurately is the
# entire subject of this issue, so collapsing them here would have reproduced the defect being
# fixed, one arm over.
unreadable) bp_code=unnamed-rule; bp="" ;;
*) bp_code=unreadable-rules; bp="" ;;
esac
else
# A 200 whose body is NOT an array never reaches the classifier — it is diverted by the array
# gate above — so it needs the same sentinel, or the generic ask below reports
# "HTTP '200' — Gitea unreachable" about a read that plainly succeeded. Same defect as the
# throw-inside-the-classifier arm, one branch earlier; fixing only the arm where it was noticed
# is the twin-missed shape this PR is largely about.
if [ "$bp_code" = "200" ]; then
bp_code=unreadable-rules
else
bp_code=${bp_code:-000} # a real transport/HTTP failure -> the ask arm below
fi
bp=""
fi
# `nomatch` is the CLASSIFIER's verdict, deliberately not an HTTP code. Reusing 404 for it made
# this deny reachable from an HTTP 404 on the list read too — repo not found, or invisible to the
# credential, which Gitea also answers 404 — and then the reason claimed "the full rule list was
# read and none matches" about a read that never happened. A transport failure must reach the ask
# below, not a deny stating a finding.
if [ "$bp_code" = "nomatch" ]; then
decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green."
fi
# `unnamed-rule` is the classifier reporting a rule whose NAME it could not use. Two distinct
# shapes, and the reason string must cover both or it states a cause that did not happen: EITHER
# both fields supply no name (absent, null, or empty), OR one of them is present holding a
# non-string, which poisons the rule however good its sibling is. It is deliberately NOT reported as
# "no rule matches": a rule that cannot be read might be the rule Gitea is applying, so a list
# containing one supports no finding about which rule governs the base. That was the #859 defect —
# `""` is a valid name that matches nothing, so an unreadable rule DENIED with a stated cause that
# had not happened.
if [ "$bp_code" = "unnamed-rule" ]; then
decide ask "H6/H10 merge gate: a branch-protection rule on this repo carries no name this hook can use — either both 'branch_name' and 'rule_name' are absent/null/empty, or one of them is present holding something that is not a string. Which rule governs '$base_ref', and whether 'review-verdict/h10' is required on it, therefore could not be derived. A rule that cannot be read might be the one Gitea applies, so this is deliberately NOT reported as 'no rule matches' (ersatztv#859). Inspect the branch-protection rules, or merge immediately instead of scheduling."
fi
# `unreadable-rules` is the CLASSIFIER failing on a 200 this hook could not turn into a verdict —
# jq died, or answered a word this contract does not define. It gets its own sentinel for the same
# reason `nomatch` does: reporting "HTTP '000' — Gitea unreachable" about a successful 200 read
# states a cause that did not happen, which is the defect fixed one arm over for the deny.
#
# A numeric `branch_name` was the worked example here until ersatztv#859 and no longer reaches this
# arm: it is not a usable NAME, so the classifier now classifies it rather than throwing on it, and
# it lands on `unnamed-rule` above with the cause that actually applies. The example is corrected
# rather than dropped, because it is the one shape a reader is likely to reach for when testing.
if [ "$bp_code" = "unreadable-rules" ]; then
decide ask "H6/H10 merge gate: this repo's branch-protection rules came back in a shape this hook could not parse, so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Check the rules manually, or merge immediately instead of scheduling."
fi
if [ "$bp_code" != "200" ] || [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then
decide ask "H6/H10 merge gate: could not read this repo's branch-protection rules (HTTP '${bp_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling."
fi
# The membership test is `any(.[]; . == …)` over a value FIRST PROVEN to be an array of strings —
# never `index()`. `index` on a STRING is substring search, so a `status_check_contexts` that
# arrived as the string "prefix-review-verdict/h10-suffix" would answer "yes" and auto-grant a
# merge on a base where no such context is required. That is a FALSE-OPEN in the gate, reachable
# from any payload shape drift, and it is the direction that matters: a false-closed costs a
# prompt, a false-open costs an unreviewed merge.
#
# Validating `$bp` as an object does not make its MEMBERS well-formed, which is the same
# one-level-down swallow that survived the first fix in the #632 base-change guard — the
# validation domain has to match the CONSUMPTION domain, not stop at the top-level type. So the
# shape is checked explicitly and anything else becomes "unknown" rather than a decision.
#
# `null` and `[]` are legitimate (an unprotected-in-practice branch) and answer "no", not
# "unknown": absent IS the finding here, not a read failure. The word is then matched
# exhaustively, because "" is not a third synonym for "no".
# `// []` defaults on FALSE as well as on null, because jq's alternative operator fires for both.
# So `"status_check_contexts": false` — a malformed shape — became `[]` and answered "no", i.e. a
# confident DENY derived from a payload that was never understood. Absent and null are defaulted
# explicitly; every other non-array is "unknown".
# `enable_status_check` is validated as a BOOLEAN before it is trusted, for the same reason the
# contexts list is: `"true"` (the string) is not `true`, and comparing it to `true` yields a
# confident "no" -> deny derived from a payload never understood. Every malformed shape on this
# endpoint has to reach the same "unknown" -> ask arm, or the tri-state is only two states.
guarded=$(printf '%s' "$bp" \
| jq -r 'def ctxs: if (has("status_check_contexts") | not) or .status_check_contexts == null
then [] else .status_check_contexts end;
if (.enable_status_check | type) != "boolean" then "unknown"
elif (ctxs | type) != "array" or any(ctxs[]; type != "string") then "unknown"
elif (.enable_status_check == true) and any(ctxs[]; . == "review-verdict/h10") then "yes"
else "no" end' 2>/dev/null || true)
case "$guarded" in
yes) : ;;
no) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is NOT a required status check on '$base_ref' (branch protection reports enable_status_check/status_check_contexts without it). A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622); without it, arming merge_when_checks_succeed freezes consent at a head Gitea may not be the one to merge. Restore it in branch protection, or merge immediately (without merge_when_checks_succeed) once CI is green." ;;
*) decide ask "H6/H10 merge gate: branch protection for '$base_ref' came back in an unexpected shape, so the 'review-verdict/h10' required check that makes a scheduled auto-merge safe (ersatztv#622) could not be confirmed either way. Check it manually, or merge immediately instead of scheduling." ;;
esac
fi
# --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the
@@ -700,19 +330,7 @@ fi
# inside a fenced code block (documentation showing the convention counted as a real verdict), and a
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
# RESOLVED FROM `$repo_root`, never `$CLAUDE_PROJECT_DIR` — the rule, the threat model and the
# boundary are in `process.hook-resolves-inputs-from-repo-root` (ersatztv#858, #891). Written once there
# rather than twice here: this file carried two resolutions of the same question, and the guard-scope
# arm below is the other one. Two answers in one file is the state most likely to be "tidied" toward
# the weaker side, so neither site restates the argument now.
#
# Site-specific consequence only: a `$CLAUDE_PROJECT_DIR` naming a sibling worktree — routine here —
# would classify THIS PR's comments with THAT tree's copy of the H10 grammar.
#
# `ETV_HOOK_FIRE_LIB` at the top of this file is bound the same way, and for a STRONGER reason — it
# is sourced, so it is code. See the block above it. Since #891 every tracked hook binds it
# identically, and `test_hook_fire_log.py` fails any that stops doing so.
verdict_script="$repo_root/scripts/check-review-verdict.sh"
verdict_script="${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh"
if [ ! -x "$verdict_script" ]; then
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
fi
@@ -741,133 +359,13 @@ case "$class" in
decide ask "H10 merge gate: unrecognized verdict classification '$class' for PR #$pr. Confirm the review covered the latest commit ($short) before merging." ;;
esac
# --- (d) Guard-scope freshness (ersatztv#787): the committed mirror of `main`'s required status
# checks must still match the server. ------------------------------------------------------
# ORDERED LAST, and that is a severity argument rather than a stylistic one. Every check above
# can DENY; this one can only ever downgrade an otherwise-satisfied auto-grant to a prompt. Run
# earlier it would preempt those verdicts and report a stale guard scope at a reader whose merge
# is blocked for a completely different and more serious reason, and it would ask on payloads the
# checks above are about to reject anyway. Placed here it is also PAST the point where the two
# merge paths converge, so it covers both without duplicating anything.
# `scripts/tests/test_ci_dropped_step_guard.py` DERIVES which jobs must carry per-step execution
# markers from `.gitea/required-status-contexts.json`, because its CI job checks out with
# `persist-credentials: false` and cannot ask Gitea. That makes the snapshot the single
# hand-maintained input in the chain: a fourth required context added on the server leaves the
# snapshot — and therefore the guard's scope — silently behind, which is the whole of #787.
#
# THIS RUNS ON BOTH MERGE PATHS, deliberately, and it is placed here rather than beside the
# branch-protection read in the scheduled-auto-merge branch for that reason.
#
# WHAT IT DOES NOT COVER, said here rather than left to be discovered: a PR whose changed files are
# all docs/process — `.gitea/` included — exits at the docs-only passthrough far above, so this arm
# never runs for it. A PR that edits ONLY `.gitea/required-status-contexts.json` is docs-only BY
# CONSTRUCTION, and that is exactly the snapshot-NARROWING direction the decision record names as
# this design's residual. Excluding that path from the allow-list would not buy the protection it
# looks like it would: this arm compares the live server against the snapshot in the LOCAL CHECKOUT,
# not against the version the PR proposes, so it cannot see a narrowing that has not landed yet.
# What does hold is that the passthrough is a passthrough — a human prompt, never an auto-grant —
# which is the `.gitea/` treatment ersatztv#317 asked for. That read is inside
# `else` (mwcs = true) and never executes on an immediate merge, which is the common case; hanging
# the freshness check off it would fire it only when an auto-merge is armed. This file already
# records that exact defect one section up — the base re-read "first landed inside the
# scheduled-auto-merge branch only", with scheduled+retarget denied while
# immediate+retarget auto-GRANTED. Same shape, so it is not repeated here.
#
# It reads `main` (the branch the snapshot names), NOT `$base_ref`. That is a DIFFERENT question
# from the one the scheduled branch asks — "is review-verdict/h10 required on the base I am merging
# into" — so this is not a second copy of that classifier and the two cannot drift into disagreeing:
# they consume different fields of different rules for different decisions.
#
# ASK, NEVER DENY. Drift does not make THIS merge unsafe: Gitea enforces the live required set
# server-side, so a newly required context with no status blocks the merge on its own. What has gone
# stale is a guard's scope — a different artifact, on a different clock. Denying would state
# something false about the change in front of the reader. Every non-`match` class asks, so a
# comparison that could not be made is surfaced rather than skipped (`unknown` is not `fine`).
# ONE base for both the checker and the snapshot, and it is `$repo_root` — see
# `process.hook-resolves-inputs-from-repo-root` for why an env var may not select either
# (ersatztv#787, #858). The reason specific to THIS arm is that both halves of a comparison are
# resolved here: from two different roots the hook would classify one checkout's snapshot with
# another checkout's script — mismatched halves of a comparison whose entire job is to detect a
# mismatch — and answer `match` about a tree nobody asked about.
ctx_base="$repo_root"
ctx_snapshot="$ctx_base/.gitea/required-status-contexts.json"
ctx_script="$ctx_base/scripts/check-required-contexts.sh"
# THIS ARM IS ABOUT ONE REPO, and the merge tool is not. Every other check here reads
# `$owner/$repo` from the tool input and is repo-agnostic; this one compares a HARDCODED branch
# against a snapshot committed in THIS checkout. Merging a PR in another repo from a session opened
# here would otherwise weigh that repo's live contexts against this repo's mirror and report a
# confident, flatly false finding about it — measured: server-management returns `[]`, which
# classifies as `nomatch`. So the snapshot names the repo it describes and the arm runs only for it.
# An unreadable snapshot cannot answer "is this my repo?" either, so it asks rather than skipping.
ctx_repo=$(jq -r 'if (.repo | type) == "string" then .repo else "" end' "$ctx_snapshot" 2>/dev/null || true)
if [ -z "$ctx_repo" ]; then
decide ask "H6 merge gate: $ctx_snapshot is missing, unreadable, or names no \`repo\`, so the dropped-step guard's scope could not be checked against branch protection — nor could it be established whether this snapshot even describes $owner/$repo (ersatztv#787). Restore the file, or check the required checks manually."
fi
# CASE-FOLDED, because Gitea resolves owner/repo case-insensitively: verified live, both
# `/repos/timothy/ersatztv` and `/repos/TIMOTHY/ErsatzTV` answer 200. A byte-exact compare would let
# any case variant sail through every other arm and SKIP this one, so drift would go unreported with
# no ask — the gate failing open on a spelling. The hook already treats case folding as
# decision-relevant one section up, where `MAIN` vs `main` makes the governing rule undecidable.
ctx_repo_fold=$(printf '%s' "$ctx_repo" | tr '[:upper:]' '[:lower:]')
target_repo_fold=$(printf '%s' "$owner/$repo" | tr '[:upper:]' '[:lower:]')
if [ "$ctx_repo_fold" = "$target_repo_fold" ]; then
if [ ! -x "$ctx_script" ]; then
decide ask "H6 merge gate: the required-contexts checker is missing or not executable at $ctx_script, so whether the dropped-step guard's scope still matches branch protection on 'main' could not be derived (ersatztv#787). Check it manually, or restore the script."
fi
# THE SHARED READ (ersatztv#859). On a scheduled merge the arm above already fetched this; here that
# call is a cache hit, so the endpoint is read once per run instead of twice. On the IMMEDIATE path
# this is the only consumer and it performs the fetch itself, which is why the call sits AFTER the
# `[ ! -x "$ctx_script" ]` check above: a missing checker must ask without having touched the
# network, and a test pins exactly that by asserting no branch-protection URL was recorded.
fetch_branch_protections
if [ "$bp_cache_code" = "mktemp-failed" ]; then
decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
fi
ctx_code=$bp_cache_code
# ONE temp file, and it holds the checker's STDERR. Until ersatztv#859 this was `mktemp` for the
# payload plus an unmanaged `$bpf.err` beside it — a second path mktemp never created and therefore
# never made unpredictable. The payload now comes from the shared cache over a pipe, so the only
# thing still needing a file is the diagnostic, and it gets the mktemp'd one.
ctx_err=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file for the guard-scope freshness check's diagnostics (ersatztv#787)."
if [ "$ctx_code" = "200" ]; then
# stderr is KEPT, not sent to /dev/null. The checker exits 2 with a diagnostic on a usage error —
# an unreadable snapshot, a branch mismatch, a missing classifier — and discarding it made all of
# those arrive at the operator as the catch-all's "returned 'nothing'", which names no cause. That
# is the same states-a-cause-that-did-not-happen shape this arm was careful about elsewhere.
ctx_class=$(printf '%s' "$bp_cache" | "$ctx_script" --branch main --snapshot "$ctx_snapshot" 2>"$ctx_err" || true)
ctx_diag=$(tr '\n' ' ' < "$ctx_err" 2>/dev/null | cut -c1-300 || true)
else
ctx_class=readfail
ctx_diag=""
fi
rm -f "$ctx_err"
case "$ctx_class" in
match) : ;;
drift)
decide ask "H6 merge gate: the required status checks on 'main' no longer match .gitea/required-status-contexts.json (ersatztv#787). scripts/tests/test_ci_dropped_step_guard.py derives its marked-job scope from that snapshot, so until it is reconciled a required context may have NO dropped-step guard — a step the runner drops would conclude success and take that check green having done no work (ersatztv#756). Re-read the live list and update the snapshot in a PR (the guard will then demand markers for any newly required job, or an ACCOUNTED_ELSEWHERE entry naming what covers it). This does not make the merge in front of you unsafe — Gitea enforces the live required set server-side — so approve if you have judged it unrelated." ;;
nomatch)
decide ask "H6 merge gate: no branch-protection rule governs 'main' at all, so the required status checks the dropped-step guard scopes itself to could not be confirmed (ersatztv#787). Branch protection on 'main' is what makes 'review-verdict/h10' load-bearing (ersatztv#743) — check it before merging." ;;
undecidable)
decide ask "H6 merge gate: a glob branch-protection rule could govern 'main', so which rule's required contexts to compare against .gitea/required-status-contexts.json is not derivable without reimplementing Gitea's matcher (ersatztv#787). Confirm the required checks manually." ;;
unreadable)
decide ask "H6 merge gate: branch protection for 'main', or .gitea/required-status-contexts.json itself, came back in a shape the required-contexts checker could not consume, so whether the dropped-step guard's scope is still current is unknown (ersatztv#787). Check the rules and the snapshot manually." ;;
readfail)
decide ask "H6 merge gate: could not read branch protection for the guard-scope freshness check (HTTP '${ctx_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether .gitea/required-status-contexts.json is still current is unknown (ersatztv#787). Confirm the required checks on 'main' manually." ;;
*)
decide ask "H6 merge gate: the required-contexts checker returned '${ctx_class:-nothing}', which is not a class this hook understands, so the dropped-step guard's scope could not be confirmed against branch protection (ersatztv#787).${ctx_diag:+ It said: ${ctx_diag}}Check scripts/check-required-contexts.sh." ;;
esac
fi # end of the guard-scope freshness arm (opened at `if [ "$ctx_repo_fold" = ... ]` above). The
# body is left unindented to match the rest of this file, which is flat throughout; the marker
# is here because the block is long enough that its extent is otherwise easy to misread.
if [ "$class" = "positive" ]; then
# (a) CI + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
# The reason string must not claim more than was actually checked: on the merge_when_checks_succeed
# path this hook never read the CI status at all (it is delegated to Gitea), so saying "CI green"
# there was a plain falsehood in the one message a human reads to decide whether to trust the gate.
if [ "$mwcs" = "true" ]; then
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed). A commit pushed before Gitea merges clears the sha-bound verdict status and is blocked by the 'review-verdict/h10' required check (ersatztv#622) — which this hook has just CONFIRMED is still required on '$base_ref' — read from the repo's full rule list and matched with Gitea's own plain-vs-glob split, refusing rather than guessing wherever precedence or folding is not derivable. That guarantee holds while that branch protection stands; if it is weakened after this check, nothing here would see it (ersatztv#778). Auto-granted."
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed), and because the verdict status is bound to this sha, a commit pushed before Gitea merges will clear it and block the merge (ersatztv#622). Auto-granted."
fi
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
fi
-7
View File
@@ -2,13 +2,6 @@
# PreToolUse / browser-navigate — deny opening download/stream endpoints in a tab
# (they hang the MCP session; curl them instead). Fail-open on parse trouble.
set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-nav-guard "" capture || true
input=$(cat)
url=$(printf '%s' "$input" | jq -r '.tool_input.url // ""' 2>/dev/null || true)
@@ -8,13 +8,6 @@
# So the main tree (never marked) and pre-convention worktrees (no marker) are unaffected;
# only a commit/merge into another session's marked worktree is blocked.
set -euo pipefail
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
# Claude hook: decides by printed JSON, so stdout is captured.
ETV_HOOK_FIRE_LIB="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)/scripts/hook-fire-log.sh" || true
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
etv_hook_fire_begin pretooluse-worktree-guard "" capture || true
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
+3 -103
View File
@@ -4,15 +4,8 @@ description: "ErsatzTV custom IPTV channel management — REST API, SQLite DB, J
---
> **Canonical copy: `~/ersatztv/.claude/skills/ersatztv/SKILL.md`** (ersatztv owns this skill per that
> repo's `CLAUDE.md` → Project Boundaries and `process.ersatztv-owns-code-not-operations`). Both
> `~/server-management/.claude/skills/ersatztv` **and** `~/media-management/.claude/skills/ersatztv`
> are symlinks to it. Edit it in the ersatztv repo; never fork a second copy (ersatztv#617, #755) —
> media-management's copy had silently become a divergent fork still describing a Blazor UI that no
> longer exists, which is what made this the rule rather than a preference.
>
> **Channel OPERATIONS (create/edit a live channel, lineup, collection, schedule, playout, logo,
> overlay) are `media-management`'s job**; ersatztv owns the fork code, `/api/v1`, CI and releases.
> This skill serves both — it is the operator's reference *and* the developer's map.
> repo's `CLAUDE.md` → Project Boundaries). `~/server-management/.claude/skills/ersatztv` is a symlink
> to it. Edit it in the ersatztv repo; never fork a second copy (ersatztv#617).
# ErsatzTV Channel Management
@@ -187,99 +180,6 @@ POST /api/v1/libraries/{id}/scan-show \
POST /api/v1/channels/{channelId}/playout/reset
```
### Scripted Schedule API — `/api/v1/scripted/…`
For **programmatic playout building**: each call mutates one build session, addressed by `buildId`.
Documented by its own OpenAPI spec, **separate from `v1.json`** — which is why
`docs/endpoint-index.md` does not list any of it. It ships as **two** files, both served at
`/openapi/` (measured 2026-08-26 on prod: `scripted-schedule.json`, `scripted-schedule-tagged.json`
and `v1.json` all return 200). They carry the same 28 paths, so either answers "what operations
exist"; they differ only in grouping — the plain file puts everything under one `ScriptedSchedule`
tag, the `-tagged` one splits it into Scripted Content / Control / Metadata / Scheduling. Scalar's
`/docs` page renders the `-tagged` file (`Startup.cs` registers `openapi/scripted-schedule-tagged.json`),
which is why the browsable docs are grouped and a raw fetch of the plain file is not.
The base path is **`/api/v1/scripted/playout/build/{buildId}/`**, and `buildId` is routed as a GUID
(`ScriptedScheduleController.cs`). An older archived copy of this skill gave it as `/api/scripted/…`,
without the `v1`; no such route is registered.
**You cannot tell a wrong base path from a stale `buildId` by probing** — measured on prod
2026-08-26, `GET …/context` with a non-existent build id:
| | `/api/v1/scripted/…` | `/api/scripted/…` (no route) |
|---|---|---|
| no key | 401 | 401 |
| valid key | 404 | 404 |
Unauthenticated everything is 401, because the api-key filter runs before routing. Authenticated, the
correct path 404s too — the build session does not exist — so the 404 that a wrong path earns is
indistinguishable from the one a correct path earns. The bound: this holds **while the build id is
not live**. Against a real, open build session the correct path would answer 200 and the difference
would show — but that is not the situation you are in when you are probing to find out why nothing
works. Confirm the route in `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`; do not infer it
from a status code.
```
# 28 operations, derived from scripted-schedule.json on 2026-08-26 (ersatztv#755)
POST add_all {content, fillerKind, customTitle, disableWatermarks}
POST add_collection {key, collection, order}
POST add_count {content, count, fillerKind, customTitle, disableWatermarks}
POST add_duration {content, duration, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
POST add_marathon {key, groupBy, itemOrder, guids, searches, playAllItems, shuffleGroups}
POST add_multi_collection {key, multiCollection, order}
POST add_playlist {key, playlist, playlistGroup}
POST add_search {key, query, order}
POST add_show {key, guids, order}
POST add_smart_collection {key, smartCollection, order}
POST create_playlist {key, items}
POST graphics_off {graphics}
POST graphics_on {graphics, variables}
POST pad_to_next {content, minutes, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
POST pad_until {content, when, tomorrow, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
POST pad_until_exact {content, when, fallback, trim, discardAttempts, stopBeforeEnd, offlineTail, fillerKind, customTitle, disableWatermarks}
POST pre_roll_off (no body)
POST pre_roll_on {playlist}
POST skip_items {content, count}
POST skip_to_item {content, season, episode}
POST start_epg_group {advance, customTitle}
POST stop_epg_group (no body)
POST wait_until {when, tomorrow, rewindOnReset}
POST wait_until_exact {when, rewindOnReset}
POST watermark_off {watermark}
POST watermark_on {watermark}
GET context (no body)
GET peek_next/{content} (no body)
```
Re-derive rather than trusting this table (it is prose and will drift):
```bash
# Absolute path on purpose: this skill is symlinked into ~/server-management and
# ~/media-management, where a repo-relative path would not resolve. ~/ersatztv is the
# shared checkout and can lag origin/main — use the live-instance form below to see
# what is actually deployed.
python3 -c "import json;d=json.load(open('$HOME/ersatztv/ErsatzTV/wwwroot/openapi/scripted-schedule.json'));\
print('\n'.join(f'{m.upper()} {p}' for p,i in d['paths'].items() for m in i if m in('get','post')))"
```
Without a checkout — straight off the running instance (prod; test is port 8410):
```bash
ssh timothy@192.168.1.29 'curl -s http://localhost:8409/openapi/scripted-schedule.json' \
| python3 -c "import json,sys;d=json.load(sys.stdin);\
print('\n'.join(f'{m.upper()} {p}' for p,i in d['paths'].items() for m in i if m in('get','post')))"
```
Field lists above are the request-body property names only; consult the spec for types,
required-ness and defaults. That omission matters for the three on/off pairs: `graphics_on`/
`graphics_off`, `watermark_on`/`watermark_off` and `pre_roll_on`/`pre_roll_off` are **separate
operations, not one toggle**, and the difference is not always visible as differing property names.
`graphics_*` and `pre_roll_*` differ outright. `watermark_on` and `watermark_off` both list
`{watermark}`, but only `on` marks it **required** — `watermark_off` with an **empty** list turns
*every* scripted watermark off (`SchedulingEngine.WatermarkOff`: `watermarks.Count == 0` →
`ClearChannelWatermarkIds()`; `GraphicsOff` is the same shape). Read the schema, not this table,
before sending an `_off`.
## SQLite DB Operations
```bash
@@ -463,7 +363,7 @@ docker start ersatztv
```
- **Dispatcharr caches ErsatzTV's XMLTV.** Repointing its DB rows is not enough — it keeps serving a stale EPG full of dead `ersatztv:8409` artwork URLs (breaks Kodi artwork). Force a refresh (EPG source 9):
```bash
ssh timothy@192.168.1.29 'docker exec dispatcharr python manage.py shell -c \
ssh timothy@192.168.1.99 'docker exec dispatcharr python manage.py shell -c \
"from apps.epg.tasks import refresh_epg_data; refresh_epg_data(9)"'
```
- **`/api/health` returns 401** (needs an API key). The Telegraf probe has no `response_string_match`, so ErsatzTV reads as **unhealthy in Grafana** — a false alarm, and **pre-existing**, not caused by the move. The container healthcheck uses the unauthenticated internal `/health` and is unaffected.
-241
View File
@@ -1,241 +0,0 @@
export const meta = {
name: 'ersatztv-issue-build',
description: 'Close one ersatztv issue or bundle in its own worktree via PR: claim, recon, implement, local gate, adversarial review before the push, fix loop, single push, PR, closing record',
phases: [{ title: 'Recon' }, { title: 'Implement' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }],
}
// args: { issues: [n,...], slug, title, body_summary, done_condition, files_likely, area, size, risk: 'routine'|'rubric',
// needs_e2e, port: the slot's ETV_UI_PORT, avoid: [{issues, files}], trailer: 'Co-Authored-By: ...\nClaude-Session: ...',
// effort?: 'xhigh' for lock/threading/migration work, model?: override for the implementer/fixer }
if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000)) {
return { error: 'args.issues (non-empty), args.trailer (with a Claude-Session: line) and an integer args.port in (1024, 65000) are required' }
}
const issues = args.issues
const ISSUE = issues[0]
const REF = issues.map(n => '#' + n).join(', ')
const BRANCH = `${issues.join('-')}-${(args.slug || 'work')}`
const WT = `/Users/timothy/orca/workspaces/ersatztv/wt-${issues.join('-')}`
const SHARED = '/Users/timothy/ersatztv'
const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv'
const big = args.size === 'large'
const rubric = args.risk === 'rubric'
const implModel = args.model || (args.size === 'small' ? 'sonnet' : 'opus')
const implEffort = args.effort || (args.size === 'small' ? 'medium' : 'high')
const TRAILER = args.trailer
const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')
const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth.
Issue(s) ${REF}: "${args.title}".
Issue body (condensed by a picker; read the real thing): ${args.body_summary}
DONE CONDITION: ${args.done_condition}
Read every issue in the bundle and all its comments yourself: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/${ISSUE} and ${API}/issues/${ISSUE}/comments (the env var is set; never write the credential into a file or a commit).
Other slots of this session are working IN PARALLEL and will edit these files; do not touch them, and if your fix genuinely needs one of them, stop and report it instead of editing:
${JSON.stringify(args.avoid || [], null, 1)}
Working rules, non-negotiable:
- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key, never by chasing a file path named in an old comment. Do not reverse-engineer conventions from source before reading these.
- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a new or reversed convention gets a record under docs/decisions/records/<area>/ and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog docs/decisions/README.md is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new or retitled doc updates docs/README.md.
- A TvContext model change needs a migration in BOTH providers: scripts/add-migration.sh <Name>.
- Tests are NUnit + Shouldly + NSubstitute in the existing *.Tests projects; vitest under web/. Pin the behaviour with a test that reddens when the fix alone is removed; never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS.
- Dependencies use Central Package Management: versions live only in Directory.Packages.props.
- Docs record the end state, never the investigation (docs.no-session-narrative): the path goes in the commit message and the issue comment. Date any measurement you write into a doc.
- Gitea labels take their own endpoint: POST ${API}/issues/{n}/labels {"labels":[100]} adds in-progress, DELETE ${API}/issues/{n}/labels/100 removes it; PATCH silently ignores labels.
- Never use bare git stash (the stash stack is shared across worktrees; commit WIP instead). Never push to main (it is refused server-side anyway). Never amend or force-push a pushed branch; a fix after the push is a new commit. Never cd out of your worktree except to read the shared checkout read-only.
- Kill only PIDs you started; never pkill by name — other sessions run dotnet and Playwright on this machine.`
const WORKTREE = `Worktree: ${WT} on branch ${BRANCH}. Check git -C ${SHARED} worktree list; if absent: git -C ${SHARED} fetch origin && git -C ${SHARED} worktree add ${WT} -b ${BRANCH} origin/main (absolute path, as written). Then give it its own web/node_modules: if cmp -s ${SHARED}/web/package-lock.json ${WT}/web/package-lock.json then cp -Rc ${SHARED}/web/node_modules ${WT}/web/node_modules, else (cd ${WT}/web && npm ci). Do ALL work inside ${WT}. If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Commit as you go; every commit message ends with these trailer lines exactly:
${TRAILER}`
const CLAIM = `CLAIM FIRST, the four-way check from the kickoff (process.parallel-session-claim), for EVERY issue in the bundle: git -C ${SHARED} fetch origin; curl the open PRs (${API}/pulls?state=open&limit=50, page until empty) for a body saying fixes/refs ${REF}; ${issues.map(n => `git -C ${SHARED} ls-remote --heads origin '*${n}*'`).join('; ')}; read each issue's comments for a claim that predates the label. If a PR, branch or comment shows another session already on ${REF} (other than this orchestrator's note, if any), STOP and report done=false with the evidence. Otherwise add the in-progress label and post a claiming comment naming branch ${BRANCH} and worktree ${WT}, on every issue in the bundle. If an issue body has no "## Done-when" section, append one (PATCH ${API}/issues/{n} with the full body): one unticked box per concrete completion criterion drawn from the issue, plus "- [ ] Adversarial review passed". The merge gate derives consent from those boxes; the orchestrator ticks them from your evidence, so write criteria that can be evidenced.`
const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — run it inside ${where} and read the real output; a skipped test is not a passing one:
- .NET: dotnet build the solution, then dotnet test on every test project that covers what you touched (ErsatzTV.Tests, ErsatzTV.Core.Tests, ErsatzTV.Scanner.Tests, ErsatzTV.FFmpeg.Tests, ErsatzTV.Architecture.Tests — all of them for anything under ErsatzTV.Core). Before any push touching .cs: BOM-check the touched set with od -A n -t x1 -N 3 <file> (efbbbf = BOM) and run bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>' (process.bom-format-detection-recipe).
- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test.
- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff check and ruff format --check on any Python you touched. A new executable under scripts/ or .claude/hooks/ needs its row in docs/remote-state-inventory.md and, if it is a guard, in docs/guard-inventory.md — the suites say so.
- Docs: python3 scripts/check-doc-narrative.py --diff origin/main and answer what it flags (it is advisory, the rule is not).
- Live-E2E${args.needs_e2e ? ' IS REQUIRED for this change (write path or UI)' : ' only if you changed a write path or a screen'}: ETV_UI_PORT=${port} scripts/e2e-local.sh <fresh CONFIG_DIR> — port ${port} is yours; one run at a time in that worktree; curl the endpoints, never a browser tab; when done, kill the PID the launcher printed and nothing else. The launcher's pre-flight refuses a busy port and names the holder: report that, do not pick another port and never kill the holder.
- Builds on this Mac are capped at 34 concurrent and other slots are building too: run the .NET and web gates sequentially, not in parallel with each other.`
const GATE = gateFor(args.port, WT)
const REPORT_SCHEMA = {
type: 'object',
required: ['done', 'summary', 'verified', 'left', 'commits', 'head_sha'],
properties: {
done: { type: 'boolean' },
summary: { type: 'string', description: 'what was built, file by file' },
verified: { type: 'string', description: 'exact gate commands run and their real output summary (test counts, E2E result)' },
left: { type: 'string', description: 'what is not done and why; what the next agent must know' },
commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' },
pr_url: { type: 'string' },
head_sha: { type: 'string', description: 'git rev-parse HEAD of YOUR WORKTREE after your last commit (not a PR head) — the finisher derives fix commits from these' },
patch_changed: { type: 'boolean', description: 'finisher only: true if the pre-push rebase changed the patch-id (a conflict resolved or an artifact regenerated)' },
},
}
const FINDINGS_SCHEMA = {
type: 'object', required: ['findings', 'verdict'],
properties: {
verdict: { type: 'string', enum: ['merge', 'send-back'] },
findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: {
severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } },
},
}
const RUNNER_SCHEMA = {
type: 'object', required: ['findings', 'verdict', 'ran'],
properties: {
ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' },
verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings,
},
}
const LAND_SCHEMA = {
type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']),
properties: REPORT_SCHEMA.properties,
}
const RECON_SCHEMA = {
type: 'object', required: ['plan', 'facts', 'risks', 'test_plan'],
properties: {
plan: { type: 'string', description: 'files, handlers, components, signatures, exact edits' },
facts: { type: 'string', description: 'what the docs the task-signal map names and the existing code say, with paths and decision keys' },
risks: { type: 'string' }, test_plan: { type: 'string', description: 'tests to add and the gate or E2E route that proves the done condition' },
},
}
let recon = null
if (big) {
phase('Recon')
recon = await agent(`${COMMON}
You are the recon agent. Read-only, in ${SHARED}. Read the docs the task-signal map names for this task, then find every fact an implementer needs to close ${REF} without re-deriving it: the exact handlers, components, signatures, call sites and guards, the existing tests, and which gate or E2E route proves the done condition. For a multi-site sweep use the csharp-lsp MCP tools, not the LSP tool (docs/local-lsp-tooling.md). Produce a concrete plan.`,
{ label: 'recon', model: 'opus', effort: 'high', schema: RECON_SCHEMA })
}
phase('Implement')
const impl = await agent(`${COMMON}
${WORKTREE}
${CLAIM}
${recon ? `Recon (verify what you rely on):\nPLAN: ${recon.plan}\nFACTS: ${recon.facts}\nRISKS: ${recon.risks}\nTEST PLAN: ${recon.test_plan}\n` : ''}
You are the implementer. Close ${REF} completely: pin the behaviour with tests named for the branch they protect, update the docs the change obligates, commit. Then git fetch origin and rebase onto origin/main if it moved (never merge main in; regenerate generated artifacts), run the LOCAL GATE and STOP — do not push; reviewers read your worktree first, and a finisher pushes once after the review loop is clean. ${GATE}
Report done=true with the gate output when the worktree is ready for review, with pr_url empty and head_sha = git rev-parse HEAD of the worktree after your last commit.`,
{ label: `impl:${REF}`, model: implModel, effort: implEffort, schema: REPORT_SCHEMA })
if (!impl) return { issues, error: 'implementer returned nothing' }
if (!impl.done) return { issues, error: 'implementer stopped', impl }
const reviewCommon = (e2ePort) => `${COMMON}
${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')}
Worktree ${WT}, branch ${BRANCH}, not yet pushed; diff: git -C ${WT} diff origin/main...HEAD. Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or run tests, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the unpushed branch there; run the .NET and web gates sequentially — other slots are building; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).`
const LENSES = [
{ key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test actually reddens when the fix alone is reverted (mutate the clause, not the file).' },
{ key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met in this diff (endpoint → api-conventions + regenerated v1.json/endpoint-index; screen/route → blazor-route-parity + domain-model; convention → decision record + regenerated catalog; new doc → README index); no narrative in docs; every new script or hook has its inventory row; CPM respected; both-provider migration if the model changed; tests are NUnit/vitest in the existing projects; no BOM in touched .cs; no edit to a file another slot owns (listed above); commit trailers present; branch rebased on current origin/main; nothing pushed yet.' },
]
let xfamilyFailedRound = null
let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
async function codexRunner(round) {
const r = await agent(`${reviewCommon(Number(args.port) + 3)}
You run the cross-family review — the diff touches a class where process.independent-review-rubric requires a reviewer from another model family, and you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of the diff of branch ${BRANCH} against origin/main in ${WT} for issue(s) ${REF} with done condition "${args.done_condition}", listing findings as blocking / should-fix / nit with file and evidence, ending with a line VERDICT: merge or VERDICT: send-back. Run it EXACTLY like this, in the background, output to a file, stdin from /dev/null (it hangs otherwise): codex exec -C ${WT} -s read-only "$(cat <prompt>)" < /dev/null > <out> 2>&1 — then wait for the process to exit (poll pgrep on its PID with Monitor; measured 2026-07-28 in the #672 session, a real review took ~35 minutes for a 7-file diff) and read the file. Return its findings faithfully in the schema with ran=true; if the file has no VERDICT line the run failed (quota, tool error) — return ran=false, verdict merge, no findings, and put the file's tail in a single nit finding so the failure is visible; never invent a verdict.`,
{ label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA })
return r
}
async function codexFallback(round, r) {
xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window`
log(`${REF}: ${xfamily}`)
return agent(`${reviewCommon(Number(args.port) + 2)}
You are a COLD, review-only substitute for a cross-family reviewer that could not run. You have seen none of this branch before. Lens: adversarial correctness AND security of the diff against the done condition — the classes process.independent-review-rubric names (locks/concurrency, auth/security, API write paths, migrations, large C# diffs). Run the gate in your own worktree and read the output; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
{ label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA })
}
async function review(round) {
// Per round, like blocking/sendBack: a substitute that failed in round 1 says nothing about the tree
// that lands after round 2, and a stale xfamily string must never reach the PR body.
xfamilyFailedRound = null
xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
// The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second
// worktree-isolated .NET reviewer and starts only after both lenses have returned.
const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null)
const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)}
Review round ${round} of the branch for ${REF}. Lens: ${l.prompt}
Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
{ label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean)
if (!rubric) return lenses
const r = await runnerPromise
if (r && r.ran === true) return lenses.concat([r])
let fb = null
try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) }
if (!fb) { xfamily += ` — the substitute ALSO failed in round ${round}; no cross-family-equivalent review ran`; xfamilyFailedRound = round }
return fb ? lenses.concat([fb]) : lenses
}
let round = 1
const actionable = rs => rs.flatMap(r => r.findings.filter(f => f.severity === 'blocking' || f.severity === 'should-fix'))
const countBy = (rs, sev) => rs.flatMap(r => r.findings).filter(f => f.severity === sev).length
let knownHead = impl.head_sha
let reviews = (await review(round)).filter(Boolean)
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history: [] }
let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
let sendBack = actionable(reviews)
const history = [{ round, reviews, fix: null, fix_range: null }]
while (sendBack.length && round < 3) {
log(`${REF} round ${round}: ${blocking.length} blocking, ${sendBack.length - blocking.length} should-fix — sending back`)
const fix = await agent(`${COMMON}
${WORKTREE}
You are the fixer. Reviewers found these problems in the unpushed branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong:
${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)}
Then re-run the LOCAL GATE and STOP without pushing; the reviewers read the worktree again. ${GATE}
Report, with head_sha = git rev-parse HEAD of the worktree after your last commit.`,
{ label: `fix:r${round}`, phase: 'Fix', model: implModel, effort: implEffort, schema: REPORT_SCHEMA })
if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history }
history[history.length - 1].fix = fix
history[history.length - 1].fix_range = fix.head_sha && fix.head_sha !== knownHead ? `${knownHead}..${fix.head_sha}` : null
knownHead = fix.head_sha || knownHead
round++
reviews = (await review(round)).filter(Boolean)
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history }
blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
sendBack = actionable(reviews)
history.push({ round, reviews, fix: null, fix_range: null })
}
if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history }
if (sendBack.length) return { issues, error: 'should-fix findings still open after two fix rounds; not pushed — the orchestrator decides', should_fix_remaining: sendBack, history }
if (xfamilyFailedRound) return { issues, error: `the cross-family runner and its substitute both failed in round ${xfamilyFailedRound}; not pushed`, cross_family: xfamily, history }
const FIX_RANGES = history.map(h => h.fix_range).filter(Boolean)
const REVIEW_HISTORY = history.map(h => `round ${h.round}: ${h.reviews.length} lens(es); ${countBy(h.reviews, 'blocking')} blocking, ${countBy(h.reviews, 'should-fix')} should-fix, ${countBy(h.reviews, 'nit')} nit` + (h.fix ? (h.fix_range ? `; answered by the fix commit(s) in git log --oneline ${h.fix_range}` : '; answered without a new commit (findings refuted with evidence in the fixer report)') : '; clean — loop ended')).join('\n')
phase('Land')
const FINISH = `FINISH, in this order. Record the patch-id first: git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable. Then git fetch origin; if origin/main moved, rebase onto it (never merge main in; regenerate, never hand-resolve, generated artifacts — the decisions catalog by its generator), re-run the LOCAL GATE, and recompute the patch-id: report patch_changed=true if it differs. ${GATE}
Then ONE push: git push -u origin ${BRANCH}. Open the PR with the Gitea API (POST ${API}/pulls; head=${BRANCH}, base=main, title, body). The body must contain "fixes #N" for every issue in the bundle so the merge closes them, the root cause for a bug fix, the measured numbers, the review history VERBATIM as recorded by the workflow, one line per round, between the markers <<REVIEW HISTORY and REVIEW HISTORY>>:
<<REVIEW HISTORY
${REVIEW_HISTORY}
REVIEW HISTORY>>
${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — and every deliberately-left item with an issue number (file follow-up issues where needed). End the body with:
🤖 Generated with [Claude Code](https://claude.com/claude-code)
${SESSION_URL}
Arm the CI monitor: note the head sha and read ${API}/commits/<sha>/status once. Then the closing-an-issue skill (invoke it through the Skill tool if you have it, otherwise read .claude/skills/closing-an-issue/SKILL.md) with two modifications: do NOT close the issue — the merge closes it — and do NOT tick any "## Done-when" box; instead the "## Closing record" comment you post on each issue, linking the PR, ends with a "Done-when evidence" list giving, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Remove nothing; the orchestrator removes the worktree after the merge. Report the PR URL, the head sha and patch_changed.`
const land = await agent(`${COMMON}
${WORKTREE}
You are the finisher. The branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`,
{ label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA })
if (!land || !land.done) return { issues, error: 'finisher stopped', land, history }
if (!land.pr_url || !land.head_sha) return { issues, error: 'finisher reported done without a PR URL or head sha — the branch may already be pushed; read its report before re-running', land, history }
log(`${REF} PR: ${land.pr_url || 'none'} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`)
let post_rebase_reviews = null
if (land.patch_changed) {
log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`)
round++
post_rebase_reviews = (await review(round)).filter(Boolean)
if (!post_rebase_reviews.length) return { issues, error: 'the post-rebase review round produced no reviews (every lens failed); pushed, no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, cross_family: xfamily, history }
const late = actionable(post_rebase_reviews)
if (late.length) return { issues, error: 'blocking or should-fix findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url, head_sha: land.head_sha, findings_remaining: late, cross_family: xfamily, history, post_rebase_reviews }
}
return { issues, pr_url: land.pr_url, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, impl, land, history, post_rebase_reviews }
-52
View File
@@ -1,52 +0,0 @@
export const meta = {
name: 'ersatztv-pick-next',
description: 'Pick the next N ersatztv issues by the kickoff queue rules from scripts/select-queue.sh and live Gitea state, mutually non-colliding and avoiding what other slots hold, then adversarially verify the set',
phases: [{ title: 'Pick' }, { title: 'Refute' }],
}
// args: { taken: [{issues:[n], files:[...]}], closed: [n...], notes: 'free text', count: how many picks to return (default 3) }
const taken = (args && args.taken) || []
const closed = (args && args.closed) || []
const notes = (args && args.notes) || ''
const count = (args && args.count) || 3
const RULES = `Work read-only in /Users/timothy/ersatztv (the shared checkout; do not modify files, push, label or comment). Never read its git log or HEAD as truth about main: run git -C /Users/timothy/ersatztv fetch origin first, then read origin/main.
Read docs/handoffs/chicorytv-issue-queue.md fully — "Current phase", "Two concurrent tracks", the Selection and Bundles rules, and step 3's four-way claim check — and docs/handoffs/orchestration.md.
Ranking is NOT yours to derive: run ETV_GITEA_BASICAUTH="$ETV_GITEA_BASICAUTH" scripts/select-queue.sh 40 (the env var is already set) and take its order as given. It already excludes in-progress, parked, PRs, bot-authored issues and anything with an open blocker. Resolve only its CLAIM? and UMBRELLA? flags, by reading the flagged issue's body and comments.
Gitea REST: base http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv, auth -u "$ETV_GITEA_BASICAUTH", curl only. Issue: GET /issues/{n}; comments: GET /issues/{n}/comments; open PRs: GET /pulls?state=open&limit=50 (page until a page comes back empty — the endpoint caps limit at 50). Remote branches naming an issue: git -C /Users/timothy/ersatztv ls-remote --heads origin '*<n>*'.
A pick is claimable only if the four-way check is clean: no open PR whose body says fixes/refs #n, no remote branch naming n, no claiming comment on the issue (a claim can precede the label), and the issue is still open after the fetch.
Bundles: after choosing an issue, scan its milestone, its cross-references and its labels for small independent siblings that are cheap to sweep in the same worktree; a bundle is one pick with several issue numbers. Never bundle issues that a taken slot already holds.
ALREADY TAKEN by this orchestrator (in flight, with the files each edits): ${JSON.stringify(taken)}
Closed this session: ${JSON.stringify(closed)}
Orchestrator notes: ${notes}`
const PICK = { type: 'object', required: ['issues', 'title', 'slug', 'rationale', 'body_summary', 'done_condition', 'files_likely', 'area', 'size', 'risk', 'needs_e2e', 'skipped'], properties: {
issues: { type: 'array', items: { type: 'integer' } }, title: { type: 'string' },
slug: { type: 'string', description: 'short kebab-case branch slug, e.g. null-font-family' },
rationale: { type: 'string' },
body_summary: { type: 'string', description: 'body plus all comments, condensed but complete; include the Done-when section verbatim if the issue has one' },
done_condition: { type: 'string' },
files_likely: { type: 'array', items: { type: 'string' } },
area: { type: 'string', enum: ['spa', 'api', 'core', 'scanner', 'ffmpeg', 'ci', 'scripts', 'docs', 'mixed'] },
size: { type: 'string', enum: ['small', 'medium', 'large'] },
risk: { type: 'string', enum: ['routine', 'rubric'], description: 'rubric = touches locks/concurrency, auth/security, an API write-path handler, a DB migration, or will exceed ~150 changed C# lines (process.independent-review-rubric); needs a cross-family review' },
needs_e2e: { type: 'boolean', description: 'true for a write path or UI change (testing.live-e2e-prepush-timing)' },
skipped: { type: 'string', description: 'each higher-ranked issue skipped and the reason' } } }
const SCHEMA = { type: 'object', required: ['picks'], properties: { picks: { type: 'array', items: PICK, description: 'in queue order; each later pick avoids the files of every earlier one' } } }
const VERDICT = { type: 'object', required: ['refuted', 'reason'], properties: { refuted: { type: 'boolean' }, reason: { type: 'string' }, bad_picks: { type: 'array', items: { type: 'integer' }, description: 'issue numbers of the picks that fail, if not all' }, better: { type: 'array', items: { type: 'integer' } } } }
phase('Pick')
const res = await agent(`${RULES}
Walk the selector's order and return up to ${count} issues or natural bundles, in that order, each of which (a) passes the four-way claim check, (b) edits no file a taken slot OR AN EARLIER PICK edits, (c) does not depend on another open issue (an earlier pick counts as open; a blocked-by dependency the selector already dropped), (d) is not a screen, handler or script an earlier pick is already on, (e) is not needs-hands or needs-the-user in disguise (a live-prod measurement nobody can take from here, a design question the body leaves open). Read each candidate's body and comments before accepting or rejecting it. Size is not a reason to skip: a large issue at the top of the queue is a pick, say size=large. Classify risk honestly — a write-path handler is rubric even when the diff is small. Stop early if the eligible queue runs out and say so in the last pick's skipped field; fewer than ${count} is fine, a colliding pair is not.`, { label: 'picker', model: 'sonnet', effort: 'medium', schema: SCHEMA })
const picks = (res && res.picks) || []
if (!picks.length) return { picks: [], refutations: [], note: 'the picker returned no eligible pick', raw: res }
log('picks: ' + picks.map(p => '#' + p.issues.join('+#')).join(', '))
phase('Refute')
const desc = picks.map(p => `- #${p.issues.join(', #')} "${p.title}" (size ${p.size}, risk ${p.risk}, area ${p.area}, e2e ${p.needs_e2e}). Rationale: ${p.rationale}. Files: ${p.files_likely.join(', ')}. Skipped: ${p.skipped}`).join('\n')
const votes = await parallel([
'ordering and claims: re-run scripts/select-queue.sh and the four-way claim check on every pick; refute if a higher-ranked eligible issue was skipped without a valid reason, the picks are out of selector order, or a pick is already claimed by a PR, branch or comment',
'collisions and classification: read the code each pick will touch; refute if any pick edits a file a taken slot or another pick edits, or the same docs section, or depends on an open issue; also refute a risk=routine pick that touches a lock, auth, an API write-path handler or a migration, and a needs_e2e=false pick that changes a write path or a screen',
].map((lens, i) => () =>
agent(`${RULES}
Picks, in order:
${desc}
Lens: ${lens}. Try to refute; name the failing picks in bad_picks and a better ordering in better.`, { label: `refute:${i}`, model: 'sonnet', effort: 'medium', schema: VERDICT })))
return { picks, refutations: votes.filter(Boolean).filter(v => v.refuted) }
-204
View File
@@ -1,204 +0,0 @@
export const meta = {
name: 'ersatztv-resume-branch',
description: 'Resume a paused ersatztv branch: finish or fix, rebase onto origin/main, local gate, adversarial review, fix loop, push, PR body and closing record refreshed',
phases: [{ title: 'Work' }, { title: 'Review' }, { title: 'Fix' }, { title: 'Land' }],
}
// args: { issues, branch, wt, pr (number or ''), mode: 'fix'|'implement', title, risk: 'routine'|'rubric', needs_e2e,
// port: the slot's ETV_UI_PORT, trailer, brief: path to a JSON file holding done_condition, findings, recon, context }
if (!args || !Array.isArray(args.issues) || !args.issues.length || !args.trailer || !/Claude-Session: \S+/.test(args.trailer) || !(Number.isInteger(args.port) && args.port > 1024 && args.port < 65000) || !args.wt || !args.branch || !args.brief) {
return { error: 'args.issues (non-empty), trailer (with a Claude-Session: line), an integer port in (1024, 65000), wt, branch and brief are required' }
}
const issues = args.issues
const REF = issues.map(n => '#' + n).join(', ')
const WT = args.wt
const BRANCH = args.branch
const SHARED = '/Users/timothy/ersatztv'
const API = 'http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv'
const rubric = args.risk === 'rubric'
const TRAILER = args.trailer
const SESSION_URL = TRAILER.split('\n').filter(l => l.startsWith('Claude-Session:')).map(l => l.replace('Claude-Session: ', '')).join('\n')
const COMMON = `Project: ersatztv, a fork of the ErsatzTV IPTV channel server (C#/.NET + a React SPA under web/). Shared checkout ${SHARED} is READ-ONLY for you: never commit there and never read its git log or HEAD as truth about main (process.shared-tree-readonly) — origin/main after a fetch is the only truth.
Issue(s) ${REF}: "${args.title}".
YOUR BRIEF is the JSON file ${args.brief}: read it first with cat. It holds done_condition, context from the orchestrator, findings (the last review round) and recon where they apply.
Read every issue in the bundle and all its comments: curl -s -u "$ETV_GITEA_BASICAUTH" ${API}/issues/N and ${API}/issues/N/comments (the env var is set; never write the credential into a file or a commit).
Working rules, non-negotiable:
- Docs-first is a HARD RULE: read CLAUDE.md, then docs/README.md's task-signal map and ONLY the sections it points to for this task, then docs/contributing.md for the code you touch. Decisions resolve through docs/decisions/README.md by key.
- Docs-update is part of done, same PR: an endpoint change updates docs/api-conventions.md's checklist and regenerates v1.json + endpoint-index.md via ./scripts/update-openapi.sh (build the app project first, then the script, then npm run generate:api under web/); a screen or route change updates docs/blazor-route-parity.md + docs/domain-model.md; a convention gets a record under docs/decisions/records/<area>/ and a regenerated catalog (PYTHONPATH=. python3 scripts/build_decisions_catalog.py — the catalog is generated and shared with other slots: never hand-edit it, regenerate it, and resolve a rebase conflict in it by regenerating); a new doc updates docs/README.md. A TvContext change needs both providers' migrations via scripts/add-migration.sh.
- Tests are NUnit + Shouldly + NSubstitute; vitest under web/. Never set ETV_UPDATE_GOLDENS or ETV_UPDATE_PLAYOUT_GOLDENS. Dependencies only in Directory.Packages.props.
- Docs record the end state, never the investigation; the path goes in the commit message.
- Gitea labels: POST ${API}/issues/{n}/labels {"labels":[100]} / DELETE ${API}/issues/{n}/labels/100; PATCH ignores labels.
- Never use bare git stash. Never push to main. The ONLY sanctioned rewrite of a pushed branch is a rebase onto origin/main pushed with --force-with-lease (process.orchestrated-session); a fix is a new commit, never an amend. Never cd out of the worktree except to read the shared checkout read-only. Kill only PIDs you started.
Worktree: ${WT} on branch ${BRANCH}; it exists, do ALL work inside it. Give it its own web/node_modules if missing (cp -Rc from ${SHARED}/web when the lockfiles match, else npm ci). If git commit is denied by the worktree-owner guard, the worktree belongs to ANOTHER session (orchestrated worktrees carry no marker): never overwrite the marker — STOP and report done=false with the guard's message. Every commit message ends with these trailer lines exactly:
${TRAILER}`
const gateFor = (port, where) => `LOCAL GATE (process.local-gate-before-push) — inside ${where}, real output, a skipped test is not a pass:
- .NET: dotnet build, then dotnet test on every test project covering what the branch touches (all of them for anything under ErsatzTV.Core); BOM-check touched .cs with od -A n -t x1 -N 3 and bash -c 'dotnet format whitespace . --folder --verify-no-changes --include <files>'.
- SPA: cd web && npm run check:api && npm run lint && npm run typecheck && npm run build && npm test.
- scripts/, .claude/, .husky/, .gitea/: PYTHONPATH=. python3 -m pytest scripts/tests -q, plus ruff on touched Python.
- Docs: python3 scripts/check-doc-narrative.py --diff origin/main.
- Live-E2E${args.needs_e2e ? ' IS REQUIRED (write path or UI)' : ' only for a write path or screen change'}: ETV_UI_PORT=${port} scripts/e2e-local.sh <fresh CONFIG_DIR> — port ${port} is yours; one run at a time in that worktree; curl, never a browser tab; kill the PID the launcher printed when done and nothing else; a busy port is reported, never taken over.
- Run the .NET and web gates sequentially; other slots are building.`
const GATE = gateFor(args.port, WT)
const REBASE = `Rebase onto origin/main FIRST: git fetch origin; git rebase origin/main; resolve conflicts faithfully, keeping both sides' intent; regenerate generated artifacts rather than hand-resolving them. A commit titled "WIP: orchestrator checkpoint" holds uncommitted work from the paused session and must be folded into the commit it belongs to, never left in history — if it sits directly on that commit: git reset --soft HEAD~1 && git commit --amend --no-edit; otherwise: git commit --fixup=<target> is already its shape, so GIT_SEQUENCE_EDITOR=true git rebase --autosquash <target>~1 folds it non-interactively.`
const REPORT_SCHEMA = {
type: 'object', required: ['done', 'summary', 'verified', 'left', 'commits', 'head_sha'],
properties: {
done: { type: 'boolean' }, summary: { type: 'string' },
verified: { type: 'string', description: 'exact gate commands run and their real output summary' },
left: { type: 'string' }, commits: { type: 'string', description: 'git log --oneline origin/main..HEAD' }, pr_url: { type: 'string' }, head_sha: { type: 'string', description: 'git rev-parse HEAD of YOUR WORKTREE after your last commit (not a PR head) — the finisher derives fix commits from these' },
patch_changed: { type: 'boolean', description: 'finisher only: true if a second rebase before the push changed the patch-id' },
},
}
const FINDINGS_SCHEMA = {
type: 'object', required: ['findings', 'verdict'],
properties: {
verdict: { type: 'string', enum: ['merge', 'send-back'] },
findings: { type: 'array', items: { type: 'object', required: ['severity', 'file', 'summary', 'evidence'], properties: {
severity: { type: 'string', enum: ['blocking', 'should-fix', 'nit'] }, file: { type: 'string' }, summary: { type: 'string' }, evidence: { type: 'string' } } } },
},
}
const RUNNER_SCHEMA = {
type: 'object', required: ['findings', 'verdict', 'ran'],
properties: {
ran: { type: 'boolean', description: 'false if codex produced no VERDICT line — required, because the fallback branches on it' },
verdict: FINDINGS_SCHEMA.properties.verdict, findings: FINDINGS_SCHEMA.properties.findings,
},
}
const LAND_SCHEMA = {
type: 'object', required: REPORT_SCHEMA.required.concat(['patch_changed']),
properties: REPORT_SCHEMA.properties,
}
phase('Work')
let work
if (args.mode === 'implement') {
work = await agent(`${COMMON}
You are the implementer, continuing a paused session. Read git log and git show for the branch's commits first; a WIP checkpoint commit is the paused implementer's partial edit. ${REBASE} The brief's recon is a plan; verify what you rely on. Finish the done condition completely, with a regression test that reddens against the unfixed code. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
{ label: `impl:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
} else {
work = await agent(`${COMMON}
You are the fixer, continuing a paused session. The PR is #${args.pr}. ${REBASE} Then the brief's findings are the last review round's: fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong. Run the LOCAL GATE and STOP without pushing; reviewers read the worktree first; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
{ label: `fix:${REF}`, model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
}
if (!work) return { issues, error: 'work agent returned nothing' }
if (!work.done) return { issues, error: 'work agent stopped', work }
const reviewCommon = (e2ePort) => `${COMMON}
${gateFor(e2ePort, 'your own isolated worktree (never ' + WT + ')')}
Diff: git -C ${WT} diff origin/main...HEAD (rebased, not yet pushed). ${args.pr ? `PR #${args.pr} exists: its pushed head, its body and any earlier closing record are INTENTIONALLY behind this worktree until the finisher pushes after this review loop and resyncs them — a stale PR head or body is not a finding, and neither is "not pushed".` : 'No PR exists yet; the finisher opens it after this loop.'} Read-only except scratch you create under /private/tmp; do not commit or push. NEVER run rm -rf, git worktree remove, git branch -D or any delete outside a directory you created under /private/tmp this session, and never build a path with .. segments. If you must build or test, do it in your own isolated worktree, never in ${WT}: git fetch ${WT} ${BRANCH} && git checkout --detach FETCH_HEAD puts the branch there; gates sequentially; E2E there on port ${e2ePort} (the GATE above is written for your worktree and that port).`
const LENSES = [
{ key: 'correctness', model: 'opus', isolation: 'worktree', prompt: 'correctness against the done condition: run the gate and, for a write path or screen, the live-E2E route yourself, and read the output; try to break the change with the edge cases the issue and the docs name; check the pinning test reddens when the fix alone is reverted.' },
{ key: 'conformance', model: 'sonnet', prompt: 'repo conformance: docs-update obligations met; no narrative in docs; inventory rows for new scripts/hooks; CPM respected; both-provider migration if the model changed; no BOM in touched .cs; no WIP commit left in history; branch rebased on current origin/main; commit trailers present; PR body will carry fixes #N for each issue.' },
]
let xfamilyFailedRound = null
let xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
async function codexRunner(round) {
const r = await agent(`${reviewCommon(Number(args.port) + 3)}
You run the cross-family review required by process.independent-review-rubric; you are only the runner. Write a prompt file under a directory you create in /private/tmp asking for an adversarial correctness and security review of branch ${BRANCH} against origin/main in ${WT} for ${REF} with done condition from the brief, findings as blocking / should-fix / nit with file and evidence, ending with VERDICT: merge or VERDICT: send-back. Run EXACTLY: codex exec -C ${WT} -s read-only "$(cat <prompt>)" < /dev/null > <out> 2>&1 in the background, wait for the PID to exit (Monitor; measured 2026-07-28 in the #672 session, ~35 minutes for a 7-file diff), read the file, return its findings faithfully with ran=true; no VERDICT line means the run failed — return ran=false, verdict merge, no findings, and the file's tail in one nit finding; never invent a verdict.`,
{ label: `review:codex:r${round}`, phase: 'Review', model: 'sonnet', effort: 'low', schema: RUNNER_SCHEMA })
return r
}
async function codexFallback(round, r) {
xfamily = `codex could not run in round ${round} (${r ? 'no VERDICT line' : 'runner returned nothing'}); substituted a cold same-family review-only agent per process.independent-review-rubric — retry cross-family next window`
log(`${REF}: ${xfamily}`)
return agent(`${reviewCommon(Number(args.port) + 2)}
You are a COLD, review-only substitute for a cross-family reviewer that could not run. Lens: adversarial correctness AND security of the diff against the done condition in the brief. Run the gate in your own worktree; report only what you verified, with evidence. blocking = done condition or a repo rule violated; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
{ label: `review:fallback:r${round}`, phase: 'Review', model: 'opus', effort: 'high', isolation: 'worktree', schema: FINDINGS_SCHEMA })
}
async function review(round) {
// Per round, like blocking/sendBack: a substitute that failed in round 1 says nothing about the tree
// that lands after round 2, and a stale xfamily string must never reach the PR body.
xfamilyFailedRound = null
xfamily = rubric ? 'codex' : 'not required (routine risk class under process.independent-review-rubric)'
// The Codex runner builds nothing, so it may run beside the lenses; the FALLBACK is a second
// worktree-isolated .NET reviewer and starts only after both lenses have returned.
const runnerPromise = rubric ? codexRunner(round).catch(() => null) : Promise.resolve(null)
const lenses = (await parallel(LENSES.map(l => () => agent(`${reviewCommon(Number(args.port) + 1)}
Review round ${round} of the branch for ${REF}. Lens: ${l.prompt}
Be adversarial; report only what you verified, with evidence. blocking = done condition or a repo rule violated, or a test that passes for the wrong reason; should-fix = real defect; nit = style. Verdict send-back if any blocking.`,
{ label: `review:${l.key}:r${round}`, phase: 'Review', model: l.model, effort: 'high', isolation: l.isolation, schema: FINDINGS_SCHEMA })))).filter(Boolean)
if (!rubric) return lenses
const r = await runnerPromise
if (r && r.ran === true) return lenses.concat([r])
let fb = null
try { fb = await codexFallback(round, r) } catch (e) { log(`${REF}: fallback reviewer threw: ${e && e.message}`) }
if (!fb) { xfamily += ` — the substitute ALSO failed in round ${round}; no cross-family-equivalent review ran`; xfamilyFailedRound = round }
return fb ? lenses.concat([fb]) : lenses
}
let round = 1
const actionable = rs => rs.flatMap(r => r.findings.filter(f => f.severity === 'blocking' || f.severity === 'should-fix'))
const countBy = (rs, sev) => rs.flatMap(r => r.findings).filter(f => f.severity === sev).length
let knownHead = work.head_sha
let reviews = (await review(round)).filter(Boolean)
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history: [] }
let blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
let sendBack = actionable(reviews)
const history = [{ round, reviews, fix: null, fix_range: null }]
while (sendBack.length && round < 3) {
log(`${REF} round ${round}: ${blocking.length} blocking, ${sendBack.length - blocking.length} should-fix — sending back`)
const fix = await agent(`${COMMON}
You are the fixer. Reviewers found these problems in the unpushed, rebased branch; fix every blocking and should-fix one as new commits, or show with evidence why a finding is wrong:
${JSON.stringify(reviews.flatMap(r => r.findings.filter(f => f.severity !== 'nit')), null, 1)}
Re-run the LOCAL GATE and STOP without pushing; report head_sha = git rev-parse HEAD of the worktree after your last commit. ${GATE}`,
{ label: `fix:r${round}`, phase: 'Fix', model: 'opus', effort: 'high', schema: REPORT_SCHEMA })
if (!fix || !fix.done) return { issues, error: `fixer for round ${round} ${fix ? 'stopped' : 'returned nothing'}; not pushed`, fix, history }
history[history.length - 1].fix = fix
history[history.length - 1].fix_range = fix.head_sha && fix.head_sha !== knownHead ? `${knownHead}..${fix.head_sha}` : null
knownHead = fix.head_sha || knownHead
round++
reviews = (await review(round)).filter(Boolean)
if (!reviews.length) return { issues, error: `review round ${round} produced no reviews (every lens failed); not pushed`, history }
blocking = reviews.flatMap(r => r.findings.filter(f => f.severity === 'blocking'))
sendBack = actionable(reviews)
history.push({ round, reviews, fix: null, fix_range: null })
}
if (blocking.length) return { issues, error: 'blocking findings after two fix rounds; not pushed', blocking_remaining: blocking, history }
if (sendBack.length) return { issues, error: 'should-fix findings still open after two fix rounds; not pushed — the orchestrator decides', should_fix_remaining: sendBack, history }
if (xfamilyFailedRound) return { issues, error: `the cross-family runner and its substitute both failed in round ${xfamilyFailedRound}; not pushed`, cross_family: xfamily, history }
const FIX_RANGES = history.map(h => h.fix_range).filter(Boolean)
const REVIEW_HISTORY = history.map(h => `round ${h.round}: ${h.reviews.length} lens(es); ${countBy(h.reviews, 'blocking')} blocking, ${countBy(h.reviews, 'should-fix')} should-fix, ${countBy(h.reviews, 'nit')} nit` + (h.fix ? (h.fix_range ? `; answered by the fix commit(s) in git log --oneline ${h.fix_range}` : '; answered without a new commit (findings refuted with evidence in the fixer report)') : '; clean — loop ended')).join('\n')
phase('Land')
const FINISH = `FINISH: record the patch-id (git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable); git fetch origin; if origin/main moved again, rebase onto it, re-run the LOCAL GATE, and recompute the patch-id — report patch_changed=true if it differs. ${GATE}
Then git push --force-with-lease origin ${BRANCH}. ${args.pr ? `Update PR #${args.pr}'s body (PATCH ${API}/pulls/${args.pr}) so it describes the branch as it now is` : `Open a PR (POST ${API}/pulls; head=${BRANCH}, base=main)`}: the body must contain "fixes #N" for every issue in the bundle, the root cause for a bug fix, the measured numbers, the review history VERBATIM as recorded by the workflow, one line per round, between the markers <<REVIEW HISTORY and REVIEW HISTORY>>:
<<REVIEW HISTORY
${REVIEW_HISTORY}
REVIEW HISTORY>>
${FIX_RANGES.length ? `followed by what each fix commit changed, read from git show and not from memory, for exactly the commits git log --oneline lists in these ranges: ${FIX_RANGES.join('; ')}` : (history.some(h => h.fix) ? 'and a sentence saying every finding was answered without a new commit, as the history block records' : 'and a sentence saying no fix commit exists because round one was clean')}, then the cross-family review status verbatim — "${xfamily}" — every deliberately-left item with an issue number, and end with:
🤖 Generated with [Claude Code](https://claude.com/claude-code)
${SESSION_URL}
Read ${API}/commits/<head-sha>/status once to arm the CI monitor. Post or update the "## Closing record" comment on each issue (the closing-an-issue skill's template) linking the PR, without closing the issue and without ticking any "## Done-when" box; end it with a "Done-when evidence" list naming, for every box, the command or artifact that evidences it — the orchestrator ticks from that. Report the PR URL, the head sha and patch_changed.`
const land = await agent(`${COMMON}
You are the finisher. The rebased branch has passed its review loop (${round} round(s)); nothing is pushed yet. ${FINISH}`,
{ label: `land:${REF}`, model: 'sonnet', effort: 'medium', schema: LAND_SCHEMA })
if (!land || !land.done) return { issues, error: 'finisher stopped', land, history }
if (!(land.pr_url || args.pr) || !land.head_sha) return { issues, error: 'finisher reported done without a PR or head sha — the branch may already be pushed; read its report before re-running', land, history }
log(`${REF} PR: ${land.pr_url || args.pr} @ ${land.head_sha || '?'}${land.patch_changed ? ' (patch changed by the pre-push rebase)' : ''}`)
let post_rebase_reviews = null
if (land.patch_changed) {
log(`${REF}: patch changed on rebase — one more review round on the pushed head before any verdict`)
round++
post_rebase_reviews = (await review(round)).filter(Boolean)
if (!post_rebase_reviews.length) return { issues, error: 'the post-rebase review round produced no reviews (every lens failed); pushed, no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, cross_family: xfamily, history }
const late = actionable(post_rebase_reviews)
if (late.length) return { issues, error: 'blocking or should-fix findings on the pushed head after the pre-push rebase; no verdict may be posted', pr_url: land.pr_url || args.pr, head_sha: land.head_sha, findings_remaining: late, cross_family: xfamily, history, post_rebase_reviews }
}
return { issues, pr_url: land.pr_url || args.pr, head_sha: land.head_sha, patch_changed: !!land.patch_changed, cross_family: xfamily, work, land, history, post_rebase_reviews }
-12
View File
@@ -1,12 +0,0 @@
{
"repo": "timothy/ersatztv",
"branch": "main",
"read_on": "2026-08-27",
"source": "GET /repos/timothy/ersatztv/branch_protections -> the rule governing `main` -> status_check_contexts",
"why": "ersatztv#787. The committed mirror of the required status checks on `main`. It exists because the guards that make a required context trustworthy run in `pr-checks.yml::script-tests`, which checks out with persist-credentials:false and holds no Gitea credential, so it cannot ask the server. scripts/tests/test_ci_dropped_step_guard.py DERIVES its marked-job scope from `contexts` rather than repeating it as a literal, and scripts/check-required-contexts.sh compares this list against the live one wherever a credential does exist. Editing `contexts` by hand without re-reading the server is the one move that defeats both. The `repo` field exists because the merge-consent hook fires for whatever owner/repo the merge tool was called with: without it, merging a PR in another repo from an ersatztv session compares that repo's live contexts against THIS repo's mirror and reports a confident, flatly false finding about it.",
"contexts": [
"Build ErsatzTV Image / Build & test (.NET) (pull_request)",
"Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request)",
"review-verdict/h10"
]
}
+11 -85
View File
@@ -4,74 +4,29 @@ name: Build CI Toolchain Image
# pushes it to the Gitea container registry (ersatztv#390). The toolchain jobs in
# docker-build.yml consume it via `container:`, pinned to an immutable :<sha>.
#
# push to MAIN touching docker/ci/** -> :<short-sha> + :latest
# workflow_dispatch on main -> :<short-sha> of main's HEAD + :latest
# workflow_dispatch on a branch -> :<short-sha> of that branch's HEAD ONLY (never :latest)
# schedule (weekly) -> picks up base-image security updates
# push touching docker/ci/** -> :<short-sha> (+ :latest only from main)
# workflow_dispatch -> manual rebuild
# schedule (weekly) -> picks up base-image security updates
#
# Deliberately separate from docker-build.yml: this image changes rarely (a Dockerfile edit or
# the weekly cron), while docker-build.yml runs on every push/PR. Coupling them would rebuild a
# ~2GB toolchain image on every commit.
#
# ROLLOUT NOTE: the jobs pin an immutable :<sha>, never :latest — a broken toolchain image would
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is a deliberate
# two-step, and BOTH steps land in the SAME PR: publish (push the docker/ci commit as branch HEAD,
# dispatch this workflow on that branch), then commit the pin bump in docker-build.yml. Merging first
# is not available: a PR that changes docker/ci/** without moving the pin turns `ci-image-pin` red,
# and the merge-consent hook reads the COMBINED commit status, so it will not auto-grant. That much
# predates ersatztv#744 — what #744 changed is how the publish half is performed.
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is therefore
# a deliberate two-step: merge a docker/ci/Dockerfile change (this workflow publishes a new :<sha>),
# then update the pin in docker-build.yml in a follow-up PR whose CI proves the new image works.
# See docs/ci-cd.md -> "CI toolchain image".
#
# Like docker-build.yml: the Gitea registry is HTTP-only, so BuildKit needs the inline
# `http = true` config (it does not inherit the host daemon's insecure-registries setting).
on:
# Publishing from a branch is a DELIBERATE act, not a side effect of pushing (ersatztv#744).
# Gitea resolves a `push` workflow's definition from the pushed branch, so an unfiltered `push`
# trigger ran this file's own YAML — attacker-supplied, unreviewed, with no status check in the
# loop — on a docker-capable runner holding the credential that writes `ersatztv:prod` and the
# `ersatztv-ci:<sha>` five `container:` jobs execute.
#
# BE PRECISE ABOUT WHAT THIS BUYS, because the mechanism cuts both ways: the filter below is read
# from the pushed ref like everything else in this file, so a branch that DELETES it re-enables
# the route. What closes is the DRIVE-BY case — an ordinary push of a legitimate `docker/ci`
# change publishing an image nobody asked for, with no deliberate act anywhere. This is NOT a
# boundary against a malicious or compromised writer and must not be cited as one. That class was
# probed and ACCEPTED in ersatztv#853 (`ci.workflow-dispatch-ref-unrestricted`): Gitea 1.27.1 cannot
# restrict `workflow_dispatch` by ref, and restricting it would close nothing anyway:
# docker-build.yml's head-resolved `pull_request:` runs attacker-authored YAML, which reaches every
# secret in the store — so it covers renovate.yml's RENOVATE_TOKEN too, without dispatching
# renovate.yml at all. Only the DISPATCH third is settled; the `v*` tag push and the PR route
# itself remain open in ersatztv#885. `workflow_dispatch` is loaded from the ref it is dispatched
# on, exactly as the `branches:` filter below is loaded from the pushed ref, and is the deliberate
# publish path (docs/ci-cd.md -> "CI toolchain image").
#
# A `v*` tag push does not match this trigger either: there is no `tags:` key, and a `branches:`
# filter is compared against a branch ref. The exact matcher semantics are not probed here; the
# observable claim is the one that matters — a release cut no longer republishes the toolchain
# image as a side effect.
#
# `.gitea/workflows/ci-image.yml` is NOT in `paths:`, and it left `ci-image-pin`'s `expected` in
# the same change. That pairing is a DECIDED TRADEOFF, not a necessity: keeping it works, because
# the dispatch above can publish the ci-image.yml commit itself and the pin then matches. The
# price is what decided it — that route charges a full ~2GB publish plus a five-pin bump for
# EVERY edit to this file, comments included, and a rebase charges it again. The cost of the side
# taken is stated here and in ci-cd.md: a change to HOW the image is built that lives only in
# this file no longer republishes on its own, so pair it with a `docker/ci/**` edit.
#
# `paths:` here and `ci-image-pin`'s `expected` pathspec in pr-checks.yml MUST name the same
# sources. Since the shared self-reference went, `scripts/tests/test_ci_image_paths_pin_agreement.py`
# is what holds them together: it derives BOTH lists from these two workflows and compares them for
# set equality (ersatztv#855). The two are written in different glob dialects, so it models exactly
# one pair of spellings — `<dir>/**` here against the pathspec `<dir>` — and REFUSES anything else
# rather than canonicalising a pattern space whose spellings the two consumers treat differently.
# Change this list and that guard goes red until the pathspec follows; write it any other way and
# it goes red asking for the new shape to be modelled.
workflow_dispatch:
push:
branches: [main]
paths:
- 'docker/ci/**'
- '.gitea/workflows/ci-image.yml'
schedule:
# Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main).
#
@@ -95,21 +50,6 @@ env:
REGISTRY: 192.168.1.95:3000
CI_IMAGE: 192.168.1.95:3000/timothy/ersatztv-ci
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
# This workflow's registry pushes authenticate with the scoped REGISTRY_* PAT
# (`ci.actions-credential-scoping`), so the injected GITEA_TOKEN serves only its single
# `actions/checkout`. This file was the one workflow #748 could not originally reach: editing it
# re-pointed `ci-image-pin`'s `expected` at the editing commit and reddened a BLOCKING job, and its
# own `paths:` made the edit publish an image. ersatztv#744 took this path out of both
# (`ci.toolchain-image-publish-is-a-dispatch`), so the exemption that briefly existed here is DELETED
# rather than documented — which is what ersatztv#835 asked for.
permissions:
code: read
jobs:
build:
name: Build & push CI image
@@ -118,23 +58,13 @@ jobs:
# toolchain image — the heaviest thing that ran in that lane. `small` is now
# git-only and capped at 1g per job, which would OOM this build.
#
# Rare trigger (main pushes touching docker/ci, a weekly cron, and the occasional
# branch dispatch), so it costs the ubuntu-latest lane almost nothing, and
# ci-runner (.127) runs no prod workload.
# Rare trigger (pushes touching docker/ci + a weekly cron), so it costs the
# ubuntu-latest lane almost nothing, and ci-runner (.127) runs no prod workload.
runs-on: ubuntu-latest
env:
CI_JOB_ROLE: none
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# ersatztv#746's convention, applied here once #744 removed the reason it was skipped:
# without it the action leaves a write-capable Authorization header in .git/config for
# every later step. Nothing here pushes with git — the only git call is the
# `rev-parse --short HEAD` below — and the repo is public, so the clone needs no
# credential of its own. Guarded for every workflow by
# scripts/tests/test_workflow_persist_credentials.py (ersatztv#835).
persist-credentials: false
# only docker/ci/Dockerfile is needed; no git describe/log here
fetch-depth: 1
@@ -146,11 +76,7 @@ jobs:
# Always publish the immutable :<sha> — that is what docker-build.yml pins.
TAGS=("${CI_IMAGE}:${SHORT}")
# :latest is a convenience/floating pointer for humans and the weekly rebuild; jobs must
# never consume it. Only main may move it — and since #744 the `push` trigger is
# main-only, so on that path the branch check is satisfied by construction. It is now the
# SOLE protection on the one event that never exercised it before: a `workflow_dispatch`
# selects any ref, and the branch-side publish path documented in ci-cd.md runs exactly
# that. Do not simplify this away on the reasoning that the trigger is already main-only.
# never consume it. Only main may move it.
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
TAGS+=("${CI_IMAGE}:latest")
fi
-14
View File
@@ -33,27 +33,13 @@ env:
DOTNET_CLI_USE_MSBUILD_SERVER: "0"
MSBUILDDISABLENODEREUSE: "1"
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
# Holds no registry credential and reads nothing from the Gitea API; the injected GITEA_TOKEN serves
# only its one `actions/checkout`.
permissions:
code: read
jobs:
scan:
name: NuGet vulnerable packages
runs-on: ubuntu-latest
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup .NET
uses: actions/setup-dotnet@v4
+50 -400
View File
@@ -38,22 +38,9 @@ name: Build ErsatzTV Image
# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration
# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
# (verified, throwaway PR #418; re-confirmed on 1.27.1, 2026-08-28, ersatztv#747 — `Build & push
# image (amd64)` is `if:`-skipped on every PR and reported `skipped` on the two heads sampled,
# PRs #829 and #828) and we don't rely on how branch protection treats a skipped
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
#
# RELEASE-PATH DELIMITER GATE (ersatztv#767): the `scan` job runs the PyYAML-based delimiter-ban
# test and is a `needs:` of `build`, so a `${{` opener in a banned job's `run:` body means `build`
# never runs. It is deliberately NOT gated by either skip below: the gate's coverage must not depend
# on a detector the gate is not allowed to trust, and it is cheap enough that gating it buys nothing.
# (Do NOT justify that with "the docs-only path still builds an image" — it does not. `Build and
# push` carries the docs_only gate too; a tag build is unaffected only because the script forces
# docs_only=false there.) Note it installs from PyPI (setup-python + pip), putting a NEW network
# dependency between a `v*` tag and its image. Not the only one on this path — `test` runs
# `dotnet restore` and `npm ci` behind actions/cache, and a cache miss reaches nuget.org/npm — but
# newly added here. Fail-closed and loud, and still a real availability dependency.
#
# ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and
# `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs
# `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is
@@ -115,78 +102,19 @@ env:
DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process
MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
# NO JOB ON THE `pull_request` ROUTE NAMES A STORED SECRET (ersatztv#885,
# `ci.pr-route-carries-no-stored-credential`). Gitea resolves a `pull_request` run from the PR HEAD,
# so this file is attacker-authored on that route and every `secrets.*` it names is materialised
# into the run. The scoped REGISTRY_* PAT is therefore held by `build` alone, which is gated
# `if: github.event_name != 'pull_request'`. The jobs that used to hold it now work without it:
# the five `container:` pulls and `toolchain-preflight`'s registry tag READ go through the registry's
# anonymous bearer-token flow, and the three commit-status GETs
# (scripts/ci-detect-already-validated.sh in `test`, `migrations` and `functional-e2e`) read the
# combined-status API unauthenticated. Those are TWO dependencies, on two different objects, with
# opposite failure directions — do not collapse them into one "keep it public or CI breaks loudly".
# The `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version of it
# reports `"repository": null`), so THIS repo's visibility is not what gates the anonymous pull
# token. (1) The five pulls and the preflight need that PACKAGE to stay anonymously pullable, and
# losing it IS loud: every `container:` job dies at image pull, before it runs a step, both
# required contexts among them, and `toolchain-preflight` names the cause in its own 401/403
# message. (2) The three commit-status GETs need `timothy/ersatztv` itself to stay publicly
# readable, and losing that is SILENT: `curl -sf` fails, ci-detect-already-validated.sh falls
# through to `skip=false`, and the jobs stay GREEN — only the ersatztv#420 cross-run skip quietly
# stops firing, which costs a redundant re-validation and never a skip that was not earned. The
# invariant is held by
# scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret.
# The injected token serves only this file's eight `actions/checkout` steps. Note it needs no
# `packages:` unit: the container pulls are anonymous, not token-authenticated.
# (Sites above are named by JOB, not by line number: this file is ~1150 lines, so any edit above a
# citation silently invalidates it — a line-number citation here has gone stale within two lines
# of being written.)
permissions:
code: read
jobs:
# Answers "is the toolchain image still there?" in ONE place, so a deleted pin does not read as
# five broken jobs and a broken diff (ersatztv#772). Deliberately container-free and deliberately
# NOT a `needs:` of the jobs it diagnoses — see scripts/ci-toolchain-image-resolves.sh for both
# decisions and for the cleanup-rule root cause it cannot fix from this repo.
toolchain-preflight:
name: CI toolchain image resolves
runs-on: small
env:
CI_EXECUTION_CLASS: bare-runner
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Resolve the pinned toolchain tag in the registry
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark resolve
scripts/ci-toolchain-image-resolves.sh
- name: Assert every expected step executed (ersatztv#756)
run: >-
scripts/ci-step-ran.sh assert
--always resolve
test:
name: Build & test (.NET)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and,
# here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit.
fetch-depth: 2
@@ -194,21 +122,14 @@ jobs:
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
# EVERY consequential `run:` step in this job marks itself as its FIRST act (ersatztv#756),
# and the trailing `Assert every expected step executed` guard fails the job when one is
# missing. This is a REQUIRED context on `main`, and a step the runner drops takes the job
# GREEN having done no work — see scripts/ci-step-ran.sh for why that is fail-OPEN here while
# the same drop in review-verdict.yml is fail-CLOSED.
- name: Detect docs-only changes
id: detect
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect
scripts/ci-detect-docs-only.sh
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
scripts/ci-detect-already-validated.sh
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
@@ -220,9 +141,7 @@ jobs:
- name: Restore
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore
dotnet restore
run: dotnet restore
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
# the SPA's package downloads are project deps, so they stay cached per lockfile.
@@ -237,50 +156,36 @@ jobs:
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark npm-ci
npm ci
run: npm ci
- name: Check generated SPA API client
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark check-api
npm run check:api
run: npm run check:api
- name: Lint SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark lint
npm run lint
run: npm run lint
- name: Typecheck SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark typecheck
npm run typecheck
run: npm run typecheck
- name: Test SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-test
npm test -- --run
run: npm test -- --run
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark web-build
npm run build
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark strip-scanner
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
# Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so
# its high-water mark spans them (SPA build/test above are comparatively light). Paired with the
@@ -294,16 +199,13 @@ jobs:
- name: Build
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build
dotnet build --configuration Release --no-restore
run: dotnet build --configuration Release --no-restore
- name: Test
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark dotnet-test
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal \
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
run: >-
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
# per test project (via --collect above); ReportGenerator merges them into a human-readable
@@ -356,48 +258,14 @@ jobs:
continue-on-error: true
run: scripts/ci-peak-anon.sh report
# THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this
# asserts the whole expected SET was recorded. A step the runner declines to interpolate is
# DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context
# reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED
# version of the same bug that #751 fixed in review-verdict.yml.
#
# NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in
# this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has
# exactly one real step, so there is no ordinary red for it to talk over. Here there are
# twelve, and a genuine failure in an early one (a lint error, a failing test) SKIPS every
# later step — an `always()` guard would then announce "these steps never executed: typecheck
# web-test build dotnet-test" on top of every normal red build. That is not a dropped step, it
# is the runner doing what it is told, and a guard that cries wolf on every red build is a
# guard that gets deleted.
#
# The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it
# rests on is worth stating because it is what makes the omission safe rather than lucky: this
# step is skipped ONLY when an earlier step failed, and an earlier step failing already fails
# the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A
# dropped step is invisible precisely because it concludes `success`, which keeps the job green
# and therefore reaches here.
#
# ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command
# with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite.
# The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload
# there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths
# are held to naming a real context by
# test_every_workflow_expression_names_a_REAL_context_or_function.
- name: Assert every expected step executed (ersatztv#756)
env:
ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }}
ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }}
run: >-
scripts/ci-step-ran.sh assert
--always detect revalidate
--gated restore npm-ci check-api lint typecheck web-test web-build strip-scanner build dotnet-test
migrations:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
# can't block image builds until it's proven reliable on the runner. Promote to a
# required check / build dependency once green. (ersatztv#13)
@@ -450,33 +318,24 @@ jobs:
--health-interval=5s
--health-timeout=5s
--health-retries=30
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate
# step's `HEAD^2` tree comparison can resolve on a main merge commit.
fetch-depth: 2
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
# Same per-step marker contract as the `test` job above (ersatztv#756) — this is the other
# REQUIRED context, so a dropped migration-replay step would report EF integrity green having
# replayed nothing.
- name: Detect docs-only changes
id: detect
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark detect
scripts/ci-detect-docs-only.sh
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark revalidate
scripts/ci-detect-already-validated.sh
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
@@ -488,15 +347,11 @@ jobs:
- name: Restore
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark restore
dotnet restore
run: dotnet restore
- name: Build
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark build
dotnet build --configuration Release --no-restore
run: dotnet build --configuration Release --no-restore
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
# — no per-run `dotnet tool install`. Bump its version there (ersatztv#390).
@@ -506,7 +361,6 @@ jobs:
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark sqlite
echo "::group::SQLite model drift (has-pending-model-changes)"
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
@@ -530,7 +384,6 @@ jobs:
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;"
run: |
set -euo pipefail
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark mysql
echo "::group::MySql model drift (has-pending-model-changes)"
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
@@ -565,42 +418,6 @@ jobs:
# how the original defects escaped. The fixture itself is retained and is opt-in via
# ETV_TEST_MYSQL_CONNECTION (skipped, visibly, without it). Re-arming it here is tracked by #627.
# THE DROPPED-STEP GUARD (ersatztv#756). Every `run:` step above records that it began; this
# asserts the whole expected SET was recorded. A step the runner declines to interpolate is
# DROPPED and still concludes `success` (ersatztv#751), so without this a REQUIRED context
# reports green having done no work — fail-OPEN, and strictly worse than the fail-CLOSED
# version of the same bug that #751 fixed in review-verdict.yml.
#
# NO `if:` HERE, WHICH IS A DELIBERATE DEPARTURE FROM THE #751 GUARD and the one decision in
# this block that is easy to "fix" wrongly. That guard uses `if: always()` because its job has
# exactly one real step, so there is no ordinary red for it to talk over. Here a genuine
# failure in an early step (a failing `dotnet build`, a MySql replay error) SKIPS every later
# step — an `always()` guard would then announce "these steps never executed: sqlite mysql" on
# top of every normal red build. That is not a dropped step, it is the runner doing what it is
# told, and a guard that cries wolf on every red build is a guard that gets deleted.
#
# The default `if:` is `success()`, which is exactly the condition wanted, and the invariant it
# rests on is worth stating because it is what makes the omission safe rather than lucky: this
# step is skipped ONLY when an earlier step failed, and an earlier step failing already fails
# the job. So `guard skipped => job red`, and the only path to a green job runs the guard. A
# dropped step is invisible precisely because it concludes `success`, which keeps the job green
# and therefore reaches here.
#
# ITS OWN BODY CANNOT BE DROPPED BY THE MECHANISM IT GUARDS AGAINST: it is a single command
# with no expression delimiter anywhere in the scalar, so the runner has nothing to rewrite.
# The two gate values come in through `env:`, which is interpolated PER VALUE — a bad payload
# there cannot take the body with it (`ci.workflow-run-body-no-expressions`), and both paths
# are held to naming a real context by
# test_every_workflow_expression_names_a_REAL_context_or_function.
- name: Assert every expected step executed (ersatztv#756)
env:
ETV_DOCS_ONLY: ${{ steps.detect.outputs.docs_only }}
ETV_REVALIDATE_SKIP: ${{ steps.revalidate.outputs.skip }}
run: >-
scripts/ci-step-ran.sh assert
--always detect revalidate
--gated restore build sqlite mysql
functional-e2e:
name: Functional E2E (curl + UI contracts)
runs-on: ubuntu-latest
@@ -615,14 +432,13 @@ jobs:
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree
# comparison can resolve on a main merge commit.
fetch-depth: 2
@@ -633,6 +449,8 @@ jobs:
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
@@ -711,111 +529,6 @@ jobs:
# server. Its exit status is Playwright's.
scripts/e2e-ui.sh
# THE DELIMITER BAN, RE-CHECKED ON THE RELEASE PATH ITSELF (ersatztv#767).
#
# The ban that keeps `build`'s `Smoke + IPTV E2E` from being silently dropped was enforced only by
# `test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body` in the
# `script-tests` job of pr-checks.yml — `on: pull_request`, and NOT a required context. So the ban
# was REVIEW-TIME only: nothing re-checked it on a `v*` tag push, which is precisely when the
# candidate image is published and `DeployStack jazz-media` promotes it.
#
# WHY A JOB AND NOT A STEP INSIDE `build`. A step cannot protect the thing it shares a job with:
# `build` is what publishes, so a guard step there fails OPEN if the runner drops it, and "my body
# has no opener so I cannot be dropped" is circular when the only thing enforcing that property is
# the same PR-only test being backstopped. As a `needs:` of `build`, a red here means `build` never
# runs at all — the image is not built, let alone pushed. Fail-closed by dependency, not by
# assertion.
#
# WHY IT RUNS THE REAL PYTEST rather than a bespoke scanner. A stdlib hand-parser of the workflow
# YAML was TRIED AND REJECTED in #767 (its appeal: no PyYAML to provision on `build`'s bare
# runner). That parser had ~10 false NEGATIVES, all found at once (flow mappings
# `{run: …}`, a quoted `"run":` key, aliases, multiline quoted scalars) — i.e. it was strictly
# WEAKER than the check it was meant to backstop, in the one direction that matters for a security
# gate. Running the existing PyYAML-based test needs no second implementation of "what is a `run:`
# body" and therefore has no drift surface. `small` is git-only, so Python is provisioned here the
# same way `script-tests` does it.
#
# This job's OWN steps carry #756 markers and a trailing assert, so a drop inside THIS job is
# caught too. That terminates the regress at the same axiom the sibling guards already rest on —
# to fail open you must now drop the pytest step AND the assert step, rather than either one.
#
# THIS PUTS A `small`-LANE JOB BACK ON THE TAG PATH, which ersatztv#535 deliberately moved away
# from — say so rather than letting it look accidental. #535 split the git-only gates into
# pr-checks.yml because on the v26.12.0 tag they wedged in act's setup phase, were killed, and
# reported `failure` with no logs. The blast radius here is WORSE than it was then: as a `needs:`
# of `build`, that flake would not merely redden a status, it would skip the build and produce no
# release image at all.
#
# It is acceptable now for a stated reason rather than an assumed one, and the evidence is weaker
# than it first looks — so read the limits. Per `ci.small-lane-git-only`, the lane's per-job cap was
# forced to 10g by its two HEAVIEST members (this file's `build` AND ci-image.yml's toolchain
# buildx), not by `build` alone, and that cap is what pinned the lane to one slot on a 25 GiB host;
# both were moved off in server-management#639, after which the lane is git-only and runs wide and
# tiny. What has NOT been demonstrated is this lane on a TAG PUSH: `script-tests` runs there happily
# but lives in pr-checks.yml (`on: pull_request`), so it has never exercised the condition #535
# measured, and #767's own runs (1928/1929) were `workflow_dispatch` on a scratch branch. The
# lane-width argument is what carries this, not a like-for-like observation. If the wedging returns,
# move this job to `ubuntu-latest` rather than weakening the `needs:` edge — a slower gate is fine,
# an optional one is not.
scan:
name: Delimiter ban (release path)
runs-on: small
env:
CI_EXECUTION_CLASS: bare-runner
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install test dependencies
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark deps
python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
# The ban test plus the structural tests that hold this job's own shape. NOT the whole
# scripts/tests suite: that is `script-tests`'s job, it needs jq/git preflights, and an
# unrelated pytest regression must not be able to block a release.
- name: Run the delimiter-ban tests
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark ban
PYTHONPATH=. python3 -m pytest scripts/tests/test_ci_dropped_step_guard.py scripts/tests/test_ci_release_path_scan_job.py -q
# THE POSITIVE CONTROL, and it is deliberately NOT a test (ersatztv#767). The step above proves
# the ban HOLDS; it cannot prove the ban would NOTICE. DEMONSTRATED: ONE repo-root `pytest.ini`
# (`addopts = -k "not delimiter_banned"`) or `conftest.py` (`pytest_collection_modifyitems`)
# disarms the entire gate, deselecting the ban test and every test guarding it,
# leaving all jobs green with a delimiter sitting in `Smoke`. Nothing inside pytest can be
# trusted to catch that, because pytest's own configuration outranks it.
#
# So this poisons the checked-out workflow, re-runs the SAME command, and fails the job if it
# PASSES. It runs in the real checkout — an isolated copy does not inherit the repo-root config
# a disarm would live in, so a check run in a copy reports healthy while the
# job's real invocation is deselected. The workflow file is restored by an EXIT trap.
- name: Prove the ban would DETECT a delimiter (ersatztv#767)
run: |
"${GITHUB_WORKSPACE:-.}/scripts/ci-step-ran.sh" mark selfcheck
scripts/ci-prove-ban-detects.sh
# No `if:` — see the sibling guards in `test`/`migrations` for why the default `success()` is
# the wanted condition. Both keys are `--always`: every step in this job is unconditional.
#
# THE MARKER-PATH RATIONALE DOES NOT TRANSFER HERE, and assuming it did would be the mistake
# `ci.required-job-step-execution-markers` itself warns about. That record says the run-id and
# attempt keying is "defence in depth" because "these jobs get a fresh container, which is the
# primary protection". This job has NO `container:` — it is on `small`, where RUNNER_TEMP is
# the shared host /tmp. So here the keying is the ONLY protection, and the residual is a
# single-job re-run that does not increment GITHUB_RUN_ATTEMPT: it would find the previous
# attempt's marker file and the assert would pass even had the pytest step been dropped.
# Identity was read off a real run rather than assumed — run 1929 printed
# `Marker identity: job=scan run=1929 attempt=1 (from the runner)`, so all three variables are
# populated on this lane.
- name: Assert every expected step executed (ersatztv#756)
run: >-
scripts/ci-step-ran.sh assert
--always deps ban selfcheck
build:
name: Build & push image (amd64)
# Moved back off `small` (server-management#639). This is the one HEAVY job that
@@ -827,24 +540,17 @@ jobs:
#
# The `ubuntu-latest` queueing that sent it to `small` in the first place
# (server-management#574: a PR-run skip stuck 31 min behind long builds) does not
# come back, because `needs: [test, migrations, scan]` means this job cannot be
# dispatched until those three have already finished — by which point the lane it
# come back, because `needs: [test, migrations]` means this job cannot be
# dispatched until those two have already finished — by which point the lane it
# was queueing behind has drained. Real builds (main/tags) get the full
# ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127).
runs-on: ubuntu-latest
# `scan` (ersatztv#767) re-checks the delimiter ban on the release path. As a `needs:` its red
# SKIPS this job outright, so a delimiter in `Smoke + IPTV E2E` can no longer reach the point
# where an image is published and never booted.
needs: [test, migrations, scan]
needs: [test, migrations]
if: github.event_name != 'pull_request'
env:
CI_EXECUTION_CLASS: bare-runner
CI_JOB_ROLE: none
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
# ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image),
@@ -864,27 +570,7 @@ jobs:
INFO_VERSION="${VERSION}"
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
else
# `git describe` MUST resolve here, and a failure is fatal rather than defaulted
# (ersatztv#836). This job checks out `fetch-depth: 0`, so the tags are present; the
# only thing that ever stopped `describe` from seeing them was the detector step above
# grafting this complete clone shallow. The old `|| echo v0.0.0` was a fallback that
# cannot fail, so from 2026-07-17 (when #416 introduced the depth) until #836 every
# `:latest` image was published carrying
# `InformationalVersion 0.0.0-<sha>` and nothing anywhere went red — the defect was
# found by reading the string out of a running container, which is not a detector.
# Failing the job instead means no `:latest` is published at all: visible, recoverable,
# and never a mislabelled image promoted downstream. The tag path above never calls
# `describe`, so a release cut is unaffected by this.
# stderr is discarded on the CAPTURE and re-run for the diagnostic, rather than folded
# in with `2>&1`: a git warning on the SUCCESS path would otherwise land inside DESC and
# become part of the version string — the same shape of silent corruption this whole
# step is being hardened against.
if ! DESC=$(git describe --tags --abbrev=0 2>/dev/null); then
echo "is-shallow-repository=$(git rev-parse --is-shallow-repository)"
git describe --tags --abbrev=0 || true
echo "::error::git describe --tags --abbrev=0 failed, so this image would ship InformationalVersion 0.0.0-${SHORT} instead of a real version (ersatztv#836). The usual cause is a --depth fetch grafting this complete clone shallow; the two lines above say which."
exit 1
fi
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
INFO_VERSION="${DESC#v}-${SHORT}"
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
fi
@@ -930,33 +616,11 @@ jobs:
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
# THE TWO VALUES COME IN THROUGH `env:`, NOT INLINE (ersatztv#756). This step runs AFTER
# `Build and push`, so on a `v*` tag the image is already in the registry as the release
# candidate — and it is this smoke run that decides whether the candidate was ever booted at
# all. A stray expression delimiter anywhere in this body (a comment is not inert — #751) would
# DROP the step and conclude the job `success`: a candidate published, never smoke-tested, and
# `DeployStack jazz-media` promotes exactly that image. `env:` is interpolated PER VALUE, so a
# bad payload there fails that value instead of taking the whole body with it, and with the
# body delimiter-free the class is unreachable here — held by
# test_the_delimiter_banned_jobs_have_NO_expression_delimiter_in_any_run_body.
#
# The ban IS re-checked on the release path now (ersatztv#767): the `scan` job above runs the
# PyYAML-based ban test and is a `needs:` of this job, so a delimiter here means `build` never
# runs and no image is published. Do not re-add the note that once stood here saying the ban is
# "review-time only, tracked as #767" — that was true before the `scan` job existed.
#
# This step still carries no per-step markers, and that is a genuine (smaller) residual rather
# than a dismissal: markers would additionally catch a drop caused by something OTHER than a
# delimiter. Adding them needs a bucket modelling this step's publish-ref `if:`, which the
# guard's always/gated buckets do not express. The delimiter class itself is covered.
- name: Smoke + IPTV E2E (assert key endpoints)
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
env:
SMOKE_SHORT_SHA: ${{ steps.meta.outputs.short }}
SMOKE_RUN_ID: ${{ github.run_id }}
run: |
IMG="${IMAGE}:${SMOKE_SHORT_SHA}"
NAME="etv-smoke-${SMOKE_RUN_ID}"
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
NAME="etv-smoke-${{ github.run_id }}"
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
echo "Pulling ${IMG}"
docker pull "$IMG"
@@ -1043,29 +707,22 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
if: github.event_name == 'pull_request'
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Detect API-surface changes
id: detect
run: |
base_ref="${{ github.base_ref }}"
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
if ! changed="$(git diff --name-only "origin/${base_ref}...HEAD")"; then
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
exit 1
fi
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"
if printf '%s\n' "$changed" | grep -Eq '^ErsatzTV/Controllers/Api/|^ErsatzTV\.Core/Api/'; then
echo "api_changed=true" >> "$GITHUB_OUTPUT"
@@ -1145,29 +802,22 @@ jobs:
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
if: github.event_name == 'pull_request'
env:
CI_EXECUTION_CLASS: toolchain
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Detect changed C# files
id: detect
run: |
base_ref="${{ github.base_ref }}"
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
if ! changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs')"; then
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
exit 1
fi
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs' 2>/dev/null || true)"
echo "Changed .cs files in this PR:"; printf '%s\n' "$changed"
if [ -n "$changed" ]; then
printf '%s\n' "$changed" > /tmp/changed-cs.txt
+42 -341
View File
@@ -42,84 +42,40 @@ concurrency:
group: ersatztv-pr-gates-${{ github.ref }}
cancel-in-progress: true
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
# Holds no secrets at all and reads nothing from the Gitea API; the injected GITEA_TOKEN serves only
# its five `actions/checkout` steps.
permissions:
code: read
jobs:
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the short sha of
# the last commit to touch the image's SOURCES (`docker/ci/**`). Read that as "the image ci-image.yml
# last published" only under the convention that every such commit is published — this job compares
# git shas and never queries the registry, so it cannot see a pin whose tag was never built or has
# been evicted. Existence is `toolchain-preflight`'s job, and the container jobs' pull is the backstop.
# Since ersatztv#744 publishing from a branch is a `workflow_dispatch`, so "was it published" is a
# human step this job does not observe.
#
# Without this detector, a PR that edits docker/ci/** ships a new image RECIPE while running its own
# 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"): get the
# Dockerfile change published as `:<sha>`, then update the pin to that sha. Since ersatztv#744 the
# publish half of that two-step is a `workflow_dispatch` on the branch rather than a side effect of
# the push — ci-image.yml's `push` trigger is now `branches: [main]`. Seconds-long git+grep -> keep
# it off the build runners.
# 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'
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
# 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 image-source commit
- 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 run that built it. Only
# its filtered `push` clause requires a `docker/ci/**` change; the weekly `schedule` and a
# `workflow_dispatch` both build the selected ref's HEAD whatever it touched. So `expected`
# is not a model of every tag in the registry — it is the one tag a PR is REQUIRED to be
# pinned to: the last commit to change the image's sources.
#
# `.gitea/workflows/ci-image.yml` is deliberately NOT part of `expected` (ersatztv#744),
# and that is a DECIDED TRADEOFF, not a necessity. Keeping it is workable — dispatch the
# branch at the ci-image.yml commit, then pin it — but it prices every edit to that file,
# comments included, at a full ~2GB publish plus a five-pin bump, redone after every
# rebase. Dropping it prices the opposite risk: a change to HOW the image is built living
# ONLY in ci-image.yml (build-args, Dockerfile path, platforms) neither republishes nor
# invalidates the pin, so CI keeps running an image built by the previous recipe. The
# second was chosen because that file is edited far more often for triggers, comments and
# runner placement than for build recipe. Make a recipe change alongside a `docker/ci/**`
# edit — a comment bump suffices, and it is the ONLY remedy: pinning the workflow-only
# commit is rejected here, because `expected` is the last `docker/ci` commit.
# This pathspec and `ci-image.yml`'s `on.push.paths` MUST name the same sources; before
# #744 the shared self-reference kept them in step. Divergence is silent and green in the
# dangerous direction, so it is enforced rather than asserted:
# `scripts/tests/test_ci_image_paths_pin_agreement.py` derives BOTH lists from the two
# workflows and compares them for set equality (ersatztv#855). It takes this pathspec from
# the ASSIGNMENT below rather than from any `git log` in the job, and models only a plain
# `<dir>` against `<dir>/**` there — any other spelling is refused rather than compared.
# Change this pathspec and that guard goes red until `on.push.paths` follows.
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
# 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)"
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)"
@@ -151,10 +107,10 @@ jobs:
# 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. That is no longer blocked by this
# job at all: since ersatztv#744, editing ci-image.yml does NOT re-point `expected`, so a
# `--short=7` change lands like any other PR. It does need a deliberate republish to take
# effect — see the note on `expected` above.
# 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
@@ -165,7 +121,7 @@ jobs:
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. Publish the new :<sha> — push this commit as branch HEAD and dispatch ci-image.yml on the branch (a branch PUSH no longer publishes, ersatztv#744) — then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
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."
@@ -178,32 +134,16 @@ jobs:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: report-only
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
# `continue-on-error` for the same reason the two steps below carry it: this whole job
# is a non-blocking nudge, and an advisory red still joins the combined status the merge gate
# reads. Unmasking the fetch (ersatztv#746) makes a broken base LOUD in the log; it must not
# also make a warn-only job merge-blocking. The three jobs that genuinely gate on this diff —
# api-docs, format, decisions lifecycle — do redden on a failed fetch, which is where that
# belongs.
- name: Warn when a screen/route change skips the parity doc
continue-on-error: true
run: |
base_ref="${{ github.base_ref }}"
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
if ! changed="$(git diff --name-only "origin/${base_ref}...HEAD")"; then
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
exit 1
fi
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
@@ -219,40 +159,6 @@ jobs:
echo "Parity-doc reminder: nothing to flag."
fi
# ersatztv#784 — ADVISORY nudge for `docs.no-session-narrative`. Deliberately NON-BLOCKING and
# deliberately in this job rather than a gate of its own: it is a string predicate over prose,
# and `docs/defect-shapes-773.md` §4 argues that class must not be load-bearing. The script
# exits 0 on every path (asserted per argument shape in scripts/tests/test_check_doc_narrative.py,
# not only in prose), so this step cannot redden the run even on a hit; if you find yourself
# wanting it to fail, read the decision record first — it says no in as many words.
# `python3` is not guaranteed on the bare `small` lane (docs/ci-cd.md), and every other
# python-using job on it declares this. Without it a missing interpreter is exit 127 — a RED
# advisory job joining the combined status, which is the one thing this step must never be.
#
# Both steps OF THIS CHECK (setup-python + the narrative step; the parity nudge above has its
# own) carry `continue-on-error` because the SCRIPT exiting 0 is not the whole invariant:
# a setup-python download failure reddens the job just as effectively as a hit would, and an
# advisory red still joins the combined status the merge gate reads (ersatztv#598). Scope,
# stated rather than implied: this covers the two steps that exist to run the check. A failed
# `Checkout` is NOT covered and deliberately so — with no tree there is nothing to check, and
# a job that cannot run is a different failure from an advisory one that ran and disagreed.
# Measured on this runner (PR#811, run 2179): the job reports `success` and the commit status
# context is `success` with both steps green under `continue-on-error`.
- name: Set up Python
uses: actions/setup-python@v5
continue-on-error: true
with:
python-version: '3.x'
- name: Warn when a doc narrates its own revision history
continue-on-error: true
run: |
base_ref="${{ github.base_ref }}"
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
python3 scripts/check-doc-narrative.py --diff "origin/${base_ref}"
# 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 a Decisions-Edit: yes git
@@ -264,13 +170,10 @@ jobs:
name: decisions lifecycle
runs-on: small
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
@@ -279,10 +182,7 @@ jobs:
- name: Validate decision lifecycle
run: |
base_ref="${{ github.base_ref }}"
if ! git fetch --no-tags origin "$base_ref"; then
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
exit 1
fi
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
@@ -306,245 +206,46 @@ jobs:
# close. A distinct job name keeps a real failure unambiguous.
#
# Runs UNCONDITIONALLY on every PR rather than behind a `scripts/**` path filter. The suite's
# corpus tests are fixture/tmp-repo based, but several execute REAL artifacts from other top-level
# directories: test_post_review_verdict.py runs `scripts/post-review-verdict.sh`,
# test_merge_consent_exemption.py runs `.claude/hooks/pretooluse-merge-consent.sh`, and since
# ersatztv#845 test_post_review_verdict.py ALSO reads `.gitea/workflows/review-verdict.yml` —
# the writer derives the H10 allow-list from it, so editing that literal changes the suite's
# outcome. Its true input set therefore spans at least three top-level directories, and this
# enumeration is the kind that goes stale: a `scripts/**` filter would silently miss a
# `.claude/hooks/**` or `.gitea/workflows/**` edit. The reason is the INPUT SET, not the cost —
# the suite was ~10s when that was decided and is minutes now, and filtering on `scripts/**`
# would still be wrong.
prove-fix:
name: "Fix proofs (Proves trailers)"
runs-on: small
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
# Full history: prove-fix.sh reverts each commit against its PARENT, so a shallow
# clone would leave it unable to resolve `<sha>^` and it would refuse every commit.
uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install test dependencies
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
# OPT-IN BY TRAILER, deliberately. Requiring `Proves:` on every commit would block
# docs, CI and refactor commits that have no code side to revert, and a gate that
# blocks ordinary work gets disabled — which is how a check ends up running nowhere
# (#631). So the trailer is the AUTHOR'S CLAIM, and this job checks claims: write
# one and it must hold. Coverage is therefore honest rather than assumed, and
# `docs/decisions/records/testing/fix-ships-a-witnessed-red-test.md` says so.
- name: Prove every commit that claims a proof
run: |
set -uo pipefail
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
echo "range: $base..$head"
# Capture and VALIDATE the enumeration before looping. `for sha in $(git ...)`
# swallows a git failure: the command substitution yields nothing, the loop body
# never runs, and the job reports "0 claims" green. Fail-open enumeration in the
# thing that decides what gets checked is the defect this job exists to catch.
if ! shas="$(git rev-list "$base".."$head")"; then
echo "::error::git rev-list failed for $base..$head — cannot enumerate commits," \
"so this job cannot assert anything. Refusing to pass."
exit 1
fi
claimed=0; proven=0; failed=0
while IFS= read -r sha; do
[ -n "$sha" ] || continue
# Trim whitespace only — NOT `xargs`, which applies quote parsing and turns a
# legitimate parametrised node id like test_x[can't] into an empty selector,
# silently dropping a real claim.
# Extract with a CHECKED status. `sel="$(git show ... )"` under `set -uo
# pipefail` but no `-e` yields an empty selector when git fails, the commit is
# skipped, and the job exits 0 having been unable to inspect a possible claim —
# fail-open in the step that decides what gets checked.
if ! raw="$(git show -s --format='%(trailers:key=Proves,valueonly)' "$sha")"; then
echo "::error::git show failed for $sha — cannot read its trailers, so this" \
"job cannot assert anything about it. Refusing to pass."
exit 1
fi
# Refuse MORE THAN ONE `Proves:` here too. prove-fix.sh has this guard, but it
# only fires when it reads the trailer itself — and this job passes the selector
# explicitly, so the guard was bypassed on the one path that actually enforces.
# Measured: a commit with two trailers reported PROVEN while the second was never
# run. Fixing the script and not its twin is how a guard reads as coverage.
# Count trailer PRESENCE, not non-empty values: `%(...valueonly)` renders a bare
# `Proves:` as an empty line, so counting non-empty lines misses a commit whose
# FIRST trailer is empty — `sel` then comes out empty and the commit is skipped
# in silence, with a real second selector never checked. Fail-open in CI while
# the script is fail-closed is the same asymmetry this guard exists to remove.
present="$(git show -s --format='%(trailers:key=Proves)' "$sha")"
if [ "$(printf '%s\n' "$present" | grep -c .)" -gt 1 ]; then
claimed=$((claimed + 1)); failed=$((failed + 1))
echo "::error::commit $sha carries more than one 'Proves:' trailer; only the" \
"first would be checked, so the rest would read as proven without ever" \
"running. Use a single selector."
continue
fi
sel="$(printf '%s\n' "$raw" | head -1 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
# A trailer that is PRESENT but empty is a claim with no selector. Refuse it
# loudly; skipping it silently would let the job report "no claims" for a PR that
# made one.
if [ -n "$present" ] && [ -z "$sel" ]; then
claimed=$((claimed + 1)); failed=$((failed + 1))
echo "::error::commit $sha carries a 'Proves:' trailer with no selector."
continue
fi
[ -n "$sel" ] || continue
claimed=$((claimed + 1))
# A merge commit has several parents, so "before this change" is ambiguous.
# prove-fix.sh refuses them; catch it here with a clearer message rather than
# letting the trailer be silently skipped (which --no-merges used to do).
if [ "$(git rev-list --parents -n 1 "$sha" | wc -w)" -gt 2 ]; then
failed=$((failed + 1))
echo "::error::commit $sha is a MERGE carrying 'Proves: $sel'. Put the trailer" \
"on the commit that carries the fix — a merge has no single 'before'."
continue
fi
echo "::group::prove $sha -> $sel"
if bash ./scripts/prove-fix.sh "$sha" "$sel"; then
proven=$((proven + 1)); echo "PROVEN $sha"
else
rc=$?
failed=$((failed + 1))
echo "::error::commit $sha claims 'Proves: $sel' but prove-fix.sh exited $rc." \
"A claimed proof that does not hold is worse than none — it reads as" \
"coverage. Strengthen the test until reverting the fix reddens it, or" \
"drop the trailer."
fi
echo "::endgroup::"
done <<< "$shas"
echo "commits claiming a proof: $claimed (proven $proven, failed $failed)"
if [ "$claimed" -eq 0 ]; then
echo "::notice::No commit in this PR carries a 'Proves:' trailer, so nothing was" \
"verified here. That is allowed — the trailer is opt-in — but it means this" \
"job asserts NOTHING about this PR. Do not read its green as fix coverage."
fi
[ "$failed" -eq 0 ]
# corpus tests are fixture/tmp-repo based, but test_post_review_verdict.py and
# test_merge_consent_exemption.py execute the REAL `scripts/post-review-verdict.sh` and
# `.claude/hooks/pretooluse-merge-consent.sh`, so its true input set spans at least two top-level
# directories. A `scripts/**` filter would silently miss a `.claude/hooks/**` edit — and at ~10s a
# filter buys nothing but drift.
script-tests:
name: Script lint and tests (ruff + pytest)
name: Script tests (pytest)
runs-on: small
if: github.event_name == 'pull_request'
env:
CI_JOB_ROLE: guard
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
# Two consumers need `git`: the lint steps below derive their population from `git ls-files`,
# and test_post_review_verdict.py / test_merge_consent_exemption.py exec the REAL
# post-review-verdict.sh / pretooluse-merge-consent.sh. `curl` those tests shim on PATH; `jq`
# and `git` they do NOT. It stays AHEAD of the lint steps, not merely ahead of pytest: without
# it, a missing git reaches the lint steps as an empty population, which they report as a
# population problem. One actionable line beats a misdirected one, and beats the wall of
# unattributable assertion failures the suite produces without git.
- name: Preflight external tools
run: |
if ! command -v git >/dev/null 2>&1; then
echo "::error::script-tests needs git on PATH but it is absent. The lint steps derive" \
"their population from it and the suite execs real shell scripts that use it." \
"Bake it into the runner image rather than apt-get installing here (ersatztv#390)."
exit 1
fi
echo "Preflight OK: $(git --version)"
# ersatztv#780. Lint runs EARLY — after the git preflight it depends on, but before the test
# dependencies, the jq preflight and the ~4-minute pytest run. A style red therefore arrives in
# seconds, and, more importantly, the lint does not sit behind `Preflight jq version`: that is
# an `--expect` tripwire, so a runner jq bump would take the lint dark for as long as the jq
# contract is broken, under a red that says "jq".
#
# The version is PINNED: an unpinned ruff makes the verdict a function of whenever the job ran
# — the same environment-divergence the committed ruff.toml exists to close. Bumping it is a
# deliberate PR (new rules may fire), exactly like the jq pin below. `pytest`/`pyyaml` are
# deliberately NOT pinned: a pytest release does not add assertions to your suite, a ruff
# release adds rules to your lint.
- name: Install ruff
run: python3 -m pip install --disable-pip-version-check --quiet 'ruff==0.12.11'
# POPULATION. Both steps lint an EXPLICIT list from `git ls-files`, never `ruff check .`, and
# pass `--no-force-exclude`. Measured with ruff 0.12.11 and `exclude = ["scripts/**"]` — a
# per-FILE pattern, because `exclude` matches per file: a bare `["scripts"]` still works at the
# top level but matches nothing under `[lint]`/`[format]`. The subject is a planted tracked file
# holding an unused import, a hardcoded credential and a formatting error. GREEN means the gate
# was silently off:
#
# DISCOVERY FORM EXPLICIT FORM (what ships)
# exclude scope check . format --check . check format --check
# top-level GREEN GREEN red red
# [lint] GREEN red red red
# [format] red GREEN red red
# top + force-exclude GREEN GREEN red red <- with the flag
# GREEN GREEN <- without it
#
# Only the top-level scope empties BOTH discovery commands; `[lint]` empties `check` and
# `[format]` empties `format --check`, so in those two the job would still redden on the other
# step. `[format]` is where a line appended to ruff.toml lands, by TOML rules. `include = []`,
# `extend-exclude` and a nested `scripts/ruff.toml` behave the same way and are equally inert
# against the explicit form. The last row is the whole reason for `--no-force-exclude`:
# `force-exclude = true` re-applies excludes to explicitly-passed paths, and is the one setting
# that reaches explicitly-passed paths at all.
#
# `ruff check .` over an empty tree exits **0** with only a stderr warning, so every GREEN above
# is a gate that was switched off without a red.
#
# This also derives the population from source rather than from the filesystem
# (docs/decisions/records/testing/guard-derives-population-from-source.md) and covers
# tracked-but-gitignored files, which `ruff check .` skips. The empty-population arm is the
# anti-vacuity check: a completeness check whose population is empty reports that it proved
# everything. What it does NOT cover: an emptied RULE set. `select = []` silences every selected
# rule, so the `ruff check` step goes green over any lint violation (a syntax error still reds)
# while printing a reassuring file count.
# `ruff format --check` is unaffected, because formatting is not rule-selected. So half the
# gate is killable by a config edit, and only a human reading that edit catches it.
- name: Lint scripts (ruff check)
run: |
mapfile -d '' -t PYFILES < <(git ls-files -z '*.py' '*.pyi' '*.ipynb')
if [ "${#PYFILES[@]}" -eq 0 ]; then
echo "::error::the lint population is EMPTY — git tracks no Python files. Either the" \
"checkout is wrong or the glob is. A lint over nothing passes; see ersatztv#780."
exit 1
fi
echo "Linting ${#PYFILES[@]} tracked Python files"
python3 -m ruff check --no-force-exclude -- "${PYFILES[@]}"
- name: Lint scripts (ruff format --check)
run: |
mapfile -d '' -t PYFILES < <(git ls-files -z '*.py' '*.pyi' '*.ipynb')
if [ "${#PYFILES[@]}" -eq 0 ]; then
echo "::error::the format population is EMPTY — git tracks no Python files. See ersatztv#780."
exit 1
fi
echo "Format-checking ${#PYFILES[@]} tracked Python files"
python3 -m ruff format --check --no-force-exclude -- "${PYFILES[@]}"
# pytest + PyYAML. PyYAML is NOT a contradiction of the dependency-free decisions READ path:
# `decisions_lib._read_frontmatter` is hand-written precisely so validation runs where nothing
# is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by
# design — and `test_migration_equivalence.py` imports that module, so the suite needs it.
# `pytest` and `yaml` are the complete third-party set, established by an AST import scan over
# all of scripts/ rather than by reading the files that seemed relevant — reading only those
# yields "pure stdlib", a claim that passes locally on a machine that happens to have PyYAML
# and goes red in CI on a collection error.
# all of scripts/ rather than by reading the files that seemed relevant: the first cut of this
# job claimed "pure stdlib", passed locally on a machine that happened to have PyYAML, and
# went red in CI on a collection error.
- name: Install test dependencies
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
# test_post_review_verdict.py and test_merge_consent_exemption.py exec the REAL
# post-review-verdict.sh / pretooluse-merge-consent.sh, which shell out to `jq` ~26 times.
# `curl` those tests shim on PATH; `jq` they do NOT. If it were missing, the suite would fail
# as ~20 opaque assertion errors — this turns that into one actionable line.
- name: Preflight external tools
run: |
if ! command -v git >/dev/null 2>&1; then
echo "::error::script-tests needs git on PATH but it is absent. The suite execs real" \
"shell scripts that use it. Bake it into the runner image rather than apt-get" \
"installing here (see ersatztv#390)."
exit 1
fi
echo "Preflight OK: $(git --version)"
# jq gets its OWN step because its VERSION, not merely its presence, is load-bearing
# (ersatztv#648). `--expect` makes this a TRIPWIRE: scripts/tests exercises the jq 1.6 code path
# only because this runner ships 1.6, so an upgrade would silently delete that coverage — and
-14
View File
@@ -45,26 +45,12 @@ concurrency:
group: ersatztv-renovate
cancel-in-progress: false
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
# This workflow has no checkout step and never uses the injected GITEA_TOKEN for anything. Renovate's
# own branch/PR writes go through RENOVATE_TOKEN, a dedicated bot PAT the Actions default does not
# govern, and its container image comes from Docker Hub. Read-only is declared to STATE that the
# injected token is unused, not because any step needs it.
permissions:
code: read
jobs:
renovate:
name: Renovate
runs-on: ubuntu-latest
container:
image: renovate/renovate:43
env:
CI_JOB_ROLE: none
steps:
- name: Run Renovate
env:
File diff suppressed because it is too large Load Diff
-20
View File
@@ -10,10 +10,6 @@ project.lock.json
# Claude Code
.mcp/
.mcp.json
# Machine-local settings (DOTNET_ROOT and friends — see docs/local-lsp-tooling.md).
# Ignored here rather than relying on a personal ~/.config/git/ignore, so a second
# contributor following that doc cannot accidentally commit their own Homebrew paths.
/.claude/settings.local.json
.agents/
plugins/
nupkg/
@@ -74,11 +70,6 @@ ErsatzTV/wwwroot/app/
web/dist/
web/node_modules
# Root-level link that makes `typescript` resolvable from the repo root, which is
# the LSP workspace root — without it typescript-language-server refuses to start
# (ersatztv#777). See docs/local-lsp-tooling.md.
/node_modules/
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
/*.png
.playwright-mcp/
@@ -95,14 +86,3 @@ web/playwright-report/
# plaintext Gitea credential and absolute /Users paths, so it is neither portable nor safe to
# commit. See ersatztv#711 for the related merge-gate gap.
.codex/
# serena's per-project state, written by `activate_project` (ersatztv#799): project.yml,
# project.local.yml, a language-server cache, and memories/.
#
# This deliberately rejects serena's own versioning model. Its nested .serena/.gitignore excludes
# only `cache` and `project.local.yml`, and project.local.yml says project.yml "is intended to be
# versioned" — but activation here is per DIRECTORY, and every worktree generates a project.yml
# whose project_name is that worktree's folder (e.g. `781-tooling`). A committed copy would name
# the wrong project in every checkout but the one that produced it. memories/ is ignored with it:
# it is serena's own written notes, and this repo's durable knowledge lives in docs/ instead.
.serena/
+2 -3
View File
@@ -12,9 +12,8 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# H11 (ersatztv#311): refuse to push a branch that is BEHIND origin/main — rebase, don't merge
# main in (a merge drags in files you never touched, e.g. legacy-BOM .cs, and trips the format
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1. Exempts a
# tag-only push (ersatztv#719) — forward the ref lines captured above so it can tell.
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-rebase-check.sh || exit 1
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
./.claude/hooks/prepush-rebase-check.sh || exit 1
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
+6 -21
View File
@@ -83,13 +83,11 @@ main in) and re-run the local gate whenever the fetch shows movement.
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 derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent. **The credential you post with must be an account on `H10_REVIEWERS` in `.gitea/workflows/review-verdict.yml`** (`timothy` today) — since ersatztv#742 the gate inherits an existing `success` only from an allow-listed creator (an existing `failure` is left alone on a weaker attributability test, so an attributable rejection VISIBLE AT THE FIRST READ is not re-derived into a green — a rejection landing later, inside a run's own write window, was a separate route and is NARROWED since ersatztv#849 — every path that cannot establish what the head carries now replaces that unknown state with a sticky sentinel instead of leaving it standing; see `ci.verdict-unverified-write-sentinel` for the residuals it names), and since ersatztv#845 the script ENFORCES that coupling rather than assuming it: it reads its own status back and refuses, before writing the verdict comment, unless the recorded `.creator.login` is on that allow-list — so a POSITIVE verdict posted with any other account fails loudly at your terminal instead of being reported as success. The gate still re-derives such a status on the next PR event — that part is unchanged; what the check removes is the tool telling you it worked. **The membership requirement is `success`-only**, mirroring the gate: a `BLOCKED` verdict is honoured from ANY attributable account, so an off-list reviewer can still record a rejection. **The status is still written** — the check runs after the POST, because it measures the creator Gitea recorded rather than what the credential claims — and what is withheld is the verdict COMMENT, which leaves the merge hook at condition (c) with nothing to classify, i.e. an `ask`. So a refused positive verdict leaves a green `review-verdict/h10` standing on that head that the gate itself will not inherit; branch protection binds the context NAME and not its issuer, so do not read that green as consent. The allow-list is derived from the workflow by `scripts/lib/h10-reviewers.sh`; it is never restated.
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent.
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.codex/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
- `.husky/pre-push``prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes. **Since ersatztv#743 that push can no longer happen at all** (see below), so this hook is now belt-and-braces for a path the server refuses.
- `.husky/pre-push``prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
**`main` is PR-only — there is no direct-push path any more (ersatztv#743, `release.main-direct-push-disabled`).** Branch protection carries `enable_push: false` **and** `block_admin_merge_override: true`: a direct `git push origin HEAD:main` is refused server-side at pre-receive for every account including a site admin, the contents API is refused too, and an admin cannot `force_merge` past a missing or red required context. This is what makes `review-verdict/h10` load-bearing rather than conventional — Gitea only evaluates `status_check_contexts` on the PR merge path, so before this the whole gate was skippable with no forgery. Practically: **every** change to `main` goes through a PR, including a one-line docs fix. Tag pushes are unaffected (separate mechanism), so the release cut is unchanged.
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 are exempt from the *review-verdict* gate; the direct-push exemption is moot now that direct pushes are refused outright.
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.
**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`)
@@ -97,30 +95,17 @@ when finishing a task that closes an issue.
## Project Boundaries
**ersatztv OWNS***developing the fork*: the ErsatzTV fork code (C#/.NET), the `/api/v1` REST
surface, M3U/XMLTV generation, the `ErsatzTV.Mcp` server, CI and releases, and the **`ersatztv`
skill** — whose canonical copy is `.claude/skills/ersatztv/SKILL.md` **here**. Both
`~/server-management/.claude/skills/ersatztv` and `~/media-management/.claude/skills/ersatztv` are
symlinks to it (ersatztv#617, #755). Edit it in this repo; never fork a second copy.
**The split that is easy to get wrong** (ersatztv#755, `process.ersatztv-owns-code-not-operations`):
channel/collection/schedule *code* is owned here; **channel OPERATIONS against the running instance
are not**. Creating and editing channels, lineups, collections, schedules, playouts, logos and
overlays on the live ErsatzTV belong to `media-management`. Driving prod from here is in scope only
as *verification of a change this repo is shipping* (live-E2E, a release smoke test) — not as
day-to-day channel work.
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, and the **`ersatztv` skill** — whose canonical copy is `.claude/skills/ersatztv/SKILL.md` **here**; `~/server-management/.claude/skills/ersatztv` is a symlink to it (ersatztv#617). Edit it in this repo; never fork a second copy.
**ersatztv does NOT own**:
- Channel/collection/schedule/playout **operations** against a live instance → media-management
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
- NFS mounts, Ansible, DNS, networking → server-management
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
- Jellyfin skill → server-management. `.claude/skills/jellyfin` here is a **relative symlink** to `~/server-management/.claude/skills/jellyfin` (ersatztv#617 — it had silently become a stale divergent copy). It therefore resolves only in a checkout at `~/ersatztv`, not inside a git worktree; that is inherent to the cross-repo symlink pattern server-management already uses (`beets`, `radarr`, `sonarr`, …).
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
**For content/media sourcing questions and channel operations** (what goes into channels, yt-dlp
pipelines, editing a live channel): open an issue in `timothy/media-management`.
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
+2 -2
View File
@@ -29,7 +29,7 @@
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
<PackageVersion Include="MediatR" Version="[12.5.0]" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.221" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.129" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
@@ -75,7 +75,7 @@
<PackageVersion Include="RichTextKit.Stbear" Version="0.4.167.3" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.12.32" />
<PackageVersion Include="Scriban.Signed" Version="7.2.6" />
<PackageVersion Include="Scriban.Signed" Version="7.2.5" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
@@ -1,44 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// #732: the On Now / Next overlay is a default rather than an opt-in, so every newly created channel
/// gets the built-in element attached.
/// </summary>
/// <remarks>
/// This lives in one place because there is more than one channel-creation path and they diverged
/// once already: <c>CreateChannelHandler</c> had it and <c>CreateChannelFromLineupHandler</c> -- the
/// SPA's primary "Add Channel" flow, and the one Auto-Tune bulk-creates through -- did not. Any new
/// site that persists a <c>Channel</c> must call this. The third site, <c>DbInitializer</c>'s default
/// channel, needs no call: it runs before <c>AttachOnNowNextByDefault</c> in the same startup, so the
/// backfill covers it.
/// </remarks>
public static class ChannelGraphicsDefaults
{
public static async Task Attach(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
{
// HLS Direct is skipped because ErsatzTV is not transcoding there -- there is no frame
// pipeline to draw into, and the editor disables the toggle for the same reason. Identity is
// the element's full seeded path (`GraphicsElementDefaults.OnNowNextSeededPath`), never its
// user-editable Name (the #67 lesson, sharpened from filename to full path by #568).
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect)
{
return;
}
Option<int> maybeElementId =
await GraphicsElementSeeder.GetBuiltInElementId(dbContext, cancellationToken);
foreach (int elementId in maybeElementId)
{
// Add rather than assign: a future create path that carries graphics ids would otherwise
// be silently discarded here.
channel.ChannelGraphicsElements ??= [];
channel.ChannelGraphicsElements.Add(new ChannelGraphicsElement { GraphicsElementId = elementId });
}
}
}
@@ -85,7 +85,6 @@ public class CreateChannelFromLineupHandler(
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
await ChannelGraphicsDefaults.Attach(dbContext, prepared.Channel, cancellationToken);
dbContext.Channels.Add(prepared.Channel);
if (prepared.Playlist is not null)
{
@@ -7,7 +7,6 @@ using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
@@ -36,8 +35,7 @@ public class CreateChannelHandler(
Right: async logoPath =>
{
ApplyResolvedLogo(request, channel, logoPath);
return Right<BaseError, CreateChannelResult>(
await PersistChannel(dbContext, channel, cancellationToken));
return Right<BaseError, CreateChannelResult>(await PersistChannel(dbContext, channel));
},
Left: e => Task.FromResult(Left<BaseError, CreateChannelResult>(e)));
},
@@ -77,12 +75,8 @@ public class CreateChannelHandler(
}
}
private async Task<CreateChannelResult> PersistChannel(
TvContext dbContext,
Channel channel,
CancellationToken cancellationToken)
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
{
await ChannelGraphicsDefaults.Attach(dbContext, channel, cancellationToken);
await dbContext.Channels.AddAsync(channel);
await dbContext.SaveChangesAsync();
searchTargets.SearchTargetsChanged();
@@ -595,13 +595,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
metadata.Genres ??= [];
metadata.Studios ??= [];
// Artists/AlbumArtists are NULLABLE primitive collections, so they are guarded at the read site
// rather than assigned back onto `metadata` like the navigations above (ersatztv#701/#691): they
// are scalar JSON-array columns, so `??= []` on a tracked entity would persist `[]` over NULL.
// The shipped `_song.sbntxt` only does `array.join`, but a user template is free to do anything.
List<string> songArtists = Optional(metadata.Artists).Flatten().ToList();
List<string> songAlbumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
@@ -614,8 +607,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
SongTitle = subtitle,
SongArtists = songArtists,
SongAlbumArtists = songAlbumArtists,
SongArtists = metadata.Artists,
SongAlbumArtists = metadata.AlbumArtists,
SongHasYear = metadata.Year.HasValue,
SongYear = metadata.Year,
SongGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
@@ -47,13 +47,8 @@ public class UpdateChannelHandler(
{
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
return await resolvedLogo.Match(
Right: logoPath =>
ApplyUpdateRequestTranslatingLostRace(
dbContext,
c,
request,
logoPath,
cancellationToken),
Right: async logoPath => Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
},
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
@@ -81,56 +76,6 @@ public class UpdateChannelHandler(
return cached;
}
// Validation and the write are two statements, not one atomic step: RefreshGraphicsElements
// deletes elements whose template file is gone, and a delete landing between the two turns the
// join insert back into the FK violation the validator exists to prevent -- the unhandled 500
// again (#568). A transaction does not close that window either: neither provider locks the rows
// the validator merely READ, so the concurrent delete still commits. Ask the existence question
// again on the failure path instead, and return the same 422 the validator would have returned;
// a DbUpdateException from any other cause keeps its own exception rather than being reported as
// a client error.
//
// What is re-asked is the WHOLE of Validate, not the graphics-element half: every FK on this
// full-replace DTO -- FFmpegProfileId, WatermarkId, FallbackFillerId, MirrorSourceChannelId and
// the graphics element ids -- is written by ApplyUpdateRequest and can lose the same race, and a
// recovery path that names its fields one by one silently omits the next FK the DTO gains.
// Re-running the validator set is what keeps the two paths from drifting: a check added to
// Validate is covered here by construction.
private async Task<Either<BaseError, ChannelViewModel>> ApplyUpdateRequestTranslatingLostRace(
TvContext dbContext,
Channel channel,
UpdateChannel request,
string logoPath,
CancellationToken cancellationToken)
{
try
{
return Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, channel, request, logoPath, cancellationToken));
}
catch (DbUpdateException)
{
// a fresh context: the failed save left the original one tracking the changes that
// could not be written, so the same query there could be answered from those. The
// channel entity is still the tracked one from the failed context, which Validate reads
// only in memory (MirrorSourceMustBeValid's own-playout count) and never re-queries.
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> recheck =
await Validate(recheckContext, request, channel, cancellationToken);
Option<BaseError> maybeError = recheck.Match(
Succ: _ => Option<BaseError>.None,
Fail: errors => Some(errors.Join()));
foreach (BaseError error in maybeError)
{
return Left<BaseError, ChannelViewModel>(error);
}
throw;
}
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
@@ -284,15 +229,14 @@ public class UpdateChannelHandler(
.Apply((_, _, _, _, _) => channel);
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
// fallback filler / graphics elements) with the channel validation; splitting keeps tuple
// arity within LanguageExt's supported applicative range while still accumulating all errors
// fallback filler) with the channel validation; splitting keeps tuple arity within
// LanguageExt's supported applicative range while still accumulating all errors
return (ValidateGroup(request.Group),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken),
channelValidation)
.Apply((_, _, _, _, _, c) => c);
.Apply((_, _, _, _, c) => c);
}
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
@@ -351,28 +295,6 @@ public class UpdateChannelHandler(
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
}
// The reconcile in ApplyUpdateRequest blindly Adds a ChannelGraphicsElement for every incoming
// id; an id with no matching GraphicsElement row would otherwise hit
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId at SaveChangesAsync and surface as
// an unhandled 500 (there is no global exception filter). Reject it here instead, for parity
// with every other FK field on this full-replace DTO (#568). The count cap, the request field
// named in the message and the cap on echoed ids all live in Validators.IdsMustExist, shared
// with the two UpdateDecoHandler twins so the three cannot drift apart.
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken) =>
Validators.IdsMustExist(
request,
r => r.GraphicsElementIds,
"Graphics element",
idsAreConsumed: true,
(ids, token) => dbContext.GraphicsElements
.Where(e => ids.Contains(e.Id))
.Select(e => e.Id)
.ToListAsync(token),
cancellationToken);
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
@@ -35,6 +35,4 @@ public record CreateFFmpegProfile(
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder,
double? ReadRate,
double? ReadRateCatchup) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
@@ -50,12 +50,8 @@ public class CreateFFmpegProfileHandler :
private static Validation<BaseError, FFmpegProfile> Validate(
CreateFFmpegProfile request,
int resolutionId) =>
(ValidateName(request),
ValidateThreadCount(request),
FFmpegProfileBounds.ValidateQsvExtraHardwareFrames(request.QsvExtraHardwareFrames, stored: null),
FFmpegProfileBounds.ValidateReadRate(request.ReadRate),
FFmpegProfileBounds.ValidateReadRateCatchup(request.ReadRateCatchup, request.ReadRate))
.Apply((name, threadCount, _, _, _) =>
(ValidateName(request), ValidateThreadCount(request))
.Apply((name, threadCount) =>
{
var hwAccel = request.NormalizeVideo
? request.HardwareAcceleration
@@ -72,9 +68,11 @@ public class CreateFFmpegProfileHandler :
HardwareAcceleration = hwAccel,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
// stored exactly as submitted: an out-of-range value was already rejected with a
// 422 naming the bound, so there is nothing left to silently rewrite (ersatztv#735)
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
// store what the pipeline will actually use, never a pool size FFmpegState would
// floor away at render time (ersatztv#529)
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames is { } frames
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
: null,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
@@ -113,9 +111,7 @@ public class CreateFFmpegProfileHandler :
NormalizeFramerate = request.NormalizeFramerate,
NormalizeColors = request.NormalizeColors,
DeinterlaceVideo = request.DeinterlaceVideo,
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder,
ReadRate = request.ReadRate,
ReadRateCatchup = request.ReadRateCatchup
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
};
});
@@ -36,6 +36,4 @@ public record UpdateFFmpegProfile(
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder,
double? ReadRate,
double? ReadRateCatchup) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
@@ -55,10 +55,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
// stored exactly as submitted: an out-of-range NEW value was already rejected with a 422
// naming the bound. an unchanged value that predates that validation is written back as-is
// rather than rewritten, and FFmpegState floors it at render time (ersatztv#735)
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
// store what the pipeline will actually use, so a profile doesn't keep displaying a pool
// size that FFmpegState floors away at render time (ersatztv#529)
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames is { } frames
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
: null;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.PadMode = update.PadMode;
@@ -107,8 +108,6 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo;
p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
p.ReadRate = update.ReadRate;
p.ReadRateCatchup = update.ReadRateCatchup;
// don't save invalid preset
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
@@ -132,14 +131,8 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
TvContext dbContext,
UpdateFFmpegProfile request,
FFmpegProfile profile) =>
(await ValidateName(dbContext, request),
ValidateThreadCount(request),
FFmpegProfileBounds.ValidateQsvExtraHardwareFrames(
request.QsvExtraHardwareFrames,
profile.QsvExtraHardwareFrames),
FFmpegProfileBounds.ValidateReadRate(request.ReadRate),
FFmpegProfileBounds.ValidateReadRateCatchup(request.ReadRateCatchup, request.ReadRate))
.Apply((_, _, _, _, _) => profile);
(await ValidateName(dbContext, request), ValidateThreadCount(request))
.Apply((_, _) => profile);
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
@@ -1,79 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles;
/// <summary>
/// Write-path bounds for the consequential numeric FFmpeg profile fields.
/// A submitted value outside its documented range is REJECTED, naming the bound, rather than
/// accepted and silently rewritten to something the caller never sent (ersatztv#735). The
/// render-time clamps in <see cref="FFmpegState" /> stay as they are: they cover rows that
/// predate this validation or were written out of band, which is what keeps the fix
/// migration-free.
/// </summary>
internal static class FFmpegProfileBounds
{
internal static Validation<BaseError, Unit> ValidateQsvExtraHardwareFrames(int? requested, int? stored)
{
// a row stored before this validation existed may hold anything, and the SPA sends the whole
// profile back on every edit — so rejecting an UNCHANGED legacy value would make an old
// profile uneditable over a field the operator never touched (and cannot even see unless
// hardware acceleration is QSV). only a NEWLY submitted out-of-range value is rejected;
// FFmpegState.QsvExtraHardwareFrames still floors the legacy one at render time
if (requested is null || requested == stored)
{
return Success<BaseError, Unit>(Unit.Default);
}
return requested < FFmpegState.MinimumQsvExtraHardwareFrames
? BaseError.New(
$"QSV extra hardware frames must be at least {FFmpegState.MinimumQsvExtraHardwareFrames}; " +
$"{requested} leaves the QSV upload pool with too little headroom and the transcode writes nothing at all")
: Success<BaseError, Unit>(Unit.Default);
}
internal static Validation<BaseError, Unit> ValidateReadRate(double? requested)
{
if (requested is null)
{
return Success<BaseError, Unit>(Unit.Default);
}
return requested is < FFmpegState.MinimumReadRate or > FFmpegState.MaximumReadRate
? BaseError.New(
$"Read rate must be between {Format(FFmpegState.MinimumReadRate)} and {Format(FFmpegState.MaximumReadRate)}; " +
"below realtime the channel stalls, and above this the input is no longer meaningfully paced")
: Success<BaseError, Unit>(Unit.Default);
}
internal static Validation<BaseError, Unit> ValidateReadRateCatchup(double? requested, double? requestedReadRate)
{
if (requested is null)
{
return Success<BaseError, Unit>(Unit.Default);
}
if (requested is < FFmpegState.MinimumReadRateCatchup or > FFmpegState.MaximumReadRateCatchup)
{
return BaseError.New(
$"Read rate catchup must be between {Format(FFmpegState.MinimumReadRateCatchup)} and " +
$"{Format(FFmpegState.MaximumReadRateCatchup)}");
}
// catchup is the rate a LAGGING input may read at until it is level again, so a value at or
// below the base rate cannot let it recover: EQUAL is rejected too, because a catchup with
// zero headroom is functionally no catchup while still reading as configured. compared
// against the transcode default rather than the stream-copy one because that is the higher
// of the two: a value that clears it clears both, without this check having to know the
// profile's video format
double effectiveReadRate = requestedReadRate ?? FFmpegState.DefaultReadRate;
return requested <= effectiveReadRate
? BaseError.New(
$"Read rate catchup ({Format(requested.Value)}) must be greater than the read rate " +
$"({Format(effectiveReadRate)}); a lagging input cannot catch up at a rate it is already paced at")
: Success<BaseError, Unit>(Unit.Default);
}
private static string Format(double value) =>
value.ToString("0.0####", System.Globalization.CultureInfo.InvariantCulture);
}
@@ -36,6 +36,4 @@ public record FFmpegProfileViewModel(
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder,
double? ReadRate,
double? ReadRateCatchup);
bool QsvPreferNativeDecoder);
@@ -38,9 +38,7 @@ internal static class Mapper
profile.NormalizeFramerate,
profile.NormalizeColors,
profile.DeinterlaceVideo == true,
profile.QsvPreferNativeDecoder != false,
profile.ReadRate,
profile.ReadRateCatchup);
profile.QsvPreferNativeDecoder != false);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
new(
@@ -84,7 +82,5 @@ internal static class Mapper
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.NormalizeColors,
ffmpegProfile.DeinterlaceVideo == true,
ffmpegProfile.QsvPreferNativeDecoder != false,
ffmpegProfile.ReadRate,
ffmpegProfile.ReadRateCatchup);
ffmpegProfile.QsvPreferNativeDecoder != false);
}
@@ -1,5 +1,4 @@
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
@@ -13,13 +12,9 @@ public class GetPagedFillerPresetsHandler(IDbContextFactory<TvContext> dbContext
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// no filter today, but count and page are still derived from ONE query so that adding one
// cannot leave the count behind (api.paged-count-matches-page-query)
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
int count = await query.CountAsync(cancellationToken);
List<FillerPresetViewModel> page = await query
int count = await dbContext.FillerPresets.CountAsync(cancellationToken);
List<FillerPresetViewModel> page = await dbContext.FillerPresets
.AsNoTracking()
.OrderBy(f => f.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -22,7 +22,7 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
.Select(e => new
{
Vm = ProjectToViewModel(e),
BuiltIn = GraphicsElementDefaults.IsOnNowNext(e.Path, e.Kind)
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
})
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
.ThenBy(x => x.Vm.Name)
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -13,6 +13,8 @@ public class GetPagedCollectionsHandler(IDbContextFactory<TvContext> dbContextFa
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.Collections.CountAsync(cancellationToken);
IQueryable<Collection> query = dbContext.Collections.AsNoTracking();
if (!string.IsNullOrWhiteSpace(request.Query))
@@ -20,9 +22,6 @@ public class GetPagedCollectionsHandler(IDbContextFactory<TvContext> dbContextFa
query = query.Where(c => EF.Functions.Like(c.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
int count = await query.CountAsync(cancellationToken);
List<MediaCollectionViewModel> page = await query
.OrderBy(c => c.Name)
.Skip(request.PageNum * request.PageSize)
@@ -13,6 +13,9 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.MultiCollections
.CountAsync(mc => mc.OwnedByChannelId == null, cancellationToken);
IQueryable<MultiCollection> query = dbContext.MultiCollections
.AsNoTracking()
.Where(mc => mc.OwnedByChannelId == null);
@@ -22,9 +25,6 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
query = query.Where(mc => EF.Functions.Like(mc.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
int count = await query.CountAsync(cancellationToken);
List<MultiCollectionViewModel> page = await query
.OrderBy(mc => mc.Name)
.Skip(request.PageNum * request.PageSize)
@@ -13,21 +13,18 @@ public class GetPagedRerunCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
IQueryable<RerunCollection> query = dbContext.RerunCollections.AsNoTracking();
int count = await dbContext.RerunCollections.CountAsync(cancellationToken);
IQueryable<RerunCollection> query = dbContext.RerunCollections.AsNoTracking().IncludeSelectionDetails();
if (!string.IsNullOrWhiteSpace(request.Query))
{
query = query.Where(rc => EF.Functions.Like(rc.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758).
// The includes belong to the page chain only — a COUNT does not materialize the graph.
int count = await query.CountAsync(cancellationToken);
// EF applies the includes to the paged subquery, so the selection graph is loaded for at most
// PageSize rows — the per-request cost is bounded by the page, not by the table (issue #671).
List<RerunCollectionViewModel> page = await query
.IncludeSelectionDetails()
.OrderBy(rc => rc.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -13,6 +13,9 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.SmartCollections
.CountAsync(sc => sc.OwnedByChannelId == null, cancellationToken);
IQueryable<SmartCollection> query = dbContext.SmartCollections
.AsNoTracking()
.Where(sc => sc.OwnedByChannelId == null);
@@ -22,9 +25,6 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
query = query.Where(sc => EF.Functions.Like(sc.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
int count = await query.CountAsync(cancellationToken);
List<SmartCollectionViewModel> page = await query
.OrderBy(s => s.Name)
.Skip(request.PageNum * request.PageSize)
@@ -1,5 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -13,13 +12,9 @@ public class GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFac
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// no filter today, but count and page are still derived from ONE query so that adding one
// cannot leave the count behind (api.paged-count-matches-page-query)
IQueryable<TraktList> query = dbContext.TraktLists.AsNoTracking();
int count = await query.CountAsync(cancellationToken);
List<TraktListViewModel> page = await query
int count = await dbContext.TraktLists.CountAsync(cancellationToken);
List<TraktListViewModel> page = await dbContext.TraktLists
.AsNoTracking()
.OrderBy(l => l.Name)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -1,5 +1,4 @@
using System.Threading.Channels;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
@@ -87,29 +86,6 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
var incoming = request.Items.Except([highest]).ToList();
// Reject an EXPLICITLY empty recurrence set before any mutation (#880). The checked set is
// `incoming` -- the exact list whose DaysOfWeek/DaysOfMonth/MonthsOfYear the loops below
// write -- so the check and its subject cannot drift apart. That EXCLUDES the highest-Index
// catch-all by construction: its recurrence is discarded along with its date range (only its
// ProgramScheduleId is read, further down), so an empty set there cannot make anything "never
// apply" and rejecting it would state a reason that is false for that item.
foreach (ReplacePlayoutAlternateSchedule item in incoming)
{
ProgramScheduleAlternate stored = existing.FirstOrDefault(e => e.Id == item.Id);
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
stored?.DaysOfWeek,
stored?.DaysOfMonth,
stored?.MonthsOfYear);
foreach (BaseError error in recurrenceError)
{
return error;
}
}
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
+3 -10
View File
@@ -1,6 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Playouts;
@@ -41,15 +40,9 @@ internal static class Mapper
programScheduleAlternate.Id,
programScheduleAlternate.Index,
programScheduleAlternate.ProgramScheduleId,
// ersatztv#823: these three are NULLABLE columns and a legacy row can hold NULL. Substitute the
// SAME unrestricted defaults AlternateScheduleSelector.GetScheduleForDate reads, so the DTO the
// SPA renders agrees with what actually gets scheduled -- web/src/screens/playoutTemplateCalendar.ts
// `appliesToDate` is an exact port of that method, and it would otherwise both mispreview and
// throw (`[...template.daysOfMonth]` on a null is a TypeError). Never assigned back onto the
// entity (`media.nullable-primitive-collection-mutation`).
programScheduleAlternate.DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
programScheduleAlternate.DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
programScheduleAlternate.MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
programScheduleAlternate.DaysOfWeek,
programScheduleAlternate.DaysOfMonth,
programScheduleAlternate.MonthsOfYear,
programScheduleAlternate.LimitToDateRange,
programScheduleAlternate.StartMonth,
programScheduleAlternate.StartDay,
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Playouts.Mapper;
@@ -13,8 +13,13 @@ public class GetPagedPlayoutsHandler(IDbContextFactory<TvContext> dbContextFacto
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.Playouts.CountAsync(cancellationToken);
IQueryable<Playout> query = dbContext.Playouts
.AsNoTracking()
.Include(p => p.Channel)
.Include(p => p.ProgramSchedule)
.Include(p => p.BuildStatus)
.Filter(p => p.Channel != null);
if (!string.IsNullOrWhiteSpace(request.Query))
@@ -22,15 +27,7 @@ public class GetPagedPlayoutsHandler(IDbContextFactory<TvContext> dbContextFacto
query = query.Where(p => EF.Functions.Like(p.Channel.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758).
// This is also what makes the `Channel != null` filter count, which the old unfiltered
// CountAsync over the whole DbSet did not.
int count = await query.CountAsync(cancellationToken);
List<PlayoutNameViewModel> page = await query
.Include(p => p.Channel)
.Include(p => p.ProgramSchedule)
.Include(p => p.BuildStatus)
.OrderBy(p => p.Channel.SortNumber)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper;
@@ -13,6 +13,8 @@ public class GetPagedProgramSchedulesHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.ProgramSchedules.CountAsync(cancellationToken);
IQueryable<ProgramSchedule> query = dbContext.ProgramSchedules.AsNoTracking();
if (!string.IsNullOrWhiteSpace(request.Query))
@@ -20,9 +22,6 @@ public class GetPagedProgramSchedulesHandler(IDbContextFactory<TvContext> dbCont
query = query.Where(ps => EF.Functions.Like(ps.Name, $"%{request.Query}%"));
}
// count the SAME query the page is taken from, so the two cannot drift (issues #690, #758)
int count = await query.CountAsync(cancellationToken);
List<ProgramScheduleViewModel> page = await query
.OrderBy(ps => ps.Name)
.Skip(request.PageNum * request.PageSize)
@@ -44,25 +44,6 @@ public class ReplacePlayoutTemplateItemsHandler(
List<ReplacePlayoutTemplate> incoming = request.Items;
// Same rule as the alternate-schedule path (#880), over ALL items: unlike that one, every
// template item's recurrence IS stored, so there is no catch-all to exclude here.
foreach (ReplacePlayoutTemplate item in incoming)
{
PlayoutTemplate stored = existing.FirstOrDefault(e => e.Id == item.Id);
Option<BaseError> recurrenceError = RecurrenceSetBounds.Validate(
item.DaysOfWeek,
item.DaysOfMonth,
item.MonthsOfYear,
stored?.DaysOfWeek,
stored?.DaysOfMonth,
stored?.MonthsOfYear);
if (recurrenceError.IsSome)
{
return recurrenceError;
}
}
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
@@ -19,50 +19,7 @@ public class UpdateDecoHandler(
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Deco> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Succ: deco => ApplyUpdateRequestTranslatingLostRace(dbContext, deco, request, cancellationToken),
Fail: errors => Task.FromResult(Left<BaseError, Unit>(errors.Join())));
}
// Mirrors UpdateChannelHandler.ApplyUpdateRequestTranslatingLostRace (#568): validation and the
// write are two statements, so a concurrent delete of a validated watermark or graphics element
// -- RefreshGraphicsElements deletes elements whose template file is gone -- lands the join
// insert on the FK violation the validators exist to prevent, as an unhandled 500. A transaction
// does not close that window either (neither provider locks the rows the validator merely READ),
// so ask the existence questions again on the failure path and return the same 422; a
// DbUpdateException from any other cause keeps its own exception.
//
// The whole of Validate is re-asked rather than a named pair of fields, for the same reason as
// the channel twin: a recovery path that enumerates its own fields omits the next one the DTO
// gains, while re-running the validator set covers a check added to Validate by construction.
private async Task<Either<BaseError, Unit>> ApplyUpdateRequestTranslatingLostRace(
TvContext dbContext,
Deco existing,
UpdateDeco request,
CancellationToken cancellationToken)
{
try
{
return await ApplyUpdateRequest(dbContext, existing, request, cancellationToken);
}
catch (DbUpdateException)
{
// a fresh context: the failed save left the original one tracking the changes that
// could not be written, so the same query there could be answered out of those.
await using TvContext recheckContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Deco> recheck = await Validate(recheckContext, request, cancellationToken);
Option<BaseError> maybeError = recheck.Match(
Succ: _ => Option<BaseError>.None,
Fail: errors => Some(errors.Join()));
foreach (BaseError error in maybeError)
{
return Left<BaseError, Unit>(error);
}
throw;
}
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
}
private async Task<Unit> ApplyUpdateRequest(
@@ -74,7 +31,7 @@ public class UpdateDecoHandler(
existing.Name = request.Name;
// watermark
bool hasWatermark = ConsumesWatermarkIds(request);
bool hasWatermark = request.WatermarkMode is (DecoMode.Override or DecoMode.Merge);
existing.WatermarkMode = request.WatermarkMode;
existing.UseWatermarkDuringFiller = hasWatermark && request.UseWatermarkDuringFiller;
@@ -102,7 +59,7 @@ public class UpdateDecoHandler(
}
// graphics elements
bool hasGraphicsElements = ConsumesGraphicsElementIds(request);
bool hasGraphicsElements = request.GraphicsElementsMode is (DecoMode.Override or DecoMode.Merge);
existing.GraphicsElementsMode = request.GraphicsElementsMode;
existing.UseGraphicsElementsDuringFiller = hasGraphicsElements && request.UseGraphicsElementsDuringFiller;
@@ -261,64 +218,8 @@ public class UpdateDecoHandler(
UpdateDeco request,
CancellationToken cancellationToken) =>
(await DecoMustExist(dbContext, request, cancellationToken), await ValidateDecoName(dbContext, request),
ValidateBreakContent(request),
await WatermarkIdsMustExist(dbContext, request, cancellationToken),
await GraphicsElementIdsMustExist(dbContext, request, cancellationToken))
.Apply((deco, _, _, _, _) => deco);
// The mode decides whether an id list is data or dead weight: ApplyUpdateRequest reconciles the
// join table only under Override/Merge and Clear()s it otherwise, ignoring the ids entirely. The
// validators below read these same two predicates rather than restating the mode test, so a
// validator can never reject an id the apply path was going to discard (#568). The SPA sends both
// id lists regardless of the mode selector, so that shape arrives from the real editor: a draft
// holding an element that has since been deleted must still be able to save the deco back to
// Inherit. The predicate is handed to Validators.IdsMustExist rather than short-circuiting the
// call, because only the EXISTENCE half belongs to the apply path: a discarded list was still
// parsed and materialized out of the request body, so the raw-count cap has to apply under
// every mode.
private static bool ConsumesWatermarkIds(UpdateDeco request) =>
request.WatermarkMode is (DecoMode.Override or DecoMode.Merge);
private static bool ConsumesGraphicsElementIds(UpdateDeco request) =>
request.GraphicsElementsMode is (DecoMode.Override or DecoMode.Merge);
// Mirrors UpdateChannelHandler.GraphicsElementIdsMustExist (#568): the reconcile in
// ApplyUpdateRequest blindly Adds a DecoWatermark/DecoGraphicsElement for every incoming id, and
// an id with no matching row hits the FK constraint at SaveChangesAsync and surfaces as an
// unhandled 500 (there is no global exception filter). These are top-level fields on
// ReplaceDecoRequest, the same position as graphicsElementIds on UpdateChannelRequest -- not the
// "deep FK ids nested inside item-list request bodies" carve-out in api-conventions.md. Both go
// through Validators.IdsMustExist, the one place the count cap, the request field named in the
// message and the cap on echoed ids are written.
private static Task<Validation<BaseError, Unit>> WatermarkIdsMustExist(
TvContext dbContext,
UpdateDeco request,
CancellationToken cancellationToken) =>
Validators.IdsMustExist(
request,
r => r.WatermarkIds,
"Watermark",
idsAreConsumed: ConsumesWatermarkIds(request),
(ids, token) => dbContext.ChannelWatermarks
.Where(w => ids.Contains(w.Id))
.Select(w => w.Id)
.ToListAsync(token),
cancellationToken);
private static Task<Validation<BaseError, Unit>> GraphicsElementIdsMustExist(
TvContext dbContext,
UpdateDeco request,
CancellationToken cancellationToken) =>
Validators.IdsMustExist(
request,
r => r.GraphicsElementIds,
"Graphics element",
idsAreConsumed: ConsumesGraphicsElementIds(request),
(ids, token) => dbContext.GraphicsElements
.Where(e => ids.Contains(e.Id))
.Select(e => e.Id)
.ToListAsync(token),
cancellationToken);
ValidateBreakContent(request))
.Apply((deco, _, _) => deco);
private static Task<Validation<BaseError, Deco>> DecoMustExist(
TvContext dbContext,
+4 -11
View File
@@ -1,7 +1,6 @@
using ErsatzTV.Application.Tree;
using ErsatzTV.Application.Tree;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Scheduling;
@@ -191,15 +190,9 @@ internal static class Mapper
ProjectToViewModel(playoutTemplate.Template),
ProjectToViewModel(playoutTemplate.DecoTemplate),
playoutTemplate.Index,
// ersatztv#823: these three are NULLABLE columns and a legacy row can hold NULL. Substitute the
// SAME unrestricted defaults AlternateScheduleSelector.GetScheduleForDate reads, so the DTO the
// SPA renders agrees with what actually gets scheduled -- web/src/screens/playoutTemplateCalendar.ts
// `appliesToDate` is an exact port of that method, and it would otherwise both mispreview and
// throw (`[...template.daysOfMonth]` on a null is a TypeError). Never assigned back onto the
// entity (`media.nullable-primitive-collection-mutation`).
playoutTemplate.DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
playoutTemplate.DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
playoutTemplate.MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
playoutTemplate.DaysOfWeek,
playoutTemplate.DaysOfMonth,
playoutTemplate.MonthsOfYear,
playoutTemplate.LimitToDateRange,
playoutTemplate.StartMonth,
playoutTemplate.StartDay,
@@ -1,74 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Scheduling;
/// <summary>
/// Validates the three recurrence sets shared by <c>ProgramScheduleAlternate</c> and
/// <c>PlayoutTemplate</c> (ersatztv#880). One validator called from BOTH replace handlers, mirroring
/// <c>FFmpegProfileBounds</c> — the exemplar for `api.ffmpeg-profile-numeric-bounds`, whose shape this
/// follows deliberately.
/// </summary>
/// <remarks>
/// <para>
/// An EMPTY set is rejected because the three are read CONJUNCTIVELY by
/// <c>AlternateScheduleSelector.GetScheduleForDate</c> — a miss on any one continues to the next
/// item — so an empty one matches NO date and stores an item that can never apply. Rejecting
/// rather than substituting is the point: accept-then-rewrite would make an explicit `[]`
/// indistinguishable from an omitted field, which is the very collapse this issue removed.
/// </para>
/// <para>
/// An UNCHANGED empty set that the row ALREADY holds is let through. Both PUT paths are
/// whole-list replaces, so a hard rejection would make every OTHER item in the playout
/// uneditable over a row the operator never touched — the same reason
/// `api.ffmpeg-profile-numeric-bounds` rejects only a NEWLY submitted out-of-range value. A row
/// whose stored set is NULL is NOT exempt: null means unrestricted, so submitting `[]` for it is
/// a new emptying, not an unchanged legacy value.
/// </para>
/// <para>
/// This runs on the COMMAND, after the request records have normalized an ABSENT array to the
/// All*() sets, so an empty set reaching here is one a caller sent EXPLICITLY. That also means a
/// direct (non-HTTP) caller is held to the same rule rather than being able to write a dead row.
/// </para>
/// </remarks>
public static class RecurrenceSetBounds
{
public static Option<BaseError> Validate(
ICollection<DayOfWeek> daysOfWeek,
ICollection<int> daysOfMonth,
ICollection<int> monthsOfYear,
ICollection<DayOfWeek> storedDaysOfWeek,
ICollection<int> storedDaysOfMonth,
ICollection<int> storedMonthsOfYear)
{
if (IsNewlyEmpty(daysOfWeek, storedDaysOfWeek))
{
return Some(BaseError.New(Message("DaysOfWeek", "no day of the week")));
}
if (IsNewlyEmpty(daysOfMonth, storedDaysOfMonth))
{
return Some(BaseError.New(Message("DaysOfMonth", "no day of the month")));
}
if (IsNewlyEmpty(monthsOfYear, storedMonthsOfYear))
{
return Some(BaseError.New(Message("MonthsOfYear", "no month")));
}
return Option<BaseError>.None;
}
// "send null" rather than "omit the property": all three are listed in the schema's `required` array
// in v1.json (they are nullable, not optional), so a client generated from the published contract
// cannot omit them. Omitting also works at runtime -- Newtonsoft maps a missing property and an
// explicit null to the same thing -- but naming only that would tell a conforming client to send
// something its own schema forbids.
private static string Message(string field, string consequence) =>
$"[{field}] must not be empty; an empty set matches {consequence}, so the item would never apply. " +
"Send null to leave it unrestricted";
// A new item (no stored row) has `stored` null, so an empty set is newly empty and is rejected.
// Only a stored set that is ITSELF already empty exempts an empty submission.
private static bool IsNewlyEmpty<T>(ICollection<T> submitted, ICollection<T> stored) =>
submitted is { Count: 0 } && stored is not { Count: 0 };
}
@@ -18,7 +18,8 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
/// Rows read per round trip when walking the list-valued (JSON-array) columns on
/// <c>SongMetadata</c>, and the ceiling on rows read per request.
/// <para>
/// These count ACTUAL ROWS. A fixed <c>LIMIT</c> budget bounded the RESULT, and
/// These count ACTUAL ROWS, and arriving at that took four tries — each earlier attempt bounded a
/// quantity that sounded like rows and was not. A fixed <c>LIMIT</c> budget bounded the RESULT, and
/// the pre-filter (allowed to over-match) starved it with rows that could not match. Keyset paging
/// with a <c>LIMIT</c> bounded CANDIDATES RETURNED — but a query matching nothing must evaluate
/// every eligible row before it can return an empty page, so rows inspected stayed unbounded. A
@@ -35,7 +36,8 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
/// </para>
/// <para>
/// <b>Be precise about what is bounded: LOGICAL ROWS RETURNED AND MATERIALIZED, and the number of
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading:
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading, and an
/// earlier version of this comment asserted it anyway:
/// <list type="bullet">
/// <item>
/// MySQL purge lag. Deleted clustered-index records survive until purge runs, and a range
@@ -140,7 +142,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
// over-match, even though the column collation (utf8mb4_0900_ai_ci) is accent-insensitive: the driver
// binds the LIKE pattern with a BINARY collation, so the comparison is accent-sensitive in practice.
// A hand-typed probe using a LITERAL pattern DOES over-match; that is a different query from the one
// this code runs, and mistaking the two gives a false read on whether this predicate over-matches.
// this code runs, and mistaking the two is how an earlier revision of the decision record got it wrong.
if (source is not null && ContainsNonAscii(query) && IsSqlite(dbContext))
{
values.AddRange(
@@ -470,7 +472,7 @@ public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextF
/// ordering key</i>, which positions the scan and never discards a row, whereas a residual
/// predicate throws away rows the engine already produced. <c>LIMIT</c> only truncates what
/// survives a residual predicate, so with one present it bounds the output rather than the row
/// count — a gap wide enough to scan straight past a nominal row-count bound. With none, <c>LIMIT n</c>
/// count — which is how every earlier revision scanned past its own bound. With none, <c>LIMIT n</c>
/// yields <c>n</c> logical rows. Null payloads are dropped in memory by
/// <see cref="ParseElements" />.
/// </para>
@@ -1,87 +0,0 @@
using System.Linq.Expressions;
using ErsatzTV.Core;
namespace ErsatzTV.Application;
public static partial class Validators
{
/// <summary>
/// The largest id list a full-replace write path accepts in one of its top-level id fields.
/// Deliberately far above any real payload -- the lists it bounds select from tables an
/// operator curates by hand (graphics elements, watermarks), where a few dozen rows is a
/// large install -- so the bound is a ceiling on abuse, not a product limit anyone can reach
/// by using the editor (#568).
/// </summary>
public const int MaximumIdListCount = 512;
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
// Enough ids to fix the payload by hand, then a count.
private const int MaximumReportedMissingIds = 10;
/// <summary>
/// The shared existence check for a top-level list of FK ids on a full-replace request:
/// bound the list, resolve which of its ids exist through <paramref name="findExisting" />,
/// and reject the rest with a 422 that names the request field it came from.
/// </summary>
/// <param name="idsAreConsumed">
/// Whether the apply path will actually read this list — false where another field of the
/// same request (a deco's <c>DecoMode</c>) makes the reconcile discard it. It gates the
/// EXISTENCE half only, never the count: a validator may not reject an id the apply path
/// was going to throw away, but the raw list was still parsed and materialized out of the
/// request body whatever is done with it afterwards, so the cap is the request's bound and
/// not the apply path's (#568).
/// </param>
/// <remarks>
/// The count is taken from the RAW list, before <c>Distinct</c> and before any database
/// work: deduplication is not what the request costs. A million-entry list of one repeated
/// id parses, allocates and materializes in full whatever the distinct count turns out to
/// be, so a cap applied after <c>Distinct</c> would bound the query and leave the request
/// itself unbounded.
/// </remarks>
public static async Task<Validation<BaseError, Unit>> IdsMustExist<T>(
T input,
Expression<Func<T, List<int>>> expression,
string noun,
bool idsAreConsumed,
Func<List<int>, CancellationToken, Task<List<int>>> findExisting,
CancellationToken cancellationToken)
{
string field = GetMemberName(expression);
List<int> submitted = expression.Compile()(input) ?? [];
if (submitted.Count > MaximumIdListCount)
{
return BaseError.New(
$"[{field}] contains {submitted.Count} ids; at most {MaximumIdListCount} are accepted. " +
"The whole list is materialized into one existence query and then reconciled against every " +
"row already attached, so a longer list turns a single request into unbounded work.");
}
if (!idsAreConsumed)
{
return Unit.Default;
}
List<int> requested = submitted.Distinct().ToList();
if (requested.Count == 0)
{
return Unit.Default;
}
List<int> existingIds = await findExisting(requested, cancellationToken);
List<int> missingIds = requested.Except(existingIds).OrderBy(id => id).ToList();
if (missingIds.Count == 0)
{
return Unit.Default;
}
return BaseError.New($"[{field}] {noun}(s) do not exist: {DescribeIds(missingIds)}");
}
private static string DescribeIds(IReadOnlyList<int> ids) =>
ids.Count <= MaximumReportedMissingIds
? string.Join(", ", ids)
: $"{string.Join(", ", ids.Take(MaximumReportedMissingIds))} (and " +
$"{ids.Count - MaximumReportedMissingIds} more)";
}
@@ -359,9 +359,9 @@ public class WatermarkSelectorDecoResolutionTests
/// </summary>
/// <remarks>
/// The channel-level fallback is deliberately an INDEPENDENTLY RESOLVABLE `ChannelLogo` watermark whose
/// cached file exists. Giving the fallback the same missing custom path as the playout-item watermark
/// would make the test unfalsifiable: a wrongly-widened guard would fall through to a fallback that also
/// resolves to None, so the assertion would hold either way.
/// cached file exists. An earlier version of this test gave the fallback the same missing custom path as
/// the playout-item watermark, which made it unfalsifiable: a wrongly-widened guard would have fallen
/// through to a fallback that also resolved to None, so the assertion held either way.
/// </remarks>
[Test]
public void Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through()
@@ -1,4 +1,3 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
@@ -865,241 +864,4 @@ public static class AlternateScheduleSelectorTests
result.IsNone.ShouldBeFalse();
}
}
/// <summary>
/// ersatztv#823. <c>DaysOfWeek</c>, <c>DaysOfMonth</c> and <c>MonthsOfYear</c> on
/// <see cref="PlayoutTemplate" /> and <see cref="ProgramScheduleAlternate" /> are six
/// single-column primitive collections whose columns are <c>nullable: true</c> on both providers.
/// A NULL column materializes as CLR <c>null</c> — EF does not invoke the value converter for a
/// NULL at all — so unguarded, each <c>.Contains</c> in
/// <see cref="AlternateScheduleSelector.GetScheduleForDate{T}" /> throws
/// <see cref="NullReferenceException" />. These tests are RED without the read-site guard.
/// <para>
/// A null reads as UNRESTRICTED (the <c>All*()</c> sets), not as empty. The deciding case is
/// SQLite's <c>20240113140741_Add_PlayoutTemplate_DaysOfMonth</c>, which adds the column
/// <c>nullable: true</c> with NO default: a row inserted before it had no day-of-month
/// restriction, so reading its NULL as empty would INVERT its meaning and silently stop the
/// template applying. That is the one NULL reachable without any code writing one.
/// </para>
/// <para>
/// Reachability itself is pinned by
/// <c>ErsatzTV.Tests.Integration.SchedulingCollectionColumnNullTests</c> against a real
/// <c>TvContext</c>; these tests pin what the selector does once the null is there.
/// </para>
/// </summary>
[TestFixture]
public class GetScheduleForDate_NullCollections
{
private static readonly TimeSpan Offset = TimeSpan.FromHours(-5);
// A Wednesday in March, so no All*() member is coincidentally excluded — and deliberately the
// 20th rather than the 6th. With a day <= 12 a CROSS-WIRED substitution survives the whole
// fixture: `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which still contains day 6, so
// every assertion here passes while the guard substitutes the wrong set. Day 20 is outside 1..12
// and kills it.
private static readonly DateTimeOffset AnyDate = new(2024, 3, 20, 0, 0, 0, Offset);
private static PlayoutTemplate Unrestricted() =>
new()
{
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
};
private static Option<PlayoutTemplate> Select(params PlayoutTemplate[] templates) =>
AlternateScheduleSelector.GetScheduleForDate(templates.ToList(), AnyDate);
[Test]
public void Null_DaysOfWeek_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
Select(template).IsSome.ShouldBeTrue(
"a NULL DaysOfWeek means no weekday restriction was recorded, so the template still applies");
}
[Test]
public void Null_DaysOfMonth_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfMonth = null!;
Select(template).IsSome.ShouldBeTrue();
}
[Test]
public void Null_MonthsOfYear_Reads_As_Unrestricted()
{
PlayoutTemplate template = Unrestricted();
template.MonthsOfYear = null!;
Select(template).IsSome.ShouldBeTrue();
}
[Test]
public void All_Three_Null_On_ProgramScheduleAlternate_Reads_As_Unrestricted()
{
var alternate = new ProgramScheduleAlternate
{
DaysOfWeek = null!,
DaysOfMonth = null!,
MonthsOfYear = null!
};
AlternateScheduleSelector.GetScheduleForDate(
new List<ProgramScheduleAlternate> { alternate },
AnyDate)
.IsSome.ShouldBeTrue();
}
/// <summary>
/// THE DISCRIMINATING CONTROL. Every test above sets a NULL and expects the item to be selected,
/// so all of them pass equally under "NULL means unrestricted" and under the much broader
/// "any NULL makes this item match unconditionally" — a refactor that short-circuits the whole
/// date check when any dimension is null keeps them green. Here the nulled dimension is paired
/// with a RESTRICTIVE non-null one that the date fails, so only the narrow reading passes.
/// </summary>
[Test]
public void A_Null_Dimension_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
// AnyDate is in MARCH; restrict to January only.
template.MonthsOfYear = [1];
Select(template).IsNone.ShouldBeTrue(
"a NULL DaysOfWeek relaxes ONLY the weekday dimension — the January restriction still "
+ "excludes a March date");
}
/// <summary>
/// One arrangement is not enough: with only the <c>DaysOfWeek</c> case above, a PER-DIMENSION
/// mutant survives the whole fixture — e.g. <c>if (item.MonthsOfYear is null) return item;</c>
/// placed ahead of the checks is never reached by that test, because its <c>MonthsOfYear</c> is
/// non-null. So each of the three dimensions is nulled in turn against a restriction on a
/// DIFFERENT dimension.
/// </summary>
[Test]
public void A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.MonthsOfYear = null!;
// AnyDate is a WEDNESDAY; restrict to Monday only.
template.DaysOfWeek = [DayOfWeek.Monday];
Select(template).IsNone.ShouldBeTrue(
"a NULL MonthsOfYear relaxes ONLY the month dimension — the Monday restriction still "
+ "excludes a Wednesday");
}
/// <summary>
/// The third of the per-dimension controls — see
/// <see cref="A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions" /> for why one
/// arrangement is not enough. Here the nulled dimension is <c>DaysOfMonth</c> and the
/// restriction that must still bite is on <c>MonthsOfYear</c>.
/// </summary>
[Test]
public void A_Null_DaysOfMonth_Does_Not_Relax_The_Other_Dimensions()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfMonth = null!;
// AnyDate is in MARCH; restrict to January only.
template.MonthsOfYear = [1];
Select(template).IsNone.ShouldBeTrue(
"a NULL DaysOfMonth relaxes ONLY the day-of-month dimension — the January restriction "
+ "still excludes a March date");
}
/// <summary>
/// A null must not be confused with an explicitly EMPTY collection. Empty is a legal, reachable
/// state meaning "matches no day", and it keeps that meaning — which is exactly why a NULL
/// cannot be normalized to it.
/// </summary>
[Test]
public void An_Explicitly_Empty_Collection_Still_Matches_Nothing()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = [];
Select(template).IsNone.ShouldBeTrue(
"an empty DaysOfWeek is a recorded restriction of NO days, unlike a NULL");
}
/// <summary>
/// The guard resolves PER ITEM: a null on the first item must not decide the second. Making the
/// nulled item genuinely non-matching is what measures that — with an unrestricted nulled item
/// at index 0 it simply wins on ordering and the second item is never evaluated, so the
/// invariant would go unmeasured while the test passed.
/// </summary>
[Test]
public void A_Null_On_One_Item_Does_Not_Decide_A_Later_Item()
{
PlayoutTemplate nulled = Unrestricted();
nulled.DaysOfWeek = null!;
nulled.MonthsOfYear = [1]; // AnyDate is in March, so this item must NOT match
nulled.Index = 0;
PlayoutTemplate second = Unrestricted();
second.Index = 1;
foreach (PlayoutTemplate selected in Select(nulled, second))
{
selected.ShouldBeSameAs(second);
return;
}
Assert.Fail("the loop stopped at the null-collection item instead of continuing to the next");
}
/// <summary>
/// ...and when the nulled item IS unrestricted it legitimately wins on ordering. Paired with the
/// test above so "index 0 wins" and "the loop continues past a non-matching null item" are
/// separately pinned.
/// </summary>
[Test]
public void An_Unrestricted_Null_Item_Wins_On_Index_Order()
{
PlayoutTemplate nulled = Unrestricted();
nulled.DaysOfWeek = null!;
nulled.Index = 0;
PlayoutTemplate second = Unrestricted();
second.Index = 1;
foreach (PlayoutTemplate selected in Select(nulled, second))
{
selected.ShouldBeSameAs(nulled);
return;
}
Assert.Fail("the null-collection item was skipped instead of read as unrestricted");
}
/// <summary>
/// The read-site guard must not be written BACK onto the item. These are single-column
/// primitive collections, so assigning the guard would flip a tracked entity to
/// <c>Modified</c> and the next <c>SaveChanges</c> would persist the substituted collection
/// over the NULL — the mechanism recorded as <c>media.nullable-primitive-collection-mutation</c>.
/// </summary>
[Test]
public void Guard_Must_Not_Be_Written_Back_Onto_The_Item()
{
PlayoutTemplate template = Unrestricted();
template.DaysOfWeek = null!;
template.DaysOfMonth = null!;
template.MonthsOfYear = null!;
Select(template);
template.DaysOfWeek.ShouldBeNull();
template.DaysOfMonth.ShouldBeNull();
template.MonthsOfYear.ShouldBeNull();
}
}
}
@@ -14,10 +14,8 @@ namespace ErsatzTV.Core.Tests.Scheduling;
// 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 permanently outside the
// automated suite, and the engine behind it is covered in-process instead (decision
// testing.scripted-engine-in-process-net, docs/testing.md -> "Scripted playout coverage"). This direct
// helper test is the regression net for the shared construction that engine drives.
// 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
{
@@ -1,50 +1,23 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling.Engine;
/// <summary>
/// Characterization of the <see cref="SchedulingEngine" /> build API — the surface a scripted schedule
/// drives, one method per <c>ScriptedScheduleController</c> action. Covers content registration
/// (<c>AddCollection</c>), the scheduling instructions (<c>AddCount</c>, <c>AddAll</c>,
/// <c>AddDuration</c>, <c>PadUntilExact</c>), EPG grouping, per-item history, the no-progress halt, and
/// the anchor round-trip that a Continue build restores from.
/// <para>
/// Deliberately out of scope here: the <c>Cli.Wrap</c> launch of the user-authored script process
/// (exit code, timeout, stdout capture) and the Kestrel/HTTP/auth transport it calls back over. Those
/// are permanently outside the automated suite; the controller adapter that sits between them and this
/// engine is pinned by <c>ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests</c>. Decision:
/// <c>testing.scripted-engine-in-process-net</c>.
/// </para>
/// <para>
/// Every fixture here is timezone-independent by construction: it uses only Chronological order plus
/// <c>AddCount</c>/<c>AddAll</c>/<c>AddDuration</c>/<c>PadUntilExact</c>, all of which preserve the
/// instant. <c>WaitUntil(TimeOnly)</c> and <c>PadUntil(string)</c> read the LOCAL day and time-of-day
/// and are therefore excluded. See docs/testing.md → Timezone independence.
/// </para>
/// </summary>
[TestFixture]
public class SchedulingEngineTests
{
private const string ContentKey = "content";
private const string CollectionName = "Test Collection";
// Pinned build window, offset zero: the engine writes PlayoutItem.Start/Finish as UtcDateTime, so every
// assertion below is on an instant rather than a wall-clock reading.
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
[Test]
public void Continue_Across_Time_Change()
{
SchedulingEngine engine = NewEngine(Substitute.For<IMediaCollectionRepository>());
var engine = new SchedulingEngine(
Substitute.For<IMediaCollectionRepository>(),
Substitute.For<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
var anchor = new PlayoutAnchor
{
@@ -52,468 +25,11 @@ public class SchedulingEngineTests
};
var start = new DateTimeOffset(new DateTime(2025, 11, 20), TimeSpan.FromHours(-6));
DateTimeOffset finish = start.AddDays(1);
var finish = start.AddDays(1);
engine.BuildBetween(start, finish);
// should not throw
engine.RestoreOrReset(anchor);
}
[Test]
public async Task AddCollection_Then_AddCount_Lays_Items_Back_To_Back()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddCount(ContentKey, 4, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Count.ShouldBe(4);
// chronological order is the collection's release-date order, which is item id order here
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4]);
items[0].Start.ShouldBe(Start.UtcDateTime);
for (var i = 1; i < items.Count; i++)
{
items[i].Start.ShouldBe(items[i - 1].Finish);
}
foreach (PlayoutItem item in items)
{
item.FillerKind.ShouldBe(FillerKind.None);
item.InPoint.ShouldBe(TimeSpan.Zero);
item.OutPoint.ShouldBe(item.Finish - item.Start);
item.PlayoutId.ShouldBe(1);
}
// outside an EPG group every item opens its own guide group
items.Select(i => i.GuideGroup).ShouldBe([1, 2, 3, 4]);
// 30 + 45 + 60 + 20 minutes of content
TimeSpan scheduled = TimeSpan.FromMinutes(155);
items[^1].Finish.ShouldBe(Start.UtcDateTime + scheduled);
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + scheduled);
}
[Test]
public async Task AddAll_Schedules_Every_Item_Once()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddAll(ContentKey, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4, 5, 6]);
// 30 + 45 + 60 + 20 + 90 + 15 minutes
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(260));
}
[Test]
public async Task AddDuration_Stops_Before_Overrunning_The_Target()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddDuration(
ContentKey,
"2:00:00",
fallback: null,
trim: false,
discardAttempts: 0,
stopBeforeEnd: true,
offlineTail: false,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
DateTimeOffset target = Start.AddHours(2);
List<PlayoutItem> items = engine.GetState().AddedItems;
// 30 + 45 fits; the third item (60) does not, and nothing is trimmed
items.Select(i => i.MediaItemId).ShouldBe([1, 2]);
items[^1].Finish.ShouldBe(Start.UtcDateTime + TimeSpan.FromMinutes(75));
items[^1].Finish.ShouldBeLessThan(target.UtcDateTime);
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(75));
}
[Test]
public async Task AddDuration_Trims_The_Last_Item_When_Trim_Is_Set()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddDuration(
ContentKey,
"2:00:00",
fallback: null,
trim: true,
discardAttempts: 0,
stopBeforeEnd: true,
offlineTail: false,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
DateTimeOffset target = Start.AddHours(2);
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3]);
items[^1].Finish.ShouldBe(target.UtcDateTime);
items[^1].OutPoint.ShouldBe(items[^1].Finish - items[^1].Start);
items[^1].OutPoint.ShouldBe(TimeSpan.FromMinutes(45));
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target);
}
[Test]
public async Task PadUntilExact_Fills_To_The_Target_Instant()
{
SchedulingEngine engine = await ResetEngineWithCollection();
DateTimeOffset target = Start.AddHours(2);
engine.PadUntilExact(
ContentKey,
target,
fallback: null,
trim: true,
discardAttempts: 0,
stopBeforeEnd: true,
offlineTail: false,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Count.ShouldBeGreaterThan(0);
items[0].Start.ShouldBe(Start.UtcDateTime);
items[^1].Finish.ShouldBe(target.UtcDateTime);
// the target is an instant, so a machine-local offset must not move it
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target);
}
[Test]
public async Task AddDuration_Rejects_An_Unparseable_Duration()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddDuration(
ContentKey,
"not-a-duration",
fallback: null,
trim: false,
discardAttempts: 0,
stopBeforeEnd: true,
offlineTail: false,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start);
}
[Test]
public async Task AddDuration_Rejects_Offline_Tail_Without_Stop_Before_End()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddDuration(
ContentKey,
"2:00:00",
fallback: null,
trim: false,
discardAttempts: 0,
stopBeforeEnd: false,
offlineTail: true,
Option<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
}
[TestCase("add_all")]
[TestCase("add_count")]
[TestCase("add_duration")]
[TestCase("pad_to_next")]
[TestCase("pad_until")]
[TestCase("pad_until_exact")]
public async Task Unknown_Content_Key_Returns_False_And_Schedules_Nothing(string instruction)
{
SchedulingEngine engine = await ResetEngineWithCollection();
const string Unknown = "no-such-key";
bool result = instruction switch
{
"add_all" => engine.AddAll(Unknown, Option<FillerKind>.None, null, false),
"add_count" => engine.AddCount(Unknown, 1, Option<FillerKind>.None, null, false),
"add_duration" => engine.AddDuration(
Unknown,
"1:00:00",
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_to_next" => engine.PadToNext(
Unknown,
15,
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_until" => engine.PadUntil(
Unknown,
"07:00",
false,
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_until_exact" => engine.PadUntilExact(
Unknown,
Start.AddHours(1),
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
_ => throw new ArgumentOutOfRangeException(nameof(instruction))
};
result.ShouldBeFalse();
// the false is only meaningful if nothing was scheduled behind it
engine.GetState().AddedItems.ShouldBeEmpty();
engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start);
}
[Test]
public async Task Empty_Collection_Is_Skipped()
{
var repository = Substitute.For<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.Returns(new List<MediaItem>());
SchedulingEngine engine = ResetEngine(repository);
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
}
[Test]
public async Task Filler_Kind_And_Custom_Title_Reach_The_Item()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddCount(ContentKey, 1, FillerKind.PreRoll, "Bumper", true).ShouldBeTrue();
PlayoutItem item = engine.GetState().AddedItems.Single();
item.FillerKind.ShouldBe(FillerKind.PreRoll);
item.CustomTitle.ShouldBe("Bumper");
item.DisableWatermarks.ShouldBeTrue();
}
[Test]
public async Task Guide_Group_Is_Locked_Across_An_Epg_Group()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.LockGuideGroup(advance: true, customTitle: "Block");
engine.AddCount(ContentKey, 3, Option<FillerKind>.None, null, false).ShouldBeTrue();
engine.UnlockGuideGroup();
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Count.ShouldBe(4);
List<PlayoutItem> grouped = items.Take(3).ToList();
grouped.Select(i => i.GuideGroup).Distinct().Count().ShouldBe(1);
grouped.ShouldAllBe(i => i.CustomTitle == "Block");
// unlocking resumes per-item advancement from the group's number
items[3].GuideGroup.ShouldBe(grouped[0].GuideGroup + 1);
items[3].CustomTitle.ShouldBeNull();
}
[Test]
public async Task History_Is_Recorded_Per_Item()
{
SchedulingEngine engine = await ResetEngineWithCollection();
engine.AddCount(ContentKey, 3, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
List<PlayoutHistory> history = engine.GetState().AddedHistory;
history.Count.ShouldBe(items.Count);
string expectedKey = HistoryDetails.KeyForSchedulingContent(ContentKey, PlaybackOrder.Chronological);
for (var i = 0; i < history.Count; i++)
{
history[i].Key.ShouldBe(expectedKey);
history[i].PlayoutId.ShouldBe(1);
history[i].PlaybackOrder.ShouldBe(PlaybackOrder.Chronological);
history[i].Index.ShouldBe(i);
history[i].When.ShouldBe(items[i].Start);
history[i].Finish.ShouldBe(items[i].Finish);
}
}
[Test]
public void Is_Done_Throws_After_Twenty_Consecutive_Calls_Without_Progress()
{
SchedulingEngine engine = ResetEngine(Substitute.For<IMediaCollectionRepository>());
ISchedulingEngineState state = engine.GetState();
// the first read establishes the baseline; each of the next 19 increments the no-progress counter
for (var i = 0; i < 20; i++)
{
state.IsDone.ShouldBeFalse();
}
Should.Throw<InvalidOperationException>(() => _ = state.IsDone);
}
[Test]
public async Task Is_Done_Counter_Resets_When_Time_Advances()
{
SchedulingEngine engine = await ResetEngineWithCollection();
ISchedulingEngineState state = engine.GetState();
for (var i = 0; i < 20; i++)
{
state.IsDone.ShouldBeFalse();
}
// one instruction that advances CurrentTime clears the counter, so the budget starts over
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
for (var i = 0; i < 20; i++)
{
state.IsDone.ShouldBeFalse();
}
}
[Test]
public async Task Anchor_Round_Trips_Through_Restore()
{
SchedulingEngine first = await ResetEngineWithCollection();
first.AddCount(ContentKey, 2, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> firstItems = first.GetState().AddedItems;
int lastGuideGroup = firstItems[^1].GuideGroup;
PlayoutAnchor anchor = first.GetAnchor();
anchor.NextStart.ShouldBe(firstItems[^1].Finish);
SchedulingEngine second = NewEngine(CollectionRepository());
second.WithPlayoutId(1)
.WithMode(PlayoutBuildMode.Continue)
.WithSeed(0)
.BuildBetween(Start, Start.AddDays(1))
.WithReferenceData(EmptyReferenceData())
.RestoreOrReset(anchor);
// the anchor carries an instant, not a wall-clock reading
second.GetState().CurrentTime.ToUniversalTime().ShouldBe(new DateTimeOffset(anchor.NextStart, TimeSpan.Zero));
await second.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
second.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
PlayoutItem resumed = second.GetState().AddedItems.Single();
resumed.Start.ShouldBe(anchor.NextStart);
// the guide group continues from the serialized context instead of restarting at 1
resumed.GuideGroup.ShouldBe(lastGuideGroup + 1);
}
private static SchedulingEngine NewEngine(IMediaCollectionRepository repository) =>
new(
repository,
Substitute.For<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
// WithReferenceData must precede RestoreOrReset and AddCollection: both dereference
// PlayoutReferenceData.PlayoutHistory, so a different order fails with a null reference that reads
// like an engine bug. This is the same order ScriptedPlayoutBuilder uses.
private static SchedulingEngine ResetEngine(IMediaCollectionRepository repository)
{
SchedulingEngine engine = NewEngine(repository);
engine.WithPlayoutId(1)
.WithMode(PlayoutBuildMode.Reset)
.WithSeed(0)
.BuildBetween(Start, Start.AddDays(1))
.WithReferenceData(EmptyReferenceData())
.RestoreOrReset(Option<PlayoutAnchor>.None);
return engine;
}
private static async Task<SchedulingEngine> ResetEngineWithCollection()
{
SchedulingEngine engine = ResetEngine(CollectionRepository());
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
return engine;
}
private static IMediaCollectionRepository CollectionRepository()
{
var repository = Substitute.For<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.Returns(_ => TestCollection());
return repository;
}
// Distinct release dates make chronological order deterministic (id order); distinct durations make
// every boundary in an assertion unambiguous.
private static List<MediaItem> TestCollection() =>
[
FakeMovie(1, 30),
FakeMovie(2, 45),
FakeMovie(3, 60),
FakeMovie(4, 20),
FakeMovie(5, 90),
FakeMovie(6, 15)
];
private static Movie FakeMovie(int id, int minutes) =>
new()
{
Id = id,
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }],
MovieMetadata =
[
new MovieMetadata
{
Title = $"Movie {id:D2}",
ReleaseDate = new DateTime(2005, 1, 1).AddDays(id)
}
]
};
private static PlayoutReferenceData EmptyReferenceData() =>
new(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero);
}
@@ -37,6 +37,4 @@ public record FFmpegFullProfileResponseModel(
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder,
double? ReadRate,
double? ReadRateCatchup);
bool QsvPreferNativeDecoder);
-3
View File
@@ -26,9 +26,6 @@ public class ConfigElementKey
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 GraphicsOnNowNextDefaultAttached =>
new("graphics.on_now_next_default_attached");
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
@@ -14,8 +14,6 @@ public record FFmpegProfile
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int? QsvExtraHardwareFrames { get; set; }
public double? ReadRate { get; set; }
public double? ReadRateCatchup { get; set; }
public bool? QsvPreferNativeDecoder { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
@@ -610,9 +610,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
channel.FFmpegProfile.QsvPreferNativeDecoder != false,
Optional(channel.FFmpegProfile.ReadRate),
Optional(channel.FFmpegProfile.ReadRateCatchup));
channel.FFmpegProfile.QsvPreferNativeDecoder != false);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -829,9 +827,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
false,
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
MaybeReadRate: Optional(channel.FFmpegProfile.ReadRate),
MaybeReadRateCatchup: Optional(channel.FFmpegProfile.ReadRateCatchup));
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@@ -972,9 +968,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
false,
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
MaybeReadRate: Optional(channel.FFmpegProfile.ReadRate),
MaybeReadRateCatchup: Optional(channel.FFmpegProfile.ReadRateCatchup));
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
var audioInputFile = new NullAudioInputFile(audioState);
@@ -1,52 +1,7 @@
using System.IO;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Graphics;
public static class GraphicsElementDefaults
{
// Built-in "On Now / Next" text element filename -- a component of OnNowNextSeededPath below,
// never itself an identity check (#568: a filename-only comparison is folder-agnostic).
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
public const string OnNowNextFileName = "on-now-next.yml";
// Display name only. Never use it for identity -- that is IsOnNowNext below (#67 / #74 / #568).
public const string OnNowNextName = "On Now / Next";
// The full path the seeder writes the built-in template to (GraphicsElementSeeder.SeedOnNowNext
// builds `target` the same way). A `builtIn` discriminator must match THIS, not
// `Path.GetFileName(...) == OnNowNextFileName` -- a filename-only comparison is folder-agnostic:
// a user element named exactly `on-now-next.yml` in any of the other four template folders
// (image/motion/subtitle/script) would also report `builtIn:true` (#568). `Kind == Text` alone
// does not close this either, since a second text template could share the filename in principle.
public static string OnNowNextSeededPath =>
Path.Combine(FileSystemLayout.GraphicsElementsTextTemplatesFolder, OnNowNextFileName);
/// <summary>
/// The one identity test for the built-in On Now / Next element: the exact file the seeder
/// wrote, at the exact path it wrote it to, of the kind it wrote it as. Ordinal on purpose,
/// and so case-sensitive on purpose.
/// </summary>
/// <remarks>
/// <para>
/// <c>Kind</c> is part of the identity rather than a second test any caller may add or
/// skip: the seeder's own "does the built-in row exist yet?" check resolves through this
/// predicate, so a row of another kind at the seeded path answering yes would suppress
/// the Text row every consumer resolves. A caller applying only the path half would
/// report that wrong-kind row as the built-in element while the seeder refused to treat
/// it as one -- the two sites disagreeing about the same row, which is the defect this
/// predicate exists to make impossible (#568).
/// </para>
/// <para>
/// Callers compare in memory rather than in a <c>Where</c> clause, because in SQL the
/// answer would be the PROVIDER's to give: <c>GraphicsElement.Path</c> takes no explicit
/// collation (<c>TvContext.OnModelCreating</c> pins one only on the listed name/title
/// columns), so SQLite compares it case-sensitively while MySQL uses the server default,
/// which is normally case-INsensitive. Evaluating one discriminator site in SQL and the
/// other in memory would let the two disagree on MySQL alone. The SQLite test suite
/// cannot tell the two apart -- BINARY collation and an ordinal comparison agree on
/// every input -- so this is held by keeping the comparison out of SQL, not by a test.
/// </para>
/// </remarks>
public static bool IsOnNowNext(string path, GraphicsElementKind kind) =>
kind == GraphicsElementKind.Text && string.Equals(path, OnNowNextSeededPath, StringComparison.Ordinal);
}
@@ -31,28 +31,6 @@ public class TextGraphicsElement : BaseGraphicsElement
[YamlMember(Alias = "z_index", ApplyNamingConventions = false)]
public int? ZIndex { get; set; }
// Background box (ersatztv#732). Element-level, not per-style: the graphics engine renders one
// TextBlock into one bitmap, so a single box behind the whole element is the only shape the
// renderer can express. Unset background_color means no FILL; a border_color alone still draws
// an outlined box. With neither there is no box and no insets -- the pre-#732 geometry.
[YamlMember(Alias = "background_color", ApplyNamingConventions = false)]
public string BackgroundColor { get; set; }
[YamlMember(Alias = "background_opacity_percent", ApplyNamingConventions = false)]
public int? BackgroundOpacityPercent { get; set; }
[YamlMember(Alias = "background_padding", ApplyNamingConventions = false)]
public double? BackgroundPadding { get; set; }
[YamlMember(Alias = "background_corner_radius", ApplyNamingConventions = false)]
public double? BackgroundCornerRadius { get; set; }
[YamlMember(Alias = "border_color", ApplyNamingConventions = false)]
public string BorderColor { get; set; }
[YamlMember(Alias = "border_width", ApplyNamingConventions = false)]
public double? BorderWidth { get; set; }
public List<StyleDefinition> Styles { get; set; } = [];
[YamlMember(Alias = "base_style", ApplyNamingConventions = false)]
@@ -85,46 +85,19 @@ public static class AlternateScheduleSelector
}
}
// These three are NULLABLE single-column primitive collections, and a runtime null IS
// reachable (ersatztv#823, measured against a real TvContext on SQLite and MySQL 8.4): EF does
// NOT invoke the value converter for a NULL column, so it materializes as CLR null rather than
// through IntCollectionValueConverter's null-to-empty branch, which never runs on this path.
// Unguarded, each .Contains below throws NullReferenceException.
//
// A NULL reads as UNRESTRICTED -- the All*() sets -- NOT as empty. This is the whole semantic
// question and it is decided by the one NULL that is reachable WITHOUT any code writing one:
// Sqlite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth adds DaysOfMonth with
// `nullable: true` and NO defaultValue, so a PlayoutTemplate row inserted before it holds NULL
// and, by construction, had NO day-of-month restriction. Reading that as empty would INVERT
// the row's meaning and silently stop the template applying at all. All*() preserves it, and
// it is how "no restriction recorded" is already represented elsewhere in this domain
// (GetPlayoutAlternateSchedulesHandler, PreviewBlockPlayoutHandler). Note what does NOT decide
// it: the API request records normalize an omitted field with `?? []`, but that is a client
// omitting a field on a WRITE and says nothing about what a legacy database NULL meant.
//
// Guarded at the READ SITE, into locals, and NEVER assigned back onto `item`: the property IS
// the column value, so writing the guard back would flip a tracked entry to Modified and
// persist the substituted collection over the NULL
// (`media.nullable-primitive-collection-mutation`). The matching substitution happens at the
// entity->DTO boundary in the two Mapper.ProjectToViewModel overloads, so the SPA's
// appliesToDate -- an exact port of this method -- previews what this actually schedules.
ICollection<DayOfWeek> itemDaysOfWeek = item.DaysOfWeek ?? AllDaysOfWeek();
ICollection<int> itemDaysOfMonth = item.DaysOfMonth ?? AllDaysOfMonth();
ICollection<int> itemMonthsOfYear = item.MonthsOfYear ?? AllMonthsOfYear();
bool daysOfWeek = itemDaysOfWeek.Contains(date.DayOfWeek);
bool daysOfWeek = item.DaysOfWeek.Contains(date.DayOfWeek);
if (!daysOfWeek)
{
continue;
}
bool daysOfMonth = itemDaysOfMonth.Contains(date.Day);
bool daysOfMonth = item.DaysOfMonth.Contains(date.Day);
if (!daysOfMonth)
{
continue;
}
bool monthOfYear = itemMonthsOfYear.Contains(date.Month);
bool monthOfYear = item.MonthsOfYear.Contains(date.Month);
if (monthOfYear)
{
return item;
@@ -680,56 +680,10 @@ public class PipelineBuilderBaseTests
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -readrate_catchup 6.0 -i /tmp/whatever.mkv");
}
// ersatztv#735: the pacing values became operator-tunable profile fields. these pin that a
// configured value actually reaches the command line -- the defaults above are the OTHER half
// of the same guard, and they are what an unset profile still gets
[Test]
public void Realtime_Input_Should_Use_A_Configured_ReadRate_And_Catchup()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 1.5,
readRateCatchup: 4.0);
command.ShouldContain("-readrate 1.5 -readrate_initial_burst 8 -readrate_catchup 4.0 -i /tmp/whatever.mkv");
command.ShouldNotContain("-readrate 1.05");
command.ShouldNotContain("-readrate_catchup 6.0");
}
// the write path rejects an out-of-range value with a 422, so this only fires for a row written
// out of band -- but FFmpeg must never see the unbounded value either way
[Test]
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRate()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 9.0,
readRateCatchup: 0.1);
// 9.0 clamps to the 2.0 ceiling, and 0.1 is raised to the resolved base rate, because a
// catchup below it could never let a lagging input recover
command.ShouldContain("-readrate 2.0 -readrate_initial_burst 8 -readrate_catchup 2.0 -i /tmp/whatever.mkv");
}
// ...and the catchup CEILING isolated from the base rate, which the case above cannot show:
// there both clamps land on the same 2.0, so either one alone would satisfy it
[Test]
public void Realtime_Input_Should_Clamp_An_Out_Of_Range_ReadRateCatchup()
{
string command = BuildRealtimeCommand(
new CatchupCapableFFmpegCapabilities(),
readRate: 1.2,
readRateCatchup: 15.0);
command.ShouldContain("-readrate 1.2 -readrate_initial_burst 8 -readrate_catchup 10.0 -i /tmp/whatever.mkv");
}
private string BuildRealtimeCommand(
IFFmpegCapabilities capabilities,
bool stillImage = false,
bool imageSubtitle = false,
Option<double> readRate = default,
Option<double> readRateCatchup = default)
bool imageSubtitle = false)
{
var videoInputFile = new VideoInputFile(
"/tmp/whatever.mkv",
@@ -794,9 +748,7 @@ public class PipelineBuilderBaseTests
false,
false,
"clip",
false,
MaybeReadRate: readRate,
MaybeReadRateCatchup: readRateCatchup);
false);
// a *separate* audio input matters here: for a still image the video input takes no readrate
// at all, so only a distinct audio input can prove the burst was suppressed (this is the
+1 -46
View File
@@ -28,9 +28,7 @@ public record FFmpegState(
bool IsHdrTonemap,
string TonemapAlgorithm,
bool IsTroubleshooting,
bool QsvPreferNativeDecoder = false,
Option<double> MaybeReadRate = default,
Option<double> MaybeReadRateCatchup = default)
bool QsvPreferNativeDecoder = false)
{
// the QSV upload pool needs headroom for the frames in flight through the filter graph.
// extra_hw_frames=0 leaves none, so any input that is not throttled exhausts it: the graph
@@ -44,49 +42,6 @@ public record FFmpegState(
public int QsvExtraHardwareFrames =>
Math.Max(MaybeQsvExtraHardwareFrames.IfNone(MinimumQsvExtraHardwareFrames), MinimumQsvExtraHardwareFrames);
// realtime pacing. an unset profile keeps the values these constants name, which are the ones
// the pipeline hardcoded before they became configurable (ersatztv#735)
public const double DefaultReadRate = 1.05;
public const double DefaultStreamCopyReadRate = 1.0;
// how fast a LAGGING realtime input may read until it is level again. measured on the #726
// repro (embedded dvd_subtitle -> overlay, QSV encode): 1.05 alone sustains 0.53x, catchup 2.0
// reaches 0.711x, and 6.0 restores the full 1.067x that the same pipeline achieves with no
// subtitle at all. 20.0 also measures 1.067x — i.e. the value is not a throughput dial above
// the point where the input catches up, so 6.0 is chosen as the smallest measured-sufficient
// ceiling rather than the largest that works (ersatztv#726)
public const double DefaultReadRateCatchup = 6.0;
// below realtime the process reads slower than a live client consumes and the channel stalls;
// ersatztv#726 is that failure, measured at an effective 0.53x. the ceiling is a CHOSEN bound,
// not a measured cliff: it exists so the field cannot be used to effectively disable pacing,
// which is the configuration ersatztv#529 measured to produce zero segments on a QSV pipeline
public const double MinimumReadRate = 1.0;
public const double MaximumReadRate = 2.0;
// catchup is a ceiling that applies only WHILE an input is behind, so it is bounded more
// loosely than the base rate; the same chosen-not-measured caveat applies to the ceiling.
// the FLOOR is only a write-path bound: at render time the resolved base rate is always at
// least MinimumReadRate, so Math.Max below already dominates it
public const double MinimumReadRateCatchup = 1.0;
public const double MaximumReadRateCatchup = 10.0;
// clamped for the same reason QsvExtraHardwareFrames is: a row written out of band (or before
// the write path validated the field) must not reach FFmpeg unbounded. the write path rejects
// an out-of-range value with a 422 naming the bound, so this is belt-and-braces, not the
// primary guard (ersatztv#735)
public double ReadRateFor(bool isStreamCopy) =>
MaybeReadRate.Match(
configured => Math.Clamp(configured, MinimumReadRate, MaximumReadRate),
() => isStreamCopy ? DefaultStreamCopyReadRate : DefaultReadRate);
// a catchup rate below the base rate cannot let a lagging input recover, so the resolved base
// rate is its real floor — no separate lower clamp, which would be unreachable behind this Max
public double ReadRateCatchupFor(bool isStreamCopy) =>
Math.Max(
Math.Min(MaybeReadRateCatchup.IfNone(DefaultReadRateCatchup), MaximumReadRateCatchup),
ReadRateFor(isStreamCopy));
public static FFmpegState Concat(bool saveReport, string channelName) =>
new(
saveReport,
@@ -22,6 +22,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
// an operator who raises that setting above 2 gets less of the benefit (ersatztv#350)
private const int InitialBurstSeconds = OutputFormatHls.SegmentSeconds * 2;
// how fast a LAGGING realtime input may read until it is level again. measured on the #726
// repro (embedded dvd_subtitle -> overlay, QSV encode): 1.05 alone sustains 0.53x, catchup 2.0
// reaches 0.711x, and 6.0 restores the full 1.067x that the same pipeline achieves with no
// subtitle at all. 20.0 also measures 1.067x — i.e. the value is not a throughput dial above
// the point where the input catches up, so 6.0 is chosen as the smallest measured-sufficient
// ceiling rather than the largest that works (ersatztv#726)
private const double CatchupReadRate = 6.0;
private readonly Option<AudioInputFile> _audioInputFile;
private readonly Option<ConcatInputFile> _concatInputFile;
private readonly IFFmpegCapabilities _ffmpegCapabilities;
@@ -652,7 +660,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
//SetStillImageInfiniteLoop(videoInputFile, videoStream, ffmpegState);
SetRealtimeInput(videoInputFile, ffmpegState, desiredState);
SetRealtimeInput(videoInputFile, desiredState);
SetInfiniteLoop(videoInputFile, videoStream, ffmpegState, desiredState);
SetFrameRateOutput(desiredState, pipelineSteps);
SetVideoTrackTimescaleOutput(desiredState, pipelineSteps);
@@ -847,17 +855,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
}
private void SetRealtimeInput(VideoInputFile videoInputFile, FFmpegState ffmpegState, FrameState desiredState)
private void SetRealtimeInput(VideoInputFile videoInputFile, FrameState desiredState)
{
if (videoInputFile.StreamInputKind is StreamInputKind.Live || !desiredState.Realtime)
{
return;
}
// both defaults and both bounds live on FFmpegState, beside the profile fields that
// override them, so the pacing contract is readable in one place (ersatztv#735)
bool isStreamCopy = desiredState.VideoFormat == VideoFormat.Copy;
double readRate = ffmpegState.ReadRateFor(isStreamCopy);
double readRate = desiredState.VideoFormat == VideoFormat.Copy ? 1.0 : 1.05;
// without a burst, the readrate throttle applies from the very first read, so the first
// segment cannot be written faster than ~realtime and every start pays a multi-second wait.
@@ -889,7 +894,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
// subtitle always rides the video path, so this shape cannot suffer the starvation anyway
Option<double> catchupReadRate =
!isStillImage && _ffmpegCapabilities.HasOption(FFmpegKnownOption.ReadrateCatchup)
? ffmpegState.ReadRateCatchupFor(isStreamCopy)
? CatchupReadRate
: Option<double>.None;
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds, catchupReadRate)));
@@ -1,38 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfile_ReadRatePacing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "ReadRate",
table: "FFmpegProfile",
type: "double",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "ReadRateCatchup",
table: "FFmpegProfile",
type: "double",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReadRate",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "ReadRateCatchup",
table: "FFmpegProfile");
}
}
}
@@ -929,12 +929,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<double?>("ReadRate")
.HasColumnType("double");
b.Property<double?>("ReadRateCatchup")
.HasColumnType("double");
b.Property<int>("ResolutionId")
.HasColumnType("int");
@@ -1,38 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfile_ReadRatePacing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "ReadRate",
table: "FFmpegProfile",
type: "REAL",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "ReadRateCatchup",
table: "FFmpegProfile",
type: "REAL",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReadRate",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "ReadRateCatchup",
table: "FFmpegProfile");
}
}
}
@@ -896,12 +896,6 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<double?>("ReadRate")
.HasColumnType("REAL");
b.Property<double?>("ReadRateCatchup")
.HasColumnType("REAL");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
@@ -1,4 +1,4 @@
using Dapper;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -171,15 +171,8 @@ public class MusicVideoRepository : IMusicVideoRepository
public async Task<int> GetMusicVideoCount(int artistId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
// count the same population GetPagedMusicVideos pages — MusicVideoMetadata, not MusicVideo.
// A music video whose metadata row is missing (a scanner failure; FindOrphanPaths models
// exactly that state) is not pageable, so counting the item table over-reports
// (api.paged-count-matches-page-query, #832).
return await dbContext.Connection.QuerySingleAsync<int>(
@"SELECT COUNT(*)
FROM MusicVideoMetadata MVM
INNER JOIN MusicVideo M on MVM.MusicVideoId = M.Id
WHERE M.ArtistId = @ArtistId",
@"SELECT COUNT(*) FROM MusicVideo WHERE ArtistId = @ArtistId",
new { ArtistId = artistId });
}
@@ -1,4 +1,4 @@
using Dapper;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
@@ -134,26 +134,9 @@ public class TelevisionRepository : ITelevisionRepository
public async Task<int> GetSeasonCount(int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
// GetPagedSeasons expands the requested show to EVERY show sharing its Title+Year (the same
// show present in two libraries) and pages the union, so the count must expand identically
// or it under-reports (api.paged-count-matches-page-query, #832).
Option<ShowMetadata> maybeShowMetadata = await dbContext.ShowMetadata
.SelectOneAsync(sm => sm.Id, sm => sm.ShowId == showId, CancellationToken.None);
foreach (ShowMetadata showMetadata in maybeShowMetadata)
{
List<int> showIds = await dbContext.ShowMetadata
.Filter(sm => sm.Title == showMetadata.Title && sm.Year == showMetadata.Year)
.Map(sm => sm.ShowId)
.ToListAsync();
return await dbContext.Seasons
.AsNoTracking()
.CountAsync(s => showIds.Contains(s.ShowId));
}
// no metadata for the requested show: GetPagedSeasons returns nothing, so neither does this
return 0;
return await dbContext.Seasons
.AsNoTracking()
.CountAsync(s => s.ShowId == showId);
}
public async Task<List<Season>> GetPagedSeasons(
@@ -196,12 +179,9 @@ public class TelevisionRepository : ITelevisionRepository
public async Task<int> GetEpisodeCount(int seasonId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
// count the same population GetPagedEpisodes pages — EpisodeMetadata, not Episode. An
// episode whose metadata row is missing is not pageable, so counting the item table
// over-reports (api.paged-count-matches-page-query, #832).
return await dbContext.EpisodeMetadata
return await dbContext.Episodes
.AsNoTracking()
.CountAsync(em => em.Episode.SeasonId == seasonId);
.CountAsync(e => e.SeasonId == seasonId);
}
public async Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize)
@@ -440,64 +440,64 @@ public class ElasticSearchIndex : ISearchIndex
Season season)
{
foreach (SeasonMetadata metadata in season.SeasonMetadata.HeadOrNone())
foreach (ShowMetadata showMetadata in season.Show.ShowMetadata.HeadOrNone())
foreach (ShowMetadata showMetadata in season.Show.ShowMetadata.HeadOrNone())
{
try
{
try
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
.ToLowerInvariant();
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
.ToLowerInvariant();
var doc = new ElasticSearchItem
{
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
.ToLowerInvariant();
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
.ToLowerInvariant();
Id = season.Id,
Type = LuceneSearchIndex.SeasonType,
Title = seasonTitle,
SortTitle = sortTitle,
LibraryName = season.LibraryPath.Library.Name,
LibraryId = season.LibraryPath.Library.Id,
TitleAndYear = titleAndYear,
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata),
State = season.State.ToString(),
SeasonNumber = season.SeasonNumber,
ShowTitle = showMetadata.Title,
ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList(),
ShowTag = showMetadata.Tags.Map(t => t.Name).ToList(),
ShowStudio = showMetadata.Studios.Map(s => s.Name).ToList(),
ShowContentRating = GetContentRatings(showMetadata.ContentRating),
Language = GetLanguages(
languageCodeService,
await searchRepository.GetLanguagesForSeason(season)),
LanguageTag = await searchRepository.GetLanguagesForSeason(season),
SubLanguage = GetLanguages(
languageCodeService,
await searchRepository.GetSubLanguagesForSeason(season)),
SubLanguageTag = await searchRepository.GetSubLanguagesForSeason(season),
ContentRating = GetContentRatings(showMetadata.ContentRating),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
TraktList = season.TraktListItems
.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture)).ToList(),
Tag = metadata.Tags.Map(a => a.Name).ToList(),
TagFull = metadata.Tags.Map(t => t.Name).ToList()
};
var doc = new ElasticSearchItem
{
Id = season.Id,
Type = LuceneSearchIndex.SeasonType,
Title = seasonTitle,
SortTitle = sortTitle,
LibraryName = season.LibraryPath.Library.Name,
LibraryId = season.LibraryPath.Library.Id,
TitleAndYear = titleAndYear,
TitleAndYearSearch = LuceneSearchIndex.GetTitleAndYearSearch(metadata),
JumpLetter = LuceneSearchIndex.GetJumpLetter(showMetadata),
State = season.State.ToString(),
SeasonNumber = season.SeasonNumber,
ShowTitle = showMetadata.Title,
ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList(),
ShowTag = showMetadata.Tags.Map(t => t.Name).ToList(),
ShowStudio = showMetadata.Studios.Map(s => s.Name).ToList(),
ShowContentRating = GetContentRatings(showMetadata.ContentRating),
Language = GetLanguages(
languageCodeService,
await searchRepository.GetLanguagesForSeason(season)),
LanguageTag = await searchRepository.GetLanguagesForSeason(season),
SubLanguage = GetLanguages(
languageCodeService,
await searchRepository.GetSubLanguagesForSeason(season)),
SubLanguageTag = await searchRepository.GetSubLanguagesForSeason(season),
ContentRating = GetContentRatings(showMetadata.ContentRating),
ReleaseDate = GetReleaseDate(metadata.ReleaseDate),
AddedDate = GetAddedDate(metadata.DateAdded),
TraktList = season.TraktListItems
.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture)).ToList(),
Tag = metadata.Tags.Map(a => a.Name).ToList(),
TagFull = metadata.Tags.Map(t => t.Name).ToList()
};
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName, ES.Id.From(doc));
}
catch (Exception ex)
foreach ((string key, List<string> value) in GetMetadataGuids(metadata))
{
metadata.Season = null;
_logger.LogWarning(ex, "Error indexing season with metadata {@Metadata}", metadata);
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName, ES.Id.From(doc));
}
catch (Exception ex)
{
metadata.Season = null;
_logger.LogWarning(ex, "Error indexing season with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateArtist(
@@ -763,10 +763,8 @@ public class ElasticSearchIndex : ISearchIndex
{
try
{
// Guard the two NULLABLE primitive collections at the READ SITE, never by assigning back onto
// `metadata` (ersatztv#701) -- see the matching comment in LuceneSearchIndex.UpdateSong.
List<string> artists = Optional(metadata.Artists).Flatten().ToList();
List<string> albumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
metadata.AlbumArtists ??= [];
metadata.Artists ??= [];
var doc = new ElasticSearchItem
{
@@ -787,8 +785,8 @@ public class ElasticSearchIndex : ISearchIndex
SubLanguageTag = GetSubLanguageTags(song.MediaVersions),
AddedDate = GetAddedDate(metadata.DateAdded),
Album = metadata.Album ?? string.Empty,
Artist = artists,
AlbumArtist = albumArtists,
Artist = metadata.Artists.ToList(),
AlbumArtist = metadata.AlbumArtists.ToList(),
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList(),
TagFull = metadata.Tags.Map(t => t.Name).ToList()
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
@@ -145,7 +145,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
_directory = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
Analyzer analyzer = SearchQueryParser.AnalyzerWrapper();
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
{ OpenMode = OpenMode.CREATE_OR_APPEND };
{ OpenMode = OpenMode.CREATE_OR_APPEND };
_writer = new IndexWriter(_directory, indexConfig);
_initialized = true;
}
@@ -328,7 +328,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
using (Analyzer analyzer = SearchQueryParser.AnalyzerWrapper())
{
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
{ OpenMode = OpenMode.CREATE_OR_APPEND };
{ OpenMode = OpenMode.CREATE_OR_APPEND };
using (var w = new IndexWriter(d, indexConfig))
{
using (DirectoryReader _ = w.GetReader(true))
@@ -1318,13 +1318,8 @@ public sealed class LuceneSearchIndex : ISearchIndex
{
try
{
// Guard the two NULLABLE primitive collections at the READ SITE, never by assigning back onto
// `metadata` (ersatztv#701). The entity reaching here may be TRACKED, and Artists/AlbumArtists
// are scalar JSON-array columns rather than navigations -- so `??= []` flips the entity to
// Modified and the next SaveChanges writes `[]` over a NULL column. Same convention as
// SongVideoGenerator and MediaCollectionRepository (ersatztv#691).
List<string> artists = Optional(metadata.Artists).Flatten().ToList();
List<string> albumArtists = Optional(metadata.AlbumArtists).Flatten().ToList();
metadata.AlbumArtists ??= [];
metadata.Artists ??= [];
var doc = new Document
{
@@ -1360,12 +1355,12 @@ public sealed class LuceneSearchIndex : ISearchIndex
doc.Add(new TextField(AlbumField, metadata.Album, Field.Store.NO));
}
foreach (string artist in artists)
foreach (string artist in metadata.Artists)
{
doc.Add(new TextField(ArtistField, artist, Field.Store.NO));
}
foreach (string albumArtist in albumArtists)
foreach (string albumArtist in metadata.AlbumArtists)
{
doc.Add(new TextField(AlbumArtistField, albumArtist, Field.Store.NO));
}
@@ -4,57 +4,11 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public static class GraphicsElementSeeder
{
// The pre-#732 default, kept verbatim so an installation still carrying it byte-for-byte can
// be recognised as unmodified and upgraded. Never edit an entry here -- it is a fingerprint of
// what we shipped, not a template. Add a new entry when the current default changes again.
private const string OnNowNextYamlV1 =
"""
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
base_style: now
styles:
- name: now
font_family: "Noto Sans"
font_size: 30
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 2
- name: sub
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
- name: next
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
text: |
[now]NOW {{ Epg[0].Title }}[/now]
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
""";
private const string OnNowNextYaml =
"""
name: On Now / Next
@@ -68,17 +22,6 @@ public static class GraphicsElementSeeder
z_index: 100
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
# #732: a translucent box carries legibility over both bright and dark content. The halo is
# cut from 2 to 1 rather than dropped -- the box is translucent, so bright content still
# shows through behind the glyphs, but 2px of halo ON TOP of a box over-darkens the text.
background_color: "#000000"
background_opacity_percent: 65
background_padding: 14
background_corner_radius: 8
# A translucent black box vanishes into dark content, so the box needs an edge of its own.
# Low-alpha white reads as a hairline on dark frames without becoming a hard line on bright ones.
border_color: "#59FFFFFF"
border_width: 1
base_style: now
styles:
- name: now
@@ -87,56 +30,39 @@ public static class GraphicsElementSeeder
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 1
halo_width: 2
- name: sub
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 1
halo_width: 2
- name: next
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 1
halo_width: 2
text: |
[now]NOW {{ Epg[0].Title }}[/now]
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
""";
// Every default we have ever shipped, most recent first. A file matching one of these was
// written by us and never touched, so replacing it is an upgrade rather than a clobber.
private static readonly string[] SupersededDefaults = [OnNowNextYamlV1];
public static async Task SeedOnNowNext(
TvContext context,
IFileSystem fileSystem,
ILogger logger,
CancellationToken cancellationToken)
public static async Task SeedOnNowNext(TvContext context, IFileSystem fileSystem, CancellationToken cancellationToken)
{
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
string seededKey = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
bool alreadySeeded = await context.ConfigElements.AnyAsync(c => c.Key == seededKey, cancellationToken);
if (alreadySeeded)
{
// Already-seeded installations never revisit the file, so a change to the default would
// otherwise reach new databases only. Upgrade the ones still carrying an untouched
// earlier default; anything an operator edited no longer matches and is left alone.
//
// Deliberately no CreateDirectory on this branch: before #732 it touched the filesystem
// not at all, so an already-seeded install stays bootable on a read-only /config.
await UpgradeUnmodifiedTemplate(fileSystem, target, logger, cancellationToken);
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
return;
}
string folder = FileSystemLayout.GraphicsElementsTextTemplatesFolder;
string target = fileSystem.Path.Combine(folder, GraphicsElementDefaults.OnNowNextFileName);
if (!fileSystem.Directory.Exists(folder))
{
fileSystem.Directory.CreateDirectory(folder);
@@ -152,242 +78,5 @@ public static class GraphicsElementSeeder
new ConfigElement { Key = seededKey, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
await EnsureBuiltInElementRow(context, fileSystem, target, cancellationToken);
}
/// <summary>
/// `RefreshGraphicsElements` is what normally turns a template file into a `GraphicsElement` row,
/// but it runs on the scheduler/stream-start path -- long after startup. Creating the row here
/// removes that ordering dependency, so `AttachOnNowNextByDefault` below can never mark itself
/// done against an element that simply had not been discovered yet.
/// </summary>
private static async Task EnsureBuiltInElementRow(
TvContext context,
IFileSystem fileSystem,
string target,
CancellationToken cancellationToken)
{
if (!fileSystem.File.Exists(target))
{
return;
}
// "Does the built-in row already exist?" is the same question every consumer asks later, so
// ask it with the same code instead of re-deriving it here. As its own SQL comparison
// (`AnyAsync(e => e.Path == target)`) it could answer differently in two ways, and either
// one leaves the built-in element undiscoverable after startup (#568):
// * string equality in SQL is the PROVIDER's collation to decide, so on MySQL's normally
// case-INsensitive default a case-variant row satisfied the check, the canonical row was
// never created, and the ordinal lookup below then matched nothing;
// * it ignored `Kind`, so a row of another kind sitting at the seeded path suppressed the
// Text row the lookup actually resolves.
// Creating the row stays idempotent because `target` IS the path the lookup matches -- held
// by `Repeated_Seeding_Does_Not_Accumulate_Element_Rows`, which reddens if the two drift.
if ((await GetBuiltInElementId(context, cancellationToken)).IsSome)
{
return;
}
// Name is display-only (identity is the full seeded path, `target` above -- #568), but
// leaving it null sorts the built-in element into the unnamed bucket at the bottom of the
// SPA list until the first refresh.
await context.GraphicsElements.AddAsync(
new Core.Domain.GraphicsElement
{
Path = target,
Kind = GraphicsElementKind.Text,
Name = GraphicsElementDefaults.OnNowNextName
},
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// #732: the On Now / Next overlay is a default, not an opt-in. Existing channels predate that
/// decision, so attach the built-in element to them once.
/// </summary>
/// <remarks>
/// The marker is written only once the built-in element RESOLVES, so an install whose row does
/// not exist yet is retried on the next startup rather than stranded permanently. Once written,
/// no channel is ever re-attached. While still armed the backfill cannot tell a deliberately
/// cleared channel from an untouched one -- a single global flag cannot express both
/// properties; see <c>graphics.on-now-next-on-by-default</c> for why that trade is made this
/// way. Every channel created after the marker gets the element from
/// <c>ChannelGraphicsDefaults.Attach</c> instead, which BOTH create paths call.
/// </remarks>
public static async Task AttachOnNowNextByDefault(TvContext context, CancellationToken cancellationToken)
{
string key = ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key;
if (await context.ConfigElements.AnyAsync(c => c.Key == key, cancellationToken))
{
return;
}
Option<int> maybeElementId = await GetBuiltInElementId(context, cancellationToken);
if (maybeElementId.IsNone)
{
// Nothing to attach TO. Writing the marker here would strand every channel permanently
// on the one population this exists for, so stay armed and try again next startup.
return;
}
foreach (int elementId in maybeElementId)
{
// HLS Direct has no frame pipeline to draw into, so an attachment there would be inert
// while still reading as "on" in the editor.
List<int> channelIds = await context.Channels
.Where(c => c.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
.Where(c => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != elementId))
.Select(c => c.Id)
.ToListAsync(cancellationToken);
foreach (int channelId in channelIds)
{
await context.AddAsync(
new ChannelGraphicsElement { ChannelId = channelId, GraphicsElementId = elementId },
cancellationToken);
}
}
await context.ConfigElements.AddAsync(
new ConfigElement { Key = key, Value = "true" },
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Identity is the full seeded path, never the user-editable Name (the #67 lesson carried into
/// #74) and never the bare filename (#568: filename-only matching is folder-agnostic, so a user
/// element named exactly `on-now-next.yml` in a different template folder would also match).
/// The <c>Kind</c> half of that identity is load-bearing rather than decorative:
/// <c>EnsureBuiltInElementRow</c> asks this method whether the row it is about to create already
/// exists, so a row of another kind at the seeded path must NOT answer yes -- it would suppress
/// the Text row every consumer resolves.
/// </summary>
/// <remarks>
/// Both halves are <see cref="GraphicsElementDefaults.IsOnNowNext(string,GraphicsElementKind)"/>
/// in memory rather than a <c>Where</c> clause. The path half must be, or the match would be the
/// provider's collation to decide and this site would disagree with the API's `builtIn` (which
/// compares in memory) on MySQL. The <c>Kind</c> half could be a SQL filter -- it is an enum,
/// not a string -- but then this site would hold half the identity and the predicate the other
/// half, and the API site could apply the predicate alone and quietly answer for rows this one
/// rejects. That is exactly the disagreement #568 found, so identity is one predicate applied
/// whole, at every site.
/// </remarks>
public static async Task<Option<int>> GetBuiltInElementId(
TvContext context,
CancellationToken cancellationToken)
{
List<(int Id, string Path, GraphicsElementKind Kind)> candidates = await context.GraphicsElements
.Select(e => new { e.Id, e.Path, e.Kind })
.ToListAsync(cancellationToken)
.Map(rows => rows.Select(r => (r.Id, r.Path, r.Kind)).ToList());
List<int> matches = candidates
.Where(c => GraphicsElementDefaults.IsOnNowNext(c.Path, c.Kind))
.Select(c => c.Id)
.OrderBy(id => id)
.ToList();
// Lowest id wins if two rows somehow share the seeded path, so the choice is stable across
// restarts rather than dependent on query order.
return matches.Count == 0 ? Option<int>.None : matches[0];
}
private static async Task UpgradeUnmodifiedTemplate(
IFileSystem fileSystem,
string target,
ILogger logger,
CancellationToken cancellationToken)
{
// This runs inside the blocking database-startup path, ahead of DatabaseIsReady(). Before
// #732 the already-seeded branch never touched the filesystem at all, so an unreadable or
// read-only template is a state that used to boot fine -- it must not become a failure to
// start. Cosmetic upgrade, best effort.
try
{
if (!fileSystem.File.Exists(target))
{
return;
}
string existing = await fileSystem.File.ReadAllTextAsync(target, cancellationToken);
if (!SupersededDefaults.Any(d => IsSameTemplate(existing, d)))
{
return;
}
// Write-then-move, never write in place. WriteAllTextAsync truncates first, so an
// interrupted write (disk full, IO fault, cancellation) would leave a partial file that
// matches no fingerprint and is therefore never repaired on a later boot -- the overlay
// would just be gone, permanently, on every channel carrying it.
//
// The temp name is random per call. A fixed one is shared by two containers on the same
// config volume, where one can truncate it while the other is mid-write and then rename
// the partial file over the live template. The process id is NOT good enough here: the
// image uses an exec-form ENTRYPOINT, so every container's PID namespace makes this
// process 1 and every container computes the same name. A random name also means a temp
// left by a crashed earlier boot is never reused. Only ever delete the path this call
// created.
string temp = $"{target}.{fileSystem.Path.GetRandomFileName()}.upgrade.tmp";
try
{
await fileSystem.File.WriteAllTextAsync(temp, OnNowNextYaml, cancellationToken);
// Deliberately NO in-place fallback when this throws. rename(2) onto a mountpoint
// is EBUSY, so a single-file bind mount of this template will not be upgraded --
// accepted, because reaching that case needs a pinned file that is ALSO byte-identical
// to a shipped default, and the alternative is reintroducing the truncation this
// whole dance exists to prevent, on every IO fault rather than just that one.
fileSystem.File.Move(temp, target, true);
}
finally
{
// Cleanup must never REPLACE the exception that brought us here. Without this inner
// catch, a delete that throws while unwinding a cancellation swaps the
// OperationCanceledException for an IOException, which the outer filter then
// swallows -- so a real shutdown would be silently downgraded to a warning.
try
{
if (fileSystem.File.Exists(temp))
{
fileSystem.File.Delete(temp);
}
}
catch (Exception cleanupEx)
{
logger.LogDebug(cleanupEx, "Could not remove the temporary upgrade file {Path}", temp);
}
}
}
// Recoverable filesystem faults only. Catching everything would swallow genuinely fatal
// runtime failures (OutOfMemory and friends) and continue booting a compromised process;
// letting IO escape would turn a file permission into a restart loop. Cancellation
// propagates so shutdown is not swallowed.
// OperationCanceledException is deliberately absent from this list so a real shutdown
// propagates -- but only a real one: an OCE raised while the token is NOT cancelled is just
// another faulty read, and letting it escape is the restart loop this catch exists to stop.
catch (Exception ex) when ((ex is IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException)
|| (ex is OperationCanceledException
&& !cancellationToken.IsCancellationRequested))
{
logger.LogWarning(
ex,
"Could not upgrade the built-in graphics template at {Path}; leaving it as-is",
target);
}
}
// Compare on content, ignoring the line endings and trailing whitespace an editor or a volume
// mount may rewrite. This is a fingerprint check, not a parse: anything that is not one of our
// own shipped defaults must fall through untouched.
private static bool IsSameTemplate(string left, string right) =>
string.Equals(Normalize(left), Normalize(right), StringComparison.Ordinal);
private static string Normalize(string value) =>
value.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd();
}
@@ -14,10 +14,6 @@ public partial class TextElement(
ILogger logger)
: GraphicsElement, IDisposable
{
// Far larger than any sane overlay on an 8K frame, and small enough that every downstream
// int cast stays well inside range.
private const float MaxBoxDimension = 10_000f;
private static readonly Regex StylePattern = StyleRegex();
private SKBitmap _image;
private SKPointI _location;
@@ -66,96 +62,30 @@ public partial class TextElement(
}
}
BackgroundBox box = BuildBackgroundBox();
RichTextKit.TextBlock textBlock = BuildTextBlock(textElement.Text);
// Padding and border sit OUTSIDE the laid-out text on every side, so they shrink the
// space the text may occupy and grow the bitmap that holds it. Zero when there is no
// box, which reproduces the pre-#732 geometry exactly.
//
// Round ONCE, here, and use the same integer on both sides: the bitmap grows by
// 2 * insetPixels, so subtracting the unrounded inset from the wrap budget would let a
// fractional padding push the finished box a pixel past width_percent.
var insetPixels = (int)Math.Ceiling(box?.Inset ?? 0f);
// Bound the inset against the FRAME even when there is no width_percent. Sanitize caps
// each field individually, but padding and border add up, and without a budget nothing
// else clamps them -- a two-field fat-finger would otherwise allocate a bitmap far
// larger than the frame it is drawn onto. Pre-#732 no config value could inflate the
// bitmap independently of the measured text.
int frameInsetCap = Math.Max(0, Math.Min(context.FrameSize.Width, context.FrameSize.Height) / 2);
if (insetPixels > frameInsetCap)
{
logger.LogWarning(
"Background padding/border of {Inset}px exceeds the frame; clamping to {Clamped}px",
insetPixels,
frameInsetCap);
insetPixels = frameInsetCap;
box = box?.ClampedTo(frameInsetCap);
}
// A width_percent of 1e300 makes maxWidth Infinity, and every int cast below it is then
// unspecified. Treat a non-finite budget as "no budget", which is what an absent
// width_percent already means.
if (textElement.WidthPercent.HasValue
&& float.IsFinite((float)(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width)))
if (textElement.WidthPercent.HasValue)
{
var maxWidth = (float)Math.Round(textElement.WidthPercent.Value / 100.0 * context.FrameSize.Width);
// A padding wider than the budget itself cannot be honoured AND stay inside it.
// Clamp the inset rather than squeezing the text to 1px: an unclamped floor turns a
// fat-fingered background_padding into a box several times the requested width.
int maxInset = Math.Max(0, (int)Math.Floor((maxWidth - 1) / 2));
if (insetPixels > maxInset)
{
logger.LogWarning(
"Background padding/border of {Inset}px does not fit within width_percent "
+ "({MaxWidth}px); clamping to {Clamped}px",
insetPixels,
maxWidth,
maxInset);
// Clamp the BOX, not just the bitmap's inset. Shrinking insetPixels alone leaves
// DrawBackgroundBox stroking at the original border width, which is centred on a
// rect that no longer has room for it -- the stroke then floods the element.
insetPixels = maxInset;
box = box?.ClampedTo(maxInset);
}
// width_percent bounds the ELEMENT, so the text gets what is left after the insets.
// With no box the budget is passed through untouched -- not through Math.Max -- so a
// width_percent that rounds to 0 keeps its exact pre-#732 behavior.
float textMaxWidth = insetPixels == 0
? maxWidth
: Math.Max(1f, maxWidth - (2 * insetPixels));
switch (textElement.Fit)
{
case TextFit.Wrap:
textBlock.MaxWidth = textMaxWidth;
textBlock.MaxWidth = maxWidth;
break;
case TextFit.Scale:
FitTextBlock(textBlock, textMaxWidth);
FitTextBlock(textBlock, maxWidth);
break;
}
}
_image = new SKBitmap(
(int)Math.Ceiling(textBlock.MeasuredWidth) + (2 * insetPixels),
(int)Math.Ceiling(textBlock.MeasuredHeight) + (2 * insetPixels));
(int)Math.Ceiling(textBlock.MeasuredWidth),
(int)Math.Ceiling(textBlock.MeasuredHeight));
using (var canvas = new SKCanvas(_image))
{
canvas.Clear(SKColors.Transparent);
if (box is not null)
{
DrawBackgroundBox(canvas, box, _image.Width, _image.Height);
}
textBlock.Paint(canvas, new SKPoint(insetPixels, insetPixels));
textBlock.Paint(canvas, new SKPoint(0, 0));
}
var horizontalMargin =
@@ -204,158 +134,6 @@ public partial class TextElement(
: new ValueTask<Option<PreparedElementImage>>(new PreparedElementImage(_image, _location, opacity, ZIndex, false));
}
// A background box is drawn only when a colour actually parses. An unparseable colour is
// warned about and skipped rather than substituted, so a typo never silently changes the
// look into something that appears deliberate.
private BackgroundBox BuildBackgroundBox()
{
SKColor? fill = ParseOptionalColor(textElement.BackgroundColor, "background_color");
if (fill.HasValue)
{
fill = ApplyOpacityPercent(fill.Value, textElement.BackgroundOpacityPercent);
}
SKColor? border = ParseOptionalColor(textElement.BorderColor, "border_color");
// A border colour with no explicit width means a hairline border, not an invisible one:
// "border_color set, nothing drawn" is the more confusing of the two readings.
float borderWidth = Sanitize(textElement.BorderWidth ?? 1, "border_width");
if (!border.HasValue)
{
borderWidth = 0;
}
if (!fill.HasValue && borderWidth <= 0)
{
return null;
}
return new BackgroundBox(
fill,
border,
borderWidth,
Sanitize(textElement.BackgroundCornerRadius, "background_corner_radius"),
Sanitize(textElement.BackgroundPadding, "background_padding"));
}
// YAML happily yields 1e100 or NaN. Cast to float those become Infinity/NaN, and
// (int)Math.Ceiling(Infinity) is an unspecified value -- in practice int.MinValue, which sails
// straight past every `> maxInset` clamp and can wrap 2 * inset back to zero. Sanitize at the
// boundary so no downstream arithmetic ever sees a non-finite value.
private float Sanitize(double? value, string fieldName)
{
if (value is not { } raw)
{
return 0f;
}
if (double.IsNaN(raw) || raw < 0)
{
logger.LogWarning("Ignoring out-of-range {Field} value {Value}", fieldName, raw);
return 0f;
}
if (raw > MaxBoxDimension)
{
logger.LogWarning(
"Clamping {Field} value {Value} to {Max}",
fieldName,
raw,
MaxBoxDimension);
return MaxBoxDimension;
}
return (float)raw;
}
private SKColor? ParseOptionalColor(string value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (SKColor.TryParse(value, out SKColor parsed))
{
return parsed;
}
logger.LogWarning(
"Unable to parse {Field} value {Value}; that part of the background box will not be drawn",
fieldName,
value);
return null;
}
private static SKColor ApplyOpacityPercent(SKColor color, int? opacityPercent)
{
if (opacityPercent is not { } percent)
{
return color;
}
int clamped = Math.Clamp(percent, 0, 100);
return color.WithAlpha((byte)Math.Round(color.Alpha * clamped / 100.0));
}
private static void DrawBackgroundBox(SKCanvas canvas, BackgroundBox box, int width, int height)
{
// Skia strokes centred on the path, so half the border would fall outside the bitmap and
// be clipped. Inset the rect by half the width to keep the whole border visible.
float half = box.BorderWidth / 2f;
var rect = new SKRect(half, half, width - half, height - half);
// A radius larger than half the shorter side is not expressible as a rounded rect.
float radius = Math.Min(box.CornerRadius, Math.Min(rect.Width, rect.Height) / 2f);
radius = Math.Max(0, radius);
if (box.Fill is { } fill)
{
using var fillPaint = new SKPaint
{
Color = fill,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRoundRect(rect, radius, radius, fillPaint);
}
if (box.Border is { } border && box.BorderWidth > 0)
{
using var borderPaint = new SKPaint
{
Color = border,
Style = SKPaintStyle.Stroke,
StrokeWidth = box.BorderWidth,
IsAntialias = true
};
canvas.DrawRoundRect(rect, radius, radius, borderPaint);
}
}
private sealed record BackgroundBox(
SKColor? Fill,
SKColor? Border,
float BorderWidth,
float CornerRadius,
float Padding)
{
public float Inset => Padding + BorderWidth;
// Border first, then whatever is left goes to padding: a border that cannot be drawn inside
// the bitmap is worse than a thin one, and padding degrades gracefully to zero.
public BackgroundBox ClampedTo(float maxInset)
{
float borderWidth = Math.Min(BorderWidth, maxInset);
float padding = Math.Max(0, maxInset - borderWidth);
return this with { BorderWidth = borderWidth, Padding = padding };
}
}
private RichTextKit.TextBlock BuildTextBlock(string textToRender)
{
var textBlock = new RichTextKit.TextBlock
@@ -433,17 +211,6 @@ public partial class TextElement(
finalStyle.TextColor = parsedColor;
}
// Halo is per-style in the schema and was being dropped here, so a non-base style's
// halo_* silently inherited the base style's. The seeded template only looked correct
// because all three of its styles declare the same halo.
finalStyle.HaloWidth = s.HaloWidth ?? finalStyle.HaloWidth;
finalStyle.HaloBlur = s.HaloBlur ?? finalStyle.HaloBlur;
if (s.HaloColor != null && SKColor.TryParse(s.HaloColor, out SKColor parsedHalo))
{
finalStyle.HaloColor = parsedHalo;
}
styles[s.Name] = finalStyle;
}
@@ -519,11 +286,6 @@ public partial class TextElement(
foreach ((string text, RichTextKit.IStyle style) in originalContent)
{
// Carry across every property the YAML schema can set, not just the ones the scale
// needs (the rest are RichTextKit defaults we never touch). Halo and
// line height were being dropped here, which only mattered once #732 gave the box
// insets that can push a previously-fitting element into the Scale path: adding a
// background would then silently remove the halo it sits behind.
var newStyle = new RichTextKit.Style
{
FontFamily = style.FontFamily,
@@ -532,11 +294,7 @@ public partial class TextElement(
FontWidth = style.FontWidth,
FontWeight = style.FontWeight,
LetterSpacing = style.LetterSpacing,
LineHeight = style.LineHeight,
TextColor = style.TextColor,
HaloColor = style.HaloColor,
HaloWidth = style.HaloWidth,
HaloBlur = style.HaloBlur
TextColor = style.TextColor
};
float newSize = newStyle.FontSize * scale;
@@ -21,16 +21,4 @@
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
</ItemGroup>
<!--
The generated OpenAPI document is the wire contract the MCP catalog wraps. Copying it into the
test output lets ToolCatalogTests assert that every write tool declares exactly the request-body
fields its endpoint accepts, so a new DTO property cannot drift out of a tool schema unnoticed
(issue #754). Regenerated by scripts/update-openapi.sh.
-->
<ItemGroup>
<Content Include="..\ErsatzTV\wwwroot\openapi\v1.json"
Link="openapi\v1.json"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+3 -260
View File
@@ -77,22 +77,9 @@ public class ToolCatalogTests
// Enums must NOT be forced required (they have server-side defaults).
createRequired.ShouldNotContain("streamingMode");
// Update carries the create body fields plus the route id...
JsonElement updateProps = update.InputSchema.RootElement.GetProperty("properties");
updateProps.TryGetProperty("id", out _).ShouldBeTrue();
updateProps.TryGetProperty("showInEpg", out _).ShouldBeTrue();
// ...plus graphicsElementIds, which is on UpdateChannelRequest only. PUT is a full replace, so
// while the tool could not express this field an agent following the tool's own "send the full
// desired state" instruction silently detached every graphics element (issue #754).
updateProps.TryGetProperty("graphicsElementIds", out JsonElement graphicsElementIds).ShouldBeTrue();
graphicsElementIds.GetProperty("type").GetString().ShouldBe("array");
graphicsElementIds.GetProperty("items").GetProperty("type").GetString().ShouldBe("integer");
// Create must NOT send it: CreateChannelRequest has no such property, and the tool schema is
// additionalProperties:false. This is why it is declared on the update tool rather than in the
// shared ChannelFields().
createProps.TryGetProperty("graphicsElementIds", out _).ShouldBeFalse();
// Update carries the same body fields plus the route id.
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("showInEpg", out _).ShouldBeTrue();
}
[Test]
@@ -269,248 +256,4 @@ public class ToolCatalogTests
tool.QueryParameters.ShouldNotBeNull();
tool.QueryParameters!.ShouldContain("deep");
}
// #754: ToolCatalog declared 27 of UpdateChannelRequest's 28 properties. The missing one was
// graphicsElementIds, and because PUT /api/v1/channels/{id} is a FULL REPLACE the omission was not
// merely "one field you cannot set" — an agent that GET-edit-PUT the channel, exactly as the tool's
// description tells it to, detached every graphics element (including the On Now/Next overlay) with
// a 200 and no error. The same shape was live on ersatztv_update_schedule, which omitted
// padToNearestMinute and silently cleared a configured pad.
//
// Neither is fixable by counting fields once: the defect is that nothing tied the tool schema to the
// contract it wraps. So this test asserts the tie for EVERY write tool against the generated OpenAPI
// document (the actual wire contract, linked into the test output by the csproj). A new property on
// any request DTO now fails here until the catalog declares it.
[Test]
public void Every_Write_Tool_Should_Declare_Exactly_Its_OpenApi_Request_Body_Fields()
{
using JsonDocument spec = LoadOpenApiDocument();
JsonElement paths = spec.RootElement.GetProperty("paths");
ToolDefinition[] writeTools = ToolCatalog.All
.Where(t => t.HttpMethod == HttpMethod.Post
|| t.HttpMethod == HttpMethod.Put
|| t.HttpMethod == HttpMethod.Patch)
.ToArray();
// Pin the covered set rather than trusting the filter. A tool that stopped being a write verb,
// or a new write tool, must show up as a change here — a bare loop over a filtered set passes
// just as happily when the set silently shrinks to nothing.
string[] expectedWriteTools =
[
"ersatztv_add_collection_items",
"ersatztv_create_channel",
"ersatztv_create_collection",
"ersatztv_create_playout",
"ersatztv_create_schedule",
"ersatztv_create_smart_collection",
"ersatztv_enable_jellyfin_library_sync",
"ersatztv_refresh_jellyfin_libraries",
"ersatztv_reset_channel_playout",
"ersatztv_scan_jellyfin_collections",
"ersatztv_scan_library",
"ersatztv_update_channel",
"ersatztv_update_collection",
"ersatztv_update_collection_custom_order",
"ersatztv_update_playout",
"ersatztv_update_schedule",
"ersatztv_update_smart_collection"
];
writeTools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal)
.ShouldBe(expectedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
foreach (ToolDefinition tool in writeTools)
{
paths.TryGetProperty(tool.PathTemplate, out JsonElement pathItem)
.ShouldBeTrue($"{tool.Name}: {tool.PathTemplate} is not in the OpenAPI document");
string verb = tool.HttpMethod.Method.ToLowerInvariant();
pathItem.TryGetProperty(verb, out JsonElement operation)
.ShouldBeTrue($"{tool.Name}: {verb.ToUpperInvariant()} {tool.PathTemplate} is not in the OpenAPI document");
Dictionary<string, string> declared = DeclaredBodyArguments(tool);
Dictionary<string, string> accepted = RequestBodyProperties(spec, operation, tool.Name);
// Compare name AND type. Names alone would let a field drift to the wrong JSON type: the
// tool would advertise "string" for an int?, the agent would send "30", and the API would
// 400 — green test, broken tool.
declared.Select(p => $"{p.Key}: {p.Value}").OrderBy(s => s, StringComparer.Ordinal)
.ShouldBe(
accepted.Select(p => $"{p.Key}: {p.Value}").OrderBy(s => s, StringComparer.Ordinal),
customMessage:
$"{tool.Name} declares body fields that do not match {verb.ToUpperInvariant()} {tool.PathTemplate}. "
+ "A field the endpoint accepts but the tool omits is silently dropped on a full-replace "
+ "write (#754); a field the tool sends but the endpoint does not accept is rejected; "
+ "a field declared with the wrong type is rejected at the API.");
}
}
// #757, the sibling of the body guard above. Query parameters drift the same way and are WORSE for
// reads: ToolArgumentValidator rejects undeclared arguments, so a parameter the tool omits is not
// merely undocumented, it is unreachable — the caller cannot pass it at all. That is how #616's
// paging omission hard-capped two tools at the first page. This covers EVERY tool, not just the
// write verbs, because the drift that existed when this was written was entirely on reads.
[Test]
public void Every_Tool_Should_Declare_Exactly_Its_OpenApi_Query_Parameters()
{
using JsonDocument spec = LoadOpenApiDocument();
JsonElement paths = spec.RootElement.GetProperty("paths");
// Every tool is covered, so an emptiness guard is enough here — there is no filter to escape.
ToolCatalog.All.Count.ShouldBeGreaterThan(30);
// Accumulate rather than throwing on the first mismatch, so one run reports the WHOLE drift set.
// Failing fast here would hand back one tool at a time and invite fixing them one at a time,
// which is how the #754 twin stayed hidden in the first place.
List<string> drift = [];
foreach (ToolDefinition tool in ToolCatalog.All)
{
paths.TryGetProperty(tool.PathTemplate, out JsonElement pathItem)
.ShouldBeTrue($"{tool.Name}: {tool.PathTemplate} is not in the OpenAPI document");
string verb = tool.HttpMethod.Method.ToLowerInvariant();
pathItem.TryGetProperty(verb, out JsonElement operation)
.ShouldBeTrue($"{tool.Name}: {verb.ToUpperInvariant()} {tool.PathTemplate} is not in the OpenAPI document");
IReadOnlySet<string> declared = tool.QueryParameters ?? new HashSet<string>(StringComparer.Ordinal);
HashSet<string> accepted = QueryParameterNames(operation);
string[] missing = accepted.Except(declared, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
string[] phantom = declared.Except(accepted, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
if (missing.Length > 0 || phantom.Length > 0)
{
drift.Add(
$"{tool.Name} ({verb.ToUpperInvariant()} {tool.PathTemplate}): "
+ $"unreachable={string.Join(",", missing)} phantom={string.Join(",", phantom)}");
}
}
// A parameter the endpoint accepts but the tool omits is UNREACHABLE, not merely undocumented:
// ToolArgumentValidator rejects undeclared arguments, so the caller cannot pass it at all
// (#616 hard-capped two paged tools exactly this way). A phantom is the reverse — the tool
// advertises something the endpoint ignores.
drift.ShouldBeEmpty();
}
private static HashSet<string> QueryParameterNames(JsonElement operation)
{
if (!operation.TryGetProperty("parameters", out JsonElement parameters))
{
return [];
}
return parameters.EnumerateArray()
.Where(p => p.TryGetProperty("in", out JsonElement location)
&& string.Equals(location.GetString(), "query", StringComparison.Ordinal))
.Select(p => p.GetProperty("name").GetString())
.OfType<string>()
.ToHashSet(StringComparer.Ordinal);
}
// The body is every declared argument that is not routed elsewhere — mirroring exactly how
// ErsatzTvApiClient builds the request, so this test cannot disagree with the code it guards.
// DELETE is not compared: ErsatzTvApiClient sets hasBody for POST/PUT/PATCH only, so a body
// argument on a DELETE tool would be silently dropped. No DELETE tool has one today.
private static Dictionary<string, string> DeclaredBodyArguments(ToolDefinition tool)
{
var pathParameters = Regex.Matches(tool.PathTemplate, @"\{([^}]+)\}")
.Select(m => m.Groups[1].Value)
.ToHashSet(StringComparer.Ordinal);
IReadOnlySet<string> queryParameters = tool.QueryParameters ?? new HashSet<string>(StringComparer.Ordinal);
if (!tool.InputSchema.RootElement.TryGetProperty("properties", out JsonElement properties))
{
return [];
}
return properties.EnumerateObject()
.Where(p => !pathParameters.Contains(p.Name)
&& !queryParameters.Contains(p.Name)
&& !string.Equals(p.Name, "ifMatch", StringComparison.Ordinal))
.ToDictionary(p => p.Name, p => DeclaredType(p.Value), StringComparer.Ordinal);
}
// The tool schema's own shape: a plain "type", plus the array element type where there is one.
private static string DeclaredType(JsonElement property)
{
string type = property.GetProperty("type").GetString().ShouldNotBeNull();
return type == "array" && property.TryGetProperty("items", out JsonElement items)
? $"array<{items.GetProperty("type").GetString()}>"
: type;
}
private static Dictionary<string, string> RequestBodyProperties(JsonDocument spec, JsonElement operation, string toolName)
{
// No request body at all (queue/scan POSTs) — the tool must send none either.
if (!operation.TryGetProperty("requestBody", out JsonElement requestBody))
{
return [];
}
JsonElement schema = requestBody
.GetProperty("content")
.GetProperty("application/json")
.GetProperty("schema");
// Every request body in this document is a plain $ref to a component schema. Anything else
// (allOf/inline/oneOf) is a contract shape this guard has not been taught to read, so fail
// loudly rather than comparing against an empty set and reporting a false pass.
schema.TryGetProperty("$ref", out JsonElement reference)
.ShouldBeTrue($"{toolName}: request body schema is not a $ref; teach this test the new shape");
JsonElement schemas = spec.RootElement.GetProperty("components").GetProperty("schemas");
string componentName = reference.GetString().ShouldNotBeNull().Split('/')[^1];
return schemas
.GetProperty(componentName)
.GetProperty("properties")
.EnumerateObject()
.ToDictionary(p => p.Name, p => SpecType(schemas, p.Value, toolName, p.Name), StringComparer.Ordinal);
}
// Normalize the generator's shapes onto the catalog's vocabulary. Two forms appear in this
// document: a nullable type as ["null", T] (the catalog has no nullable notion — optionality is
// carried by `required`), and a $ref to a component, which for the enum fields is a string enum
// and for `logo` is an object.
private static string SpecType(JsonElement schemas, JsonElement property, string toolName, string fieldName)
{
if (property.TryGetProperty("$ref", out JsonElement reference))
{
string componentName = reference.GetString().ShouldNotBeNull().Split('/')[^1];
return SpecType(schemas, schemas.GetProperty(componentName), toolName, fieldName);
}
JsonElement type = property.GetProperty("type");
string[] types = type.ValueKind == JsonValueKind.Array
? type.EnumerateArray().Select(t => t.GetString()).OfType<string>().Where(t => t != "null").ToArray()
: [type.GetString().ShouldNotBeNull()];
// More than one non-null type is a shape this guard has not been taught to read; fail rather
// than picking one and reporting a comparison that means nothing.
types.Length.ShouldBe(1, $"{toolName}.{fieldName}: unexpected OpenAPI type union [{string.Join(", ", types)}]");
// The element schema is resolved through the same normalization: an array's items can itself be
// a $ref to a component (ReplaceRemoteLibraryPreferencesRequest.libraries), which the catalog
// declares as an object array.
return types[0] == "array" && property.TryGetProperty("items", out JsonElement items)
? $"array<{SpecType(schemas, items, toolName, fieldName)}>"
: types[0];
}
private static JsonDocument LoadOpenApiDocument()
{
string path = Path.Combine(AppContext.BaseDirectory, "openapi", "v1.json");
// A missing spec would make every assertion above vacuous, so it is an explicit failure.
File.Exists(path).ShouldBeTrue(
$"OpenAPI document not found at {path}; the test project links it from ErsatzTV/wwwroot/openapi/v1.json");
return JsonDocument.Parse(File.ReadAllText(path));
}
}
+6 -43
View File
@@ -29,32 +29,14 @@ public static class ToolCatalog
Get("ersatztv_list_schedules", "List schedules.", "/api/v1/schedules"),
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
Get("ersatztv_get_schedule_items", "Get a schedule's items. Emits the schedule ETag.", "/api/v1/schedules/{id}/items", IdPath("Schedule id.")),
Get(
"ersatztv_list_playouts",
"List playouts (paged), optionally filtered by channel name.",
"/api/v1/playouts",
[],
[
Str(
"query",
"Optional case-insensitive substring match on the CHANNEL name (not the playout or schedule name); omit for all playouts.",
arg: In.Query),
.. Page()
]),
Get("ersatztv_list_playouts", "List playouts (paged).", "/api/v1/playouts", [], Page()),
Get("ersatztv_get_playout", "Get a playout by id.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
Get(
"ersatztv_get_playout_items",
"Get upcoming items (and unscheduled gaps) for a playout (paged).",
"/api/v1/playouts/{id}/items",
[IdPath("Playout id.")],
[
Bool(
"showFiller",
"Include items whose filler kind is not None (pre/mid/post-roll, tail, fallback, guide-mode, deco); "
+ "default false returns only non-filler items.",
arg: In.Query),
.. Page()
]),
Page()),
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/v1/ffmpeg/profiles"),
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/v1/ffmpeg/profiles/{id}", IdPath("FFmpeg profile id.")),
Get(
@@ -150,8 +132,7 @@ public static class ToolCatalog
[Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
Put(
"ersatztv_update_schedule",
"Update a program schedule. Send the full desired state: every field is applied, so omitting "
+ "padToNearestMinute CLEARS a configured pad (GET the schedule first to copy current values).",
"Update a program schedule's settings.",
"/api/v1/schedules/{id}",
[IdPath("Schedule id."), Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
Delete("ersatztv_delete_schedule", "Delete a program schedule.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
@@ -178,22 +159,9 @@ public static class ToolCatalog
ChannelFields()),
Put(
"ersatztv_update_channel",
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values). "
+ "graphicsElementIds is part of that state: omitting it DETACHES every graphics element (e.g. the On Now/Next overlay), "
+ "so copy it from ersatztv_get_channel unless you mean to clear it.",
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values).",
"/api/v1/channels/{id}",
[
IdPath("Channel id."),
.. ChannelFields(),
// Update-only: UpdateChannelRequest carries GraphicsElementIds, CreateChannelRequest does
// not, so this cannot move into the shared ChannelFields() without making create send an
// unknown property. PUT is a full replace, so omitting it detaches every attached element
// with no error — issue #754.
IntArray(
"graphicsElementIds",
"Ids of the graphics elements attached to the channel. Full replace: omit or send [] to detach all.")
]),
[IdPath("Channel id."), .. ChannelFields()]),
Post(
"ersatztv_reset_channel_playout",
"Queue a rebuild of a channel's playout (202 Accepted; 409 if a build is already running).",
@@ -329,12 +297,7 @@ public static class ToolCatalog
Bool("treatCollectionsAsShows", "Treat collections as shows."),
Bool("shuffleScheduleItems", "Shuffle schedule items."),
Bool("randomStartPoint", "Use a random start point."),
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values)."),
// Both Create- and UpdateScheduleRequest carry this, so it belongs in the shared helper. The
// update PUT is a full replace that writes the value unconditionally, so omitting it used to
// clear a configured pad silently — the same #754 shape as channel graphicsElementIds.
Int("padToNearestMinute", "Pad each item to the nearest N minutes; omit or send null for no padding.")
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values).")
];
// ---- Tool factories ----
@@ -1,92 +0,0 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
/// <summary>
/// #732: the On Now / Next overlay is a default rather than an opt-in, so a channel created after
/// that decision gets the built-in element without the operator toggling anything.
/// </summary>
[TestFixture]
public class CreateChannelDefaultGraphicsElementTests : ChannelHandlerTestBase
{
private CreateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
private async Task<int> SeedBuiltInElement()
{
await using TvContext context = Db.CreateContext();
var element = new GraphicsElement
{
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Kind = GraphicsElementKind.Text
};
context.GraphicsElements.Add(element);
await context.SaveChangesAsync();
return element.Id;
}
private async Task<List<int>> AttachedElementIds(int channelId)
{
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels
.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channelId);
return reloaded.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ToList();
}
[Test]
public async Task Attaches_The_Built_In_Element_To_A_New_Channel()
{
await SeedFFmpegProfile();
int elementId = await SeedBuiltInElement();
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(MakeCreate(), CancellationToken.None);
result.IsRight.ShouldBeTrue();
int channelId = result.RightToSeq().Head().ChannelId;
(await AttachedElementIds(channelId)).ShouldBe([elementId]);
}
[Test]
public async Task Leaves_An_Hls_Direct_Channel_Alone_Because_Nothing_Can_Render_There()
{
await SeedFFmpegProfile();
await SeedBuiltInElement();
Either<BaseError, CreateChannelResult> result = await MakeHandler().Handle(
MakeCreate(streamingMode: StreamingMode.HttpLiveStreamingDirect),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
int channelId = result.RightToSeq().Head().ChannelId;
(await AttachedElementIds(channelId)).ShouldBeEmpty();
}
[Test]
public async Task Creates_The_Channel_Even_When_The_Built_In_Element_Does_Not_Exist()
{
await SeedFFmpegProfile();
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(MakeCreate(), CancellationToken.None);
result.IsRight.ShouldBeTrue();
int channelId = result.RightToSeq().Head().ChannelId;
(await AttachedElementIds(channelId)).ShouldBeEmpty();
}
}
@@ -9,11 +9,9 @@ using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
@@ -464,7 +462,7 @@ public class CreateChannelFromLineupHandlerTests
[Test]
public async Task Should_Reject_WeightedShuffle_For_A_Multi_Item_Lineup()
{
// regression (#70, PR #402): a 2+ entry lineup is persisted as a
// regression (#70, found by adversarial review of PR #402): a 2+ entry lineup is persisted as a
// Playlist, and PlaylistEnumerator has no default arm -- an order it doesn't know leaves the
// enumerator null and the items vanish from the playlist with nothing reported. This handler is the
// THIRD writer of PlaylistItem.PlaybackOrder and was missed when the other two were gated.
@@ -966,66 +964,4 @@ public class CreateChannelFromLineupHandlerTests
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e.Value}"), Right: r => r);
// #732: this is the SPA's primary "Add Channel" flow and the one Auto-Tune bulk-creates through.
// It was the channel-creation site the default attach originally missed, so a channel made here
// would silently never get the overlay once the one-time backfill marker had landed.
[Test]
public async Task Should_Attach_The_Built_In_On_Now_Next_Element()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
int elementId = await SeedBuiltInGraphicsElement();
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
await using TvContext context = _db.CreateContext();
DomainChannel channel = await context.Channels
.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == response.ChannelId);
channel.ChannelGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe([elementId]);
}
[Test]
public async Task Should_Not_Attach_The_Overlay_To_An_Hls_Direct_Channel()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedBuiltInGraphicsElement();
Either<BaseError, CreateChannelFromLineupResponseModel> result = await MakeHandler().Handle(
MakeRequest(advanced: new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder.Shuffle,
StreamingMode: StreamingMode.HttpLiveStreamingDirect)),
CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
await using TvContext context = _db.CreateContext();
DomainChannel channel = await context.Channels
.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == response.ChannelId);
channel.ChannelGraphicsElements.ShouldBeEmpty();
}
private async Task<int> SeedBuiltInGraphicsElement()
{
await using TvContext context = _db.CreateContext();
var element = new GraphicsElement
{
Path = GraphicsElementDefaults.OnNowNextSeededPath,
Kind = GraphicsElementKind.Text
};
context.GraphicsElements.Add(element);
await context.SaveChangesAsync();
return element.Id;
}
}
@@ -1,9 +1,6 @@
using System.Globalization;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
@@ -18,9 +15,6 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
{
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets, RemoteLogoCacher);
private static BaseError LeftOf(Either<BaseError, ChannelViewModel> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private async Task<(int ElementAId, int ElementBId)> SeedGraphicsElements()
{
await using TvContext context = Db.CreateContext();
@@ -31,37 +25,6 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
return (elementA.Id, elementB.Id);
}
private async Task<int> SeedWatermark()
{
await using TvContext context = Db.CreateContext();
var watermark = new ChannelWatermark { Name = "W" };
context.ChannelWatermarks.Add(watermark);
await context.SaveChangesAsync();
return watermark.Id;
}
private async Task<List<int>> SeedGraphicsElements(int count)
{
await using TvContext context = Db.CreateContext();
List<GraphicsElement> elements = Enumerable.Range(0, count)
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
.ToList();
context.GraphicsElements.AddRange(elements);
await context.SaveChangesAsync();
return elements.Select(e => e.Id).ToList();
}
// Replaces the harness the base class built with one whose SaveChangesAsync can be made to fail
// on demand. Foreign keys are off in InMemoryTvContext, so the FK violation a concurrent delete
// really produces cannot be raised by seeding alone.
private async Task<ArmedSaveFailureInterceptor> UseFailingSaveHarness()
{
await Db.DisposeAsync();
var interceptor = new ArmedSaveFailureInterceptor();
Db = await InMemoryTvContext.CreateAsync(interceptor);
return interceptor;
}
[Test]
public async Task Should_Reconcile_GraphicsElement_Join_Add_Then_Remove()
{
@@ -110,199 +73,4 @@ public class UpdateChannelGraphicsElementsTests : ChannelHandlerTestBase
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
}
// #568: an unknown graphicsElementIds entry used to reach ApplyUpdateRequest unchecked, which
// blindly Adds a ChannelGraphicsElement and lets SaveChangesAsync hit
// FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId -> unhandled 500. Removing the
// GraphicsElementIdsMustExist validator alone from UpdateChannelHandler.Validate is row 35 of the
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
[Test]
public async Task Should_Reject_Unknown_GraphicsElementId_With_422_Not_500()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [999]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("999");
// no partial write: the channel keeps no graphics element association
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.ShouldBeEmpty();
}
[Test]
public async Task Should_Reject_When_One_Of_Several_GraphicsElementIds_Is_Unknown()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId, 12345]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("12345");
}
// #568: the id list is client-supplied and was bounded only by the Kestrel body cap, which is a
// transport limit and not a collection limit. The cap is Validators.MaximumIdListCount, shared
// by all three id-list validators; these three tests pin its two edges and the shape that makes
// its placement matter. Removing the cap from Validators.IdsMustExist is row 41 of the mutation
// table in docs/graphics-elements.md.
[Test]
public async Task Should_Accept_Exactly_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount);
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
Channel reloaded = await context.Channels.Include(c => c.ChannelGraphicsElements)
.SingleAsync(c => c.Id == channel.Id);
reloaded.ChannelGraphicsElements.Count.ShouldBe(Validators.MaximumIdListCount);
}
[Test]
public async Task Should_Reject_One_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The cap counts the RAW list, before Distinct: every one of these ids exists and they collapse
// to a single distinct id, so a cap applied after deduplication would accept this request and
// leave the parse and materialization it costs unbounded.
[Test]
public async Task Should_Reject_A_Duplicate_Heavy_List_On_Its_Raw_Count()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
List<int> ids = Enumerable.Repeat(elementAId, Validators.MaximumIdListCount + 1).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain((Validators.MaximumIdListCount + 1).ToString(CultureInfo.InvariantCulture));
error.Value.ShouldNotContain("do not exist");
}
// A 422 that echoes every rejected id turns an oversized request into an oversized response.
// Removing the truncation from Validators.DescribeIds is row 42 of the mutation table.
[Test]
public async Task Should_Cap_The_Ids_Echoed_Back_In_The_Unknown_Id_422()
{
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
List<int> ids = Enumerable.Range(1001, 30).ToList();
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: ids),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("1001");
error.Value.ShouldContain("(and 20 more)");
error.Value.ShouldNotContain("1030");
}
// #568: validation and the write are two statements, so RefreshGraphicsElements can delete a
// validated element in between and hand the join insert the FK violation the validator exists to
// prevent -- the unhandled 500 again. Removing the DbUpdateException catch from
// ApplyUpdateRequestTranslatingLostRace is row 43 of the mutation table.
[Test]
public async Task Should_Translate_An_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
interceptor.SqlBeforeFailing =
$"DELETE FROM GraphicsElement WHERE Id = {elementAId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(elementAId.ToString(CultureInfo.InvariantCulture));
}
// graphicsElementIds is not the only FK this DTO writes, and the recovery path re-asks the whole
// of Validate rather than the graphics-element half precisely so the other FKs are covered:
// WatermarkId is written by the same SaveChangesAsync and loses the same race. Reddens if the
// recheck is narrowed back to GraphicsElementIdsMustExist -- row 45 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Translate_A_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
int watermarkId = await SeedWatermark();
interceptor.SqlBeforeFailing =
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", watermarkId: watermarkId),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("Watermark");
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
}
// The other half of that catch: a DbUpdateException whose cause is NOT a missing graphics
// element is a real fault and must keep its own exception rather than be reported to the client
// as a validation error about ids that are all still present.
[Test]
public async Task Should_Not_Report_An_Unrelated_DbUpdateException_As_A_Graphics_Element_422()
{
ArmedSaveFailureInterceptor interceptor = await UseFailingSaveHarness();
await SeedFFmpegProfile();
Channel channel = await SeedChannel(1, "5");
(int elementAId, _) = await SeedGraphicsElements();
interceptor.Armed = true;
await Should.ThrowAsync<DbUpdateException>(
async () => await MakeHandler().Handle(
MakeUpdate(channel.Id, number: "5", graphicsElementIds: [elementAId]),
CancellationToken.None));
}
}
@@ -5,9 +5,6 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.EntityFrameworkCore;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
@@ -137,30 +134,15 @@ public class FFmpegProfileHandlerTests
persisted.QsvPreferNativeDecoder.ShouldBe(false);
}
// ersatztv#735: the write path used to accept an out-of-range pool size and store the floored
// value instead, so a client that PUT 0 got a 200 and read back 64. it is now rejected, naming
// the bound; FFmpegState still floors at render time for rows that predate this.
[TestCase(0)]
[TestCase(-8)]
[TestCase(63)]
public async Task Create_Should_Reject_QsvExtraHardwareFrames_Below_Minimum(int configured)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, qsvExtraHardwareFrames: configured),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("at least 64");
await using TvContext context = _db.CreateContext();
(await context.FFmpegProfiles.CountAsync()).ShouldBe(0);
}
[TestCase(64)]
[TestCase(128)]
public async Task Create_Should_Store_QsvExtraHardwareFrames_Exactly_As_Submitted(int configured)
// ersatztv#529: a stored 0 reached ffmpeg as hwupload=extra_hw_frames=0, leaving the QSV pool no
// headroom; FFmpegState floors it at render time, and these pin that the stored row converges too
// so the profile never keeps displaying a value the pipeline would override.
[TestCase(0, 64)]
[TestCase(-8, 64)]
[TestCase(63, 64)]
[TestCase(64, 64)]
[TestCase(128, 128)]
public async Task Create_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
@@ -173,15 +155,15 @@ public class FFmpegProfileHandlerTests
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
persisted.QsvExtraHardwareFrames.ShouldBe(configured);
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
}
[TestCase(0)]
[TestCase(-8)]
[TestCase(63)]
public async Task Update_Should_Reject_A_Newly_Submitted_QsvExtraHardwareFrames_Below_Minimum(int configured)
[TestCase(0, 64)]
[TestCase(-8, 64)]
[TestCase(128, 128)]
public async Task Update_Should_Floor_QsvExtraHardwareFrames(int configured, int expected)
{
await SeedProfile(1, qsvExtraHardwareFrames: 128);
await SeedProfile(1);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
@@ -189,59 +171,11 @@ public class FFmpegProfileHandlerTests
MakeUpdate(1, qsvExtraHardwareFrames: configured),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("at least 64");
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.QsvExtraHardwareFrames.ShouldBe(128);
}
// the other half of the same rule: the SPA sends the whole profile back on every edit, so a row
// stored before this validation existed must stay editable over fields the operator did touch.
// an UNCHANGED out-of-range value is written back as-is and floored at render time instead
[Test]
public async Task Update_Should_Accept_An_Unchanged_Legacy_QsvExtraHardwareFrames()
{
await SeedProfile(1, qsvExtraHardwareFrames: 0);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
MakeUpdate(1, qsvExtraHardwareFrames: 0),
CancellationToken.None);
RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.QsvExtraHardwareFrames.ShouldBe(0);
new FFmpegState(
false,
HardwareAccelerationMode.None,
HardwareAccelerationMode.None,
None,
None,
None,
None,
false,
None,
None,
None,
None,
None,
OutputFormatKind.MpegTs,
None,
None,
None,
None,
TimeSpan.Zero,
None,
Optional(persisted.QsvExtraHardwareFrames),
false,
false,
"linear",
false)
.QsvExtraHardwareFrames.ShouldBe(FFmpegState.MinimumQsvExtraHardwareFrames);
persisted.QsvExtraHardwareFrames.ShouldBe(expected);
}
// null means "unconfigured" and FFmpegState already resolves it to the same 64; it must stay
@@ -262,133 +196,6 @@ public class FFmpegProfileHandlerTests
persisted.QsvExtraHardwareFrames.ShouldBeNull();
}
// ersatztv#735: readrate pacing is an operator-tunable bounded field. out of band it is a dead
// channel either way — below realtime the client starves, above the ceiling the input is no
// longer meaningfully paced (which is the unthrottled read #529 measured to write no segments)
[TestCase(0.9)]
[TestCase(0.0)]
[TestCase(2.5)]
public async Task Create_Should_Reject_ReadRate_Outside_Bounds(double configured)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, readRate: configured),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0");
}
[TestCase(0.9)]
[TestCase(10.5)]
public async Task Create_Should_Reject_ReadRateCatchup_Outside_Bounds(double configured)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, readRateCatchup: configured),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Read rate catchup must be between 1.0 and 10.0");
}
// a catchup rate inside its own band can still be at or below the base rate, where it cannot
// let a lagging input recover — the cross-field bound is the one a per-field check cannot see.
// the EQUAL cases matter: zero headroom is functionally no catchup, while still reading as a
// configured one
[TestCase(null, 1.0)]
[TestCase(null, 1.05)]
[TestCase(1.5, 1.2)]
[TestCase(1.5, 1.5)]
[TestCase(2.0, 2.0)]
public async Task Create_Should_Reject_ReadRateCatchup_At_Or_Below_The_ReadRate(double? readRate, double catchup)
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, readRate: readRate, readRateCatchup: catchup),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("must be greater than the read rate");
}
[Test]
public async Task Create_Should_Persist_ReadRate_Pacing()
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result = await handler.Handle(
MakeCreate(1, readRate: 1.2, readRateCatchup: 4.0),
CancellationToken.None);
CreateFFmpegProfileResult created = RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
persisted.ReadRate.ShouldBe(1.2);
persisted.ReadRateCatchup.ShouldBe(4.0);
}
// unset is the default posture and must stay null: FFmpegState resolves null to the values the
// pipeline used before the fields existed, so an untouched profile paces exactly as it did
[Test]
public async Task Create_Should_Leave_Unset_ReadRate_Pacing_Null()
{
await SeedResolution(1);
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, CreateFFmpegProfileResult> result =
await handler.Handle(MakeCreate(1), CancellationToken.None);
CreateFFmpegProfileResult created = RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
persisted.ReadRate.ShouldBeNull();
persisted.ReadRateCatchup.ShouldBeNull();
}
[Test]
public async Task Update_Should_Reject_ReadRate_Outside_Bounds()
{
await SeedProfile(1);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
MakeUpdate(1, readRate: 3.0),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Read rate must be between 1.0 and 2.0");
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.ReadRate.ShouldBeNull();
}
[Test]
public async Task Update_Should_Persist_ReadRate_Pacing()
{
await SeedProfile(1);
await SeedResolution(1);
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
Either<BaseError, UpdateFFmpegProfileResult> result = await handler.Handle(
MakeUpdate(1, readRate: 1.5, readRateCatchup: 8.0),
CancellationToken.None);
RightOf(result);
await using TvContext context = _db.CreateContext();
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
persisted.ReadRate.ShouldBe(1.5);
persisted.ReadRateCatchup.ShouldBe(8.0);
}
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e}"), Right: r => r);
@@ -402,13 +209,12 @@ public class FFmpegProfileHandlerTests
await context.SaveChangesAsync();
}
private async Task SeedProfile(int id, int? qsvExtraHardwareFrames = null)
private async Task SeedProfile(int id)
{
await using TvContext context = _db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile
{
Id = id,
QsvExtraHardwareFrames = qsvExtraHardwareFrames,
Name = "Default",
ThreadCount = 1,
NormalizeAudio = true,
@@ -443,9 +249,7 @@ public class FFmpegProfileHandlerTests
private static CreateFFmpegProfile MakeCreate(
int resolutionId,
bool qsvPreferNativeDecoder = true,
int? qsvExtraHardwareFrames = null,
double? readRate = null,
double? readRateCatchup = null) =>
int? qsvExtraHardwareFrames = null) =>
new(
"Default",
1,
@@ -477,17 +281,13 @@ public class FFmpegProfileHandlerTests
false,
false,
false,
qsvPreferNativeDecoder,
readRate,
readRateCatchup);
qsvPreferNativeDecoder);
private static UpdateFFmpegProfile MakeUpdate(
int id,
int resolutionId = 1,
bool qsvPreferNativeDecoder = true,
int? qsvExtraHardwareFrames = null,
double? readRate = null,
double? readRateCatchup = null) =>
int? qsvExtraHardwareFrames = null) =>
new(
id,
"Default",
@@ -520,7 +320,5 @@ public class FFmpegProfileHandlerTests
false,
false,
false,
qsvPreferNativeDecoder,
readRate,
readRateCatchup);
qsvPreferNativeDecoder);
}
@@ -1,8 +1,6 @@
using ErsatzTV.Application.Graphics;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
@@ -64,97 +62,6 @@ public class GraphicsElementHandlerTests
result.ShouldBeEmpty();
}
// #568: the discriminator used to be Path.GetFileName(e.Path) == OnNowNextFileName, which is
// folder-agnostic -- a user element named exactly "on-now-next.yml" outside the seeded text
// template folder would also report builtIn:true. The row carries the SAME Kind as the real
// seeded element, deliberately: a wrong-kind row here would be rejected by the Kind conjunct
// whatever the path comparison is, so the composite revert (filename AND Kind == Text) would
// pass. Only the PATH half can reject a Text row in another folder, which is what this pins --
// it mirrors the seeder-site Ignores_A_Same_Named_Same_Kind_Element_Outside_The_Seeded_Folder.
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_Same_Filename_Outside_Seeded_Folder_As_BuiltIn()
{
string userElementPath = System.IO.Path.Combine(
"/config/graphics-elements/text/some-subfolder",
GraphicsElementDefaults.OnNowNextFileName);
await SeedElement(1, userElementPath, GraphicsElementKind.Text, string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Mark_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(
1,
GraphicsElementDefaults.OnNowNextSeededPath,
GraphicsElementKind.Text,
GraphicsElementDefaults.OnNowNextName);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeTrue();
}
// #568: identity is GraphicsElementDefaults.IsOnNowNext, an ORDINAL comparison, so a row whose
// path differs from the seeded one only in case is a different element. Reddens if that
// comparison is loosened to OrdinalIgnoreCase. It does NOT pin provider independence: under
// SQLite's BINARY collation a `Where(e => e.Path == ...)` in SQL answers identically, which is
// why the comparison is kept in memory rather than pinned here (see IsOnNowNext's remarks).
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Case_Variant_Of_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(1, CaseVariantOfSeededPath(), GraphicsElementKind.Text, string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
// #568: Kind is part of the built-in element's identity, not a second test the seeder applies
// and the API skips. GetBuiltInElementId refuses a wrong-kind row at the seeded path -- it has
// to, since EnsureBuiltInElementRow asks it whether the Text row it is about to create already
// exists -- so an API that reported the same row as builtIn:true would have the two sites
// disagreeing about one row. Reddens if the Kind conjunct is dropped from
// GraphicsElementDefaults.IsOnNowNext (row 18 of the mutation table in docs/graphics-elements.md).
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Not_Mark_A_Wrong_Kind_Row_At_The_Seeded_Path_As_BuiltIn()
{
await SeedElement(
1,
GraphicsElementDefaults.OnNowNextSeededPath,
GraphicsElementKind.Image,
string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(1);
result[0].BuiltIn.ShouldBeFalse();
}
// The seeded path with only the FILENAME's case changed -- same folder, same spelling.
private static string CaseVariantOfSeededPath() =>
System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(GraphicsElementDefaults.OnNowNextSeededPath)!,
GraphicsElementDefaults.OnNowNextFileName.ToUpperInvariant());
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
{
await using TvContext context = _db.CreateContext();
@@ -1,166 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Paging;
/// <summary>
/// The MediaCards count/page pairs live in two separate repository methods rather than in one
/// handler, so `api.paged-count-matches-page-query` cannot be satisfied structurally there — the
/// two must be kept in agreement and pinned by a test instead. Each case below constructs the
/// divergence the count used to miss and asserts count == pageable rows.
/// Expected values are pinned literals, never re-derived from the method's own predicate.
/// </summary>
[TestFixture]
public class MediaCardsCountMatchesPageTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private TelevisionRepository TelevisionRepo =>
new(_db.Factory, NullLogger<TelevisionRepository>.Instance);
[Test]
public async Task GetSeasonCount_Should_Expand_To_The_Same_Shows_GetPagedSeasons_Pages()
{
// the same show present in two libraries: same Title+Year, different Show rows.
// GetPagedSeasons pages the union (2 + 3), so the count must be 5, not 2.
await using (TvContext context = _db.CreateContext())
{
context.Shows.Add(new Show { Id = 1 });
context.Shows.Add(new Show { Id = 2 });
context.ShowMetadata.Add(new ShowMetadata { Id = 201, ShowId = 1, Title = "Star Trek", Year = 1966 });
context.ShowMetadata.Add(new ShowMetadata { Id = 202, ShowId = 2, Title = "Star Trek", Year = 1966 });
// an unrelated show that must NOT be swept in
context.Shows.Add(new Show { Id = 3 });
context.ShowMetadata.Add(new ShowMetadata { Id = 203, ShowId = 3, Title = "Star Trek", Year = 1987 });
context.Seasons.Add(new Season { Id = 19, ShowId = 3, SeasonNumber = 1 });
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
context.Seasons.Add(new Season { Id = 12, ShowId = 1, SeasonNumber = 2 });
context.Seasons.Add(new Season { Id = 13, ShowId = 2, SeasonNumber = 1 });
context.Seasons.Add(new Season { Id = 14, ShowId = 2, SeasonNumber = 2 });
context.Seasons.Add(new Season { Id = 15, ShowId = 2, SeasonNumber = 3 });
await context.SaveChangesAsync();
}
int count = await TelevisionRepo.GetSeasonCount(1);
List<Season> page = await TelevisionRepo.GetPagedSeasons(1, 1, 50, CancellationToken.None);
count.ShouldBe(5);
page.Count.ShouldBe(5);
count.ShouldBe(page.Count);
// pin WHICH rows, not just how many — a count and a page can agree on the wrong set
page.Select(s => s.Id).OrderBy(id => id).ShouldBe([11, 12, 13, 14, 15]);
}
[Test]
public async Task GetSeasonCount_Should_Be_Zero_When_The_Show_Has_No_Metadata()
{
// GetPagedSeasons returns nothing without a ShowMetadata row to expand from, so the count
// must agree rather than reporting the show's seasons
await using (TvContext context = _db.CreateContext())
{
context.Shows.Add(new Show { Id = 1 });
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
await context.SaveChangesAsync();
}
int count = await TelevisionRepo.GetSeasonCount(1);
List<Season> page = await TelevisionRepo.GetPagedSeasons(1, 1, 50, CancellationToken.None);
count.ShouldBe(0);
page.ShouldBeEmpty();
}
[Test]
public async Task GetEpisodeCount_Should_Count_Episodes_That_Have_Metadata()
{
// 3 episodes, one of which lost its metadata row to a scanner failure. GetPagedEpisodes
// pages EpisodeMetadata, so only 2 are reachable and the count must say 2.
await using (TvContext context = _db.CreateContext())
{
// GetPagedEpisodes's include chain reaches Episode -> Season -> Show through REQUIRED
// reference navs, which EF emits as INNER JOINs, so a missing Season or Show row drops
// every row and would make the page 0 for a reason unrelated to the count under test.
// The ShowMetadata leg is a COLLECTION nav (LEFT JOIN) and drops nothing — the row below
// is incidental, seeded only to keep the graph realistic.
context.Shows.Add(new Show { Id = 1 });
context.ShowMetadata.Add(new ShowMetadata { Id = 201, ShowId = 1, Title = "Show", Year = 2000 });
context.Seasons.Add(new Season { Id = 11, ShowId = 1, SeasonNumber = 1 });
for (var i = 21; i <= 23; i++)
{
context.Episodes.Add(new Episode { Id = i, SeasonId = 11 });
}
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 221, EpisodeId = 21, EpisodeNumber = 1 });
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 222, EpisodeId = 22, EpisodeNumber = 2 });
// an episode in a different season must not be swept in
context.Seasons.Add(new Season { Id = 12, ShowId = 1, SeasonNumber = 2 });
context.Episodes.Add(new Episode { Id = 29, SeasonId = 12 });
context.EpisodeMetadata.Add(new EpisodeMetadata { Id = 229, EpisodeId = 29, EpisodeNumber = 1 });
await context.SaveChangesAsync();
}
int count = await TelevisionRepo.GetEpisodeCount(11);
List<EpisodeMetadata> page = await TelevisionRepo.GetPagedEpisodes(11, 1, 50);
count.ShouldBe(2);
page.Count.ShouldBe(2);
count.ShouldBe(page.Count);
// the two episodes WITH metadata, and not the other season's
page.Select(em => em.EpisodeId).OrderBy(id => id).ShouldBe([21, 22]);
}
[Test]
public async Task GetMusicVideoCount_Should_Count_Music_Videos_That_Have_Metadata()
{
// 3 music videos for the artist, one without a metadata row; GetPagedMusicVideos pages
// MusicVideoMetadata, so the count must be 2
await using (TvContext context = _db.CreateContext())
{
context.Artists.Add(new Artist { Id = 41 });
for (var i = 31; i <= 33; i++)
{
context.MusicVideos.Add(new MusicVideo { Id = i, ArtistId = 41 });
}
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 231, MusicVideoId = 31, Title = "A" });
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 232, MusicVideoId = 32, Title = "B" });
// another artist's video must not be swept in
context.Artists.Add(new Artist { Id = 42 });
context.MusicVideos.Add(new MusicVideo { Id = 39, ArtistId = 42 });
context.MusicVideoMetadata.Add(new MusicVideoMetadata { Id = 239, MusicVideoId = 39, Title = "C" });
await context.SaveChangesAsync();
}
var repo = new MusicVideoRepository(_db.Factory);
int count = await repo.GetMusicVideoCount(41);
List<MusicVideoMetadata> page = await repo.GetPagedMusicVideos(41, 1, 50);
count.ShouldBe(2);
page.Count.ShouldBe(2);
count.ShouldBe(page.Count);
// this artist's two videos with metadata, and not the other artist's
page.Select(m => m.Title).OrderBy(x => x).ShouldBe(["A", "B"]);
}
}
@@ -1,286 +0,0 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Tests.Application.Paging;
/// <summary>
/// Every paged handler whose page query applies a filter must compute its TotalCount from the SAME
/// query, or a filtered page reports the unfiltered total and the SPA paginates to pages that can
/// never contain anything (issues #690, #758).
/// Expected counts here are PINNED LITERALS derived from the seeded set by hand — never recomputed
/// by re-applying the handler's own predicate, which would pass whatever the handler happens to do.
/// </summary>
[TestFixture]
public class PagedQueryTotalCountTests
{
// 5 seeded rows, of which exactly these 2 contain "Alpha"
private const int SeededRows = 5;
private const int MatchingAlpha = 2;
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private static readonly string[] Names =
["Alpha One", "Beta", "Alpha Two", "Gamma", "Delta"];
[Test]
public async Task GetPagedCollections_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
}
await context.SaveChangesAsync();
}
var handler = new GetPagedCollectionsHandler(_db.Factory);
PagedMediaCollectionsViewModel unfiltered =
await handler.Handle(new GetPagedCollections(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedMediaCollectionsViewModel filtered =
await handler.Handle(new GetPagedCollections("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(c => c.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedMultiCollections_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.MultiCollections.Add(new MultiCollection { Id = i + 1, Name = Names[i] });
}
// channel-owned rows are excluded from BOTH the page and the count, filter or no filter
context.MultiCollections.Add(
new MultiCollection { Id = 99, Name = "Alpha Owned", OwnedByChannelId = 7 });
await context.SaveChangesAsync();
}
var handler = new GetPagedMultiCollectionsHandler(_db.Factory);
PagedMultiCollectionsViewModel unfiltered =
await handler.Handle(new GetPagedMultiCollections(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedMultiCollectionsViewModel filtered =
await handler.Handle(new GetPagedMultiCollections("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(mc => mc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedSmartCollections_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.SmartCollections.Add(
new SmartCollection { Id = i + 1, Name = Names[i], Query = "tag:family" });
}
context.SmartCollections.Add(
new SmartCollection
{
Id = 99,
Name = "Alpha Owned",
Query = "tag:family",
OwnedByChannelId = 7
});
await context.SaveChangesAsync();
}
var handler = new GetPagedSmartCollectionsHandler(_db.Factory);
PagedSmartCollectionsViewModel unfiltered =
await handler.Handle(new GetPagedSmartCollections(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedSmartCollectionsViewModel filtered =
await handler.Handle(new GetPagedSmartCollections("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(sc => sc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedRerunCollections_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.RerunCollections.Add(
new RerunCollection
{
Id = i + 1,
Name = Names[i],
CollectionType = CollectionType.Collection,
CollectionId = i + 1
});
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
}
await context.SaveChangesAsync();
}
var handler = new GetPagedRerunCollectionsHandler(_db.Factory);
PagedRerunCollectionsViewModel unfiltered =
await handler.Handle(new GetPagedRerunCollections(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedRerunCollectionsViewModel filtered =
await handler.Handle(new GetPagedRerunCollections("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(rc => rc.Name).ShouldBe(["Alpha One", "Alpha Two"]);
// the selection graph still loads for the page — moving IncludeSelectionDetails off the
// counted query must not stop the page from projecting it (issue #671)
filtered.Page.Select(rc => rc.Collection?.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedProgramSchedules_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.ProgramSchedules.Add(new ProgramSchedule { Id = i + 1, Name = Names[i] });
}
await context.SaveChangesAsync();
}
var handler = new GetPagedProgramSchedulesHandler(_db.Factory);
PagedProgramSchedulesViewModel unfiltered =
await handler.Handle(new GetPagedProgramSchedules(string.Empty, 0, 10), CancellationToken.None);
unfiltered.TotalCount.ShouldBe(SeededRows);
PagedProgramSchedulesViewModel filtered =
await handler.Handle(new GetPagedProgramSchedules("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(ps => ps.Name).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task GetPagedPlayouts_Filtered_Count_Should_Match_Filter()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.Channels.Add(NewChannel(i + 1, $"{i + 1}", Names[i]));
context.Playouts.Add(
new Playout
{
Id = i + 1,
ChannelId = i + 1,
ScheduleKind = PlayoutScheduleKind.Classic
});
}
// a playout whose channel row does not exist: excluded from the page by the
// `Channel != null` filter, so it must be excluded from the count too
context.Playouts.Add(
new Playout { Id = 99, ChannelId = 4242, ScheduleKind = PlayoutScheduleKind.Classic });
await context.SaveChangesAsync();
}
var handler = new GetPagedPlayoutsHandler(_db.Factory);
PagedPlayoutsViewModel unfiltered =
await handler.Handle(new GetPagedPlayouts(string.Empty, 0, 10), CancellationToken.None);
// 5, NOT 6 — the orphaned playout is filtered out of the page, so it is not part of the total
unfiltered.TotalCount.ShouldBe(SeededRows);
unfiltered.Page.Count.ShouldBe(SeededRows);
PagedPlayoutsViewModel filtered =
await handler.Handle(new GetPagedPlayouts("Alpha", 0, 10), CancellationToken.None);
filtered.TotalCount.ShouldBe(MatchingAlpha);
filtered.Page.Select(p => p.ChannelName).ShouldBe(["Alpha One", "Alpha Two"]);
}
[Test]
public async Task Filtered_Count_Should_Drive_A_Second_Page()
{
await using (TvContext context = _db.CreateContext())
{
for (var i = 0; i < Names.Length; i++)
{
context.Collections.Add(new Collection { Id = i + 1, Name = Names[i], MediaItems = [] });
}
await context.SaveChangesAsync();
}
var handler = new GetPagedCollectionsHandler(_db.Factory);
// pageSize 1 over the 2 matching rows: this is the property the issues are about — the count
// is what tells the client a SECOND page exists, and each page holds exactly its own row
PagedMediaCollectionsViewModel first =
await handler.Handle(new GetPagedCollections("Alpha", 0, 1), CancellationToken.None);
first.TotalCount.ShouldBe(MatchingAlpha);
first.Page.Select(c => c.Name).ShouldBe(["Alpha One"]);
PagedMediaCollectionsViewModel second =
await handler.Handle(new GetPagedCollections("Alpha", 1, 1), CancellationToken.None);
second.TotalCount.ShouldBe(MatchingAlpha);
second.Page.Select(c => c.Name).ShouldBe(["Alpha Two"]);
// and the page AFTER the last matching row is empty — with the pre-fix count of 5 the client
// would have been told to fetch three more pages that can never contain anything
PagedMediaCollectionsViewModel past =
await handler.Handle(new GetPagedCollections("Alpha", 2, 1), CancellationToken.None);
past.TotalCount.ShouldBe(MatchingAlpha);
past.Page.ShouldBeEmpty();
}
private static DomainChannel NewChannel(int id, string number, string name) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
SortNumber = id,
Name = name,
Group = "ErsatzTV",
Categories = string.Empty,
FFmpegProfileId = 1,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
StreamingMode = StreamingMode.TransportStreamHybrid,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous
};
}
@@ -4,7 +4,6 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -122,200 +121,13 @@ public class PlayoutHandlerTests
LeftOf(result).Value.ShouldContain("must not be empty");
}
// ---- #880 empty recurrence sets ----
[Test]
public async Task ReplaceAlternateSchedules_Should_Reject_A_Newly_Empty_Recurrence_On_A_Stored_Item()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
// TWO items: index 1 is the catch-all (highest index), so index 0 is a real alternate whose
// recurrence IS stored. The empty set goes on THAT one.
ReplacePlayoutAlternateSchedule empty = AltItem(index: 0) with { DaysOfWeek = [] };
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, [empty, AltItem(index: 1)]),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("[DaysOfWeek]");
LeftOf(result).Value.ShouldContain("no day of the week");
// rejected BEFORE any mutation -- the version bump is the observable proof nothing was written
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
// The catch-all's recurrence is discarded by the handler (only its ProgramScheduleId is used), so an
// empty set there cannot make anything "never apply". Rejecting it would state a reason that is FALSE
// for that item, which is why the check walks `incoming` rather than every submitted item.
[Test]
public async Task ReplaceAlternateSchedules_Should_Allow_An_Empty_Recurrence_On_The_CatchAll_Item()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutAlternateScheduleItemsHandler(
_db.Factory,
_worker,
NullLogger<ReplacePlayoutAlternateScheduleItemsHandler>.Instance);
// a single item IS the catch-all
ReplacePlayoutAlternateSchedule catchAll = AltItem(index: 0) with { DaysOfWeek = [], MonthsOfYear = [] };
Either<BaseError, Unit> result = await handler.Handle(
new ReplacePlayoutAlternateScheduleItems(1, [catchAll]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadPlayoutVersion(1)).ShouldBe(2);
}
[Test]
public async Task ReplaceTemplates_Should_Reject_A_Newly_Empty_Recurrence()
{
await SeedPlayout(1, version: 1);
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
ReplacePlayoutTemplate empty = TemplateItem() with { DaysOfMonth = [] };
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [empty]),
CancellationToken.None);
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
.Value.ShouldContain("[DaysOfMonth]");
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
// `api.ffmpeg-profile-numeric-bounds`: reject a NEWLY submitted bad value, not an UNCHANGED one the row
// already holds. Both PUT paths are whole-list replaces, so without this a single pre-existing empty row
// would make every OTHER item in the playout uneditable.
[Test]
public async Task ReplaceTemplates_Should_Allow_An_UNCHANGED_Empty_Recurrence_That_Is_Already_Stored()
{
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
// same row, same empty DaysOfWeek -- an edit to some OTHER field on the same list
ReplacePlayoutTemplate unchanged = TemplateItem() with { Id = templateItemId, DaysOfWeek = [] };
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [unchanged]),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
(await ReadPlayoutVersion(1)).ShouldBe(2);
}
// ... and the complement: the SAME stored row rejects a DIFFERENT field being newly emptied, so the
// exemption is per-field rather than "this row is grandfathered".
[Test]
public async Task ReplaceTemplates_Should_Still_Reject_A_Different_Field_Newly_Emptied_On_A_Stored_Row()
{
int templateItemId = await SeedPlayoutWithEmptyTemplateRecurrence();
var handler = new ReplacePlayoutTemplateItemsHandler(
_db.Factory,
NullLogger<ReplacePlayoutTemplateItemsHandler>.Instance);
// DaysOfWeek is the stored-empty one; MonthsOfYear is stored FULL, so emptying it is new
ReplacePlayoutTemplate item = TemplateItem() with
{
Id = templateItemId,
DaysOfWeek = [],
MonthsOfYear = []
};
Option<BaseError> result = await handler.Handle(
new ReplacePlayoutTemplateItems(1, [item]),
CancellationToken.None);
result.IfNone(() => throw new AssertionException("Expected a Some(error)"))
.Value.ShouldContain("[MonthsOfYear]");
(await ReadPlayoutVersion(1)).ShouldBe(1);
}
private async Task<int> SeedPlayoutWithEmptyTemplateRecurrence()
{
await using TvContext context = _db.CreateContext();
// The handler loads templates with `.Include(p => p.Templates).ThenInclude(t => t.Template)`, and
// that navigation is required -- so a PlayoutTemplate whose Template row does not exist is joined
// OUT and never reaches `existing`. Without seeding this, the stored row is invisible, the
// exemption cannot match, and the test fails for a reason that has nothing to do with the rule.
context.TemplateGroups.Add(new TemplateGroup { Id = 5, Name = "Group", Templates = [] });
context.Templates.Add(new Template { Id = 20, TemplateGroupId = 5, Name = "Template 20", Items = [] });
await context.SaveChangesAsync();
var template = new PlayoutTemplate
{
Index = 0,
TemplateId = 20,
DaysOfWeek = [],
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
LimitToDateRange = false,
StartMonth = 1,
StartDay = 1,
EndMonth = 12,
EndDay = 31
};
context.Playouts.Add(
new Playout
{
Id = 1,
ChannelId = 1,
ProgramScheduleId = 10,
Version = 1,
Items = [],
ProgramScheduleAlternates = [],
Templates = [template]
});
await context.SaveChangesAsync();
return template.Id;
}
// ---- #253 optimistic concurrency (alternate schedules #7 + templates #8, shared Playout.Version) ----
// Recurrence sets are UNRESTRICTED here, not empty (#880): an empty set now means "matches no date"
// and is rejected on any item whose recurrence is stored, so an empty fixture would make these
// concurrency tests measure the recurrence guard instead of the version check.
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10, int index = 0) =>
new(
0,
index,
programScheduleId,
AlternateScheduleSelector.AllDaysOfWeek(),
AlternateScheduleSelector.AllDaysOfMonth(),
AlternateScheduleSelector.AllMonthsOfYear(),
false,
1,
1,
null,
12,
31,
null);
private static ReplacePlayoutAlternateSchedule AltItem(int programScheduleId = 10) =>
new(0, 0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
private static ReplacePlayoutTemplate TemplateItem(int templateId = 20) =>
new(
0,
0,
templateId,
null,
AlternateScheduleSelector.AllDaysOfWeek(),
AlternateScheduleSelector.AllDaysOfMonth(),
AlternateScheduleSelector.AllMonthsOfYear(),
false,
1,
1,
null,
12,
31,
null);
new(0, 0, templateId, null, [], [], [], false, 1, 1, null, 12, 31, null);
private async Task SeedPlayout(int id, int version, int? programScheduleId = 10)
{
@@ -1,5 +1,3 @@
using System.Collections;
using System.Reflection;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
@@ -67,8 +65,7 @@ public class ScheduleItemResponseRoundTripTests
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None);
replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
// GET again → envelope B; A and B must be semantically identical INCLUDING row ids
// the handler reconciles by id and updates in place, it does not regenerate rows.
// GET again → envelope B; A and B must be semantically identical (ignoring regenerated row ids).
ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId);
envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count);
@@ -343,97 +340,62 @@ public class ScheduleItemResponseRoundTripTests
r.PreferredSubtitleLanguageCode,
r.SubtitleMode);
// ersatztv#779 (detector G): the compared field list is DERIVED from the DTO by reflection,
// never hand-copied. The previous version was a hand-written run of `b.X.ShouldBe(a.X)` lines.
// It was COMPLETE on the day it was written — every property but Id — and had no way
// to report the day it stopped being: a field added to ScheduleItemResponseModel simply went
// uncompared, and this "lossless round-trip" test kept passing while the round trip silently
// dropped it. That is #754's mechanism exactly (a hand-maintained mirror drifting from a
// 28-property DTO by one field, HTTP 200, no error), one altitude up — in the very test whose
// job is to catch losses.
//
// Properties deliberately NOT compared. The set is EMPTY, and that is a finding rather than an
// oversight. The first version exempted Id on the reasoning that "the PUT replaces the item
// set, so B's rows are new rows with new ids". ReplaceProgramScheduleItemsHandler does not do
// that for this fixture's payload: it forwards every Id, takes the id-based reconcile, and
// updates rows in place. So Id compares equal, and the exemption was unnecessary.
//
// Two mutations of this fixture, both EXECUTED — recorded as results, with no account of why:
// a confident mechanism for this observation is easy to get wrong, and two independent ones
// were each contradicted by the code:
//
// ToReplaceCommand passes `null` for EVERY id -> test stays GREEN
// ToReplaceCommand passes `null` for index 0 only -> test goes RED, "Id differs"
//
// So the Id comparison does discriminate; it is not decorative. What it is NOT is a substitute
// for ReplaceProgramScheduleItemsReconcileTests, whose
// Reorder_ById_Should_Move_State_With_The_Logical_Item_Not_The_Slot and
// Insert_ById_In_Middle_Should_Keep_Existing_Ids_And_State pass real ids and pin that state
// moves with the logical item rather than the slot. Those are the #252 tests; this is a
// round-trip check that happens to also notice a lost row.
//
// Any name added here must still exist on ScheduleItemResponseModel (asserted below), so
// renaming a field cannot leave a stale exemption silently exempting nothing.
private static readonly System.Collections.Generic.HashSet<string> RoundTripExemptProperties =
new(StringComparer.Ordinal);
private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
{
PropertyInfo[] properties = typeof(ScheduleItemResponseModel)
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
// A stale exemption is a silent hole: it would exempt nothing while reading as a reviewed
// decision, and the property it once named would be compared or not by accident.
foreach (string exempt in RoundTripExemptProperties)
{
properties.Any(p => p.Name == exempt).ShouldBeTrue(
$"'{exempt}' is exempted from the round-trip comparison but is not a property of "
+ $"{nameof(ScheduleItemResponseModel)}; remove the stale exemption or fix the name.");
}
var compared = 0;
foreach (PropertyInfo property in properties)
{
if (RoundTripExemptProperties.Contains(property.Name))
{
continue;
}
object? expected = property.GetValue(a);
object? actual = property.GetValue(b);
if (expected is IEnumerable expectedSequence and not string)
{
// Collection-valued members (WatermarkIds, Watermarks, GraphicsElementIds,
// GraphicsElements). The elementwise walk still delegates to each element's Equals,
// so it is value equality only because those elements are records
// (NamedIdResponseModel) or value types (the int id lists); a future element type that
// is neither would silently be compared by REFERENCE inside this loop. It is also order-sensitive, which is
// correct for these ordered lists but would be wrong for an unordered type such as
// a dictionary-valued property.
actual.ShouldNotBeNull($"{property.Name} was null on the round-tripped item");
var actualSequence = (IEnumerable)actual;
actualSequence.Cast<object?>().ToList()
.ShouldBe(expectedSequence.Cast<object?>().ToList(), $"{property.Name} differs");
}
else
{
actual.ShouldBe(expected, $"{property.Name} differs");
}
compared++;
}
// Anti-vacuity, as a PIN rather than a floor. A `>=` floor lets properties vanish silently,
// which is the one-sided version of the both-directions rule this test is meant to embody.
// Comparing against the reflected count minus exemptions would be tautological — both sides
// come from the same reflection — so the expected number is written down and must be
// bumped deliberately in the same change that adds or removes a DTO field.
const int expectedComparedProperties = 55;
compared.ShouldBe(
expectedComparedProperties,
$"{compared} properties were compared, expected {expectedComparedProperties}; update "
+ "this pin in the same change that alters ScheduleItemResponseModel's field list");
b.Index.ShouldBe(a.Index);
b.StartType.ShouldBe(a.StartType);
b.StartTime.ShouldBe(a.StartTime);
b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior);
b.PlayoutMode.ShouldBe(a.PlayoutMode);
b.CollectionType.ShouldBe(a.CollectionType);
b.CollectionId.ShouldBe(a.CollectionId);
b.MultiCollectionId.ShouldBe(a.MultiCollectionId);
b.SmartCollectionId.ShouldBe(a.SmartCollectionId);
b.RerunCollectionId.ShouldBe(a.RerunCollectionId);
b.MediaItemId.ShouldBe(a.MediaItemId);
b.PlaylistId.ShouldBe(a.PlaylistId);
b.SearchTitle.ShouldBe(a.SearchTitle);
b.SearchQuery.ShouldBe(a.SearchQuery);
b.PlaybackOrder.ShouldBe(a.PlaybackOrder);
b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy);
b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups);
b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems);
b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize);
b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode);
b.MultipleMode.ShouldBe(a.MultipleMode);
b.MultipleCount.ShouldBe(a.MultipleCount);
b.PlayoutDuration.ShouldBe(a.PlayoutDuration);
b.TailMode.ShouldBe(a.TailMode);
b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts);
b.CustomTitle.ShouldBe(a.CustomTitle);
b.GuideMode.ShouldBe(a.GuideMode);
b.PreRollFillerId.ShouldBe(a.PreRollFillerId);
b.MidRollFillerId.ShouldBe(a.MidRollFillerId);
b.PostRollFillerId.ShouldBe(a.PostRollFillerId);
b.TailFillerId.ShouldBe(a.TailFillerId);
b.FallbackFillerId.ShouldBe(a.FallbackFillerId);
b.WatermarkIds.ShouldBe(a.WatermarkIds);
b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds);
b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode);
b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle);
b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode);
b.SubtitleMode.ShouldBe(a.SubtitleMode);
b.CollectionName.ShouldBe(a.CollectionName);
b.MultiCollectionName.ShouldBe(a.MultiCollectionName);
b.SmartCollectionName.ShouldBe(a.SmartCollectionName);
b.RerunCollectionName.ShouldBe(a.RerunCollectionName);
b.PlaylistName.ShouldBe(a.PlaylistName);
b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId);
b.MediaItemName.ShouldBe(a.MediaItemName);
b.PreRollFillerName.ShouldBe(a.PreRollFillerName);
b.MidRollFillerName.ShouldBe(a.MidRollFillerName);
b.PostRollFillerName.ShouldBe(a.PostRollFillerName);
b.TailFillerName.ShouldBe(a.TailFillerName);
b.FallbackFillerName.ShouldBe(a.FallbackFillerName);
b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name)));
b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name)));
b.Name.ShouldBe(a.Name);
b.DurationEstimate.ShouldBe(a.DurationEstimate);
}
private async Task<int> SeedScheduleAndReferences(bool shuffleScheduleItems)
@@ -1,151 +0,0 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
using Shouldly;
using PlayoutsMapper = ErsatzTV.Application.Playouts.Mapper;
using SchedulingMapper = ErsatzTV.Application.Scheduling.Mapper;
namespace ErsatzTV.Tests.Application.Scheduling;
/// <summary>
/// ersatztv#823. Guarding <see cref="AlternateScheduleSelector" /> alone would have left the OTHER read
/// of the same six columns unguarded — the entity→view-model mappers, which feed
/// <c>PlayoutController</c>'s response models and therefore the SPA.
/// <para>
/// Two things break without the guard, and neither is a C# exception, which is why the selector
/// tests cannot see them. <c>web/src/screens/PlayoutScheduleEditors.tsx</c> spreads the collection
/// (<c>daysOfMonth: [...template.daysOfMonth]</c>) and throws <c>TypeError: not iterable</c> on a
/// JSON <c>null</c>; and <c>web/src/screens/playoutTemplateCalendar.ts</c>'s <c>appliesToDate</c> —
/// an exact TypeScript port of <see cref="AlternateScheduleSelector.GetScheduleForDate{T}" /> —
/// calls <c>.includes</c> on it.
/// </para>
/// <para>
/// So the mappers substitute the SAME unrestricted defaults the selector reads. That agreement is
/// the point: a DTO that said "empty" while the selector scheduled "unrestricted" would make the
/// preview calendar disagree with the playout it is previewing.
/// </para>
/// </summary>
[TestFixture]
public class RecurrenceLimitsMapperNullTests
{
private static Template MinimalTemplate() =>
new()
{
Id = 7,
Name = "T",
TemplateGroupId = 1,
TemplateGroup = new TemplateGroup { Name = "G" },
Items = []
};
[Test]
public void ProgramScheduleAlternate_Null_Collections_Map_To_Unrestricted()
{
var alternate = new ProgramScheduleAlternate
{
Id = 1,
Index = 0,
ProgramScheduleId = 2,
DaysOfWeek = null!,
DaysOfMonth = null!,
MonthsOfYear = null!
};
PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate);
vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek());
vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth());
vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear());
// Never assigned back: these are single-column primitive collections, so writing the guard onto a
// tracked entity would persist the substituted set over the NULL
// (media.nullable-primitive-collection-mutation).
alternate.DaysOfWeek.ShouldBeNull();
alternate.DaysOfMonth.ShouldBeNull();
alternate.MonthsOfYear.ShouldBeNull();
}
[Test]
public void PlayoutTemplate_Null_Collections_Map_To_Unrestricted()
{
var template = new PlayoutTemplate
{
Id = 1,
Index = 0,
Template = MinimalTemplate(),
DecoTemplate = null,
DaysOfWeek = null!,
DaysOfMonth = null!,
MonthsOfYear = null!
};
PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template);
vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek());
vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth());
vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear());
template.DaysOfWeek.ShouldBeNull();
template.DaysOfMonth.ShouldBeNull();
template.MonthsOfYear.ShouldBeNull();
}
/// <summary>
/// An explicitly EMPTY collection is a recorded restriction of no days and must survive the mapper
/// unchanged. Without this, a guard written as "empty or null becomes All*" would pass the two tests
/// above while silently rewriting real user data on the way out.
/// <para>
/// There is one of these per MAPPER, not one in total. The two overloads are byte-identical
/// triples in different files, so a defensive edit to one alone is exactly the "one helper, two
/// callers" shape this repo has been bitten by: covering only the Playouts mapper would leave
/// the PlayoutTemplate one free to acquire an `empty-or-null` guard with the suite still green.
/// </para>
/// </summary>
[Test]
public void An_Explicitly_Empty_Collection_Is_Not_Rewritten_By_The_PlayoutTemplate_Mapper()
{
var template = new PlayoutTemplate
{
Id = 1,
Index = 0,
Template = MinimalTemplate(),
DecoTemplate = null,
DaysOfWeek = [],
DaysOfMonth = [],
MonthsOfYear = []
};
PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template);
vm.DaysOfWeek.ShouldBeEmpty();
vm.DaysOfMonth.ShouldBeEmpty();
vm.MonthsOfYear.ShouldBeEmpty();
}
/// <summary>
/// The <c>ProgramScheduleAlternate</c> half of the same pair — see the PlayoutTemplate one above
/// for why there is one per MAPPER rather than one in total.
/// </summary>
[Test]
public void An_Explicitly_Empty_Collection_Is_Not_Rewritten()
{
var alternate = new ProgramScheduleAlternate
{
Id = 1,
Index = 0,
ProgramScheduleId = 2,
DaysOfWeek = [],
DaysOfMonth = [],
MonthsOfYear = []
};
PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate);
vm.DaysOfWeek.ShouldBeEmpty();
vm.DaysOfMonth.ShouldBeEmpty();
vm.MonthsOfYear.ShouldBeEmpty();
}
}
@@ -1,416 +0,0 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Scheduling;
/// <summary>
/// #568: the same full-replace-DTO FK hardening applied to UpdateChannelHandler's
/// graphicsElementIds also closes the identical twin defect in UpdateDecoHandler -- both
/// graphicsElementIds and watermarkIds are top-level ReplaceDecoRequest fields (not the "deep FK
/// ids nested inside item-list request bodies" carve-out in api-conventions.md), and the
/// reconcile in ApplyUpdateRequest blindly Adds a join row for every incoming id, so an unknown
/// id used to hit the FK constraint at SaveChangesAsync and surface as an unhandled 500.
/// </summary>
[TestFixture]
public class UpdateDecoGraphicsElementsTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _channel = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_channel = Substitute.For<ChannelWriter<IBackgroundServiceRequest>>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private static bool IsLeft<T>(Either<BaseError, T> result) => result.Match(Right: _ => false, Left: _ => true);
private async Task SeedDeco()
{
await using TvContext context = _db.CreateContext();
context.Decos.Add(
new Deco
{
Id = 1,
DecoGroupId = 1,
Name = "D",
BreakContent = [],
DecoWatermarks = [],
DecoGraphicsElements = []
});
await context.SaveChangesAsync();
}
private static UpdateDeco MakeUpdate(
List<int> graphicsElementIds = null,
List<int> watermarkIds = null,
DecoMode? graphicsElementsMode = null,
DecoMode? watermarkMode = null) =>
new(
1,
1,
"D",
watermarkMode ?? DecoMode.Inherit,
watermarkIds ?? [],
false,
graphicsElementsMode ?? (graphicsElementIds is null ? DecoMode.Inherit : DecoMode.Override),
graphicsElementIds ?? [],
false,
DecoMode.Inherit,
[],
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null,
false,
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null);
private async Task<int> SeedGraphicsElement()
{
await using TvContext context = _db.CreateContext();
var element = new GraphicsElement { Path = "element-a.yml" };
context.GraphicsElements.Add(element);
await context.SaveChangesAsync();
return element.Id;
}
private async Task<List<int>> SeedGraphicsElements(int count)
{
await using TvContext context = _db.CreateContext();
List<GraphicsElement> elements = Enumerable.Range(0, count)
.Select(i => new GraphicsElement { Path = $"element-{i}.yml" })
.ToList();
context.GraphicsElements.AddRange(elements);
await context.SaveChangesAsync();
return elements.Select(e => e.Id).ToList();
}
private async Task<List<int>> SeedWatermarks(int count)
{
await using TvContext context = _db.CreateContext();
List<ChannelWatermark> watermarks = Enumerable.Range(0, count)
.Select(i => new ChannelWatermark { Name = $"W{i}" })
.ToList();
context.ChannelWatermarks.AddRange(watermarks);
await context.SaveChangesAsync();
return watermarks.Select(w => w.Id).ToList();
}
private async Task<int> SeedWatermark()
{
await using TvContext context = _db.CreateContext();
var watermark = new ChannelWatermark { Name = "W" };
context.ChannelWatermarks.Add(watermark);
await context.SaveChangesAsync();
return watermark.Id;
}
private async Task AttachWatermark(int watermarkId)
{
await using TvContext context = _db.CreateContext();
Deco deco = await context.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
deco.WatermarkMode = DecoMode.Override;
deco.DecoWatermarks.Add(new DecoWatermark { DecoId = 1, WatermarkId = watermarkId });
await context.SaveChangesAsync();
}
private async Task AttachGraphicsElement(int elementId)
{
await using TvContext context = _db.CreateContext();
Deco deco = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
deco.GraphicsElementsMode = DecoMode.Override;
deco.DecoGraphicsElements.Add(new DecoGraphicsElement { DecoId = 1, GraphicsElementId = elementId });
await context.SaveChangesAsync();
}
// Removing UpdateDecoHandler.GraphicsElementIdsMustExist alone from Validate is row 36 of the
// mutation table in docs/graphics-elements.md, measured against the whole ErsatzTV.Tests project.
[Test]
public async Task Should_Reject_Unknown_GraphicsElementId_With_A_Validation_Error_Not_A_Throw()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [999]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain("999");
// no partial write: the deco keeps no graphics element association
await using TvContext context = _db.CreateContext();
Deco reloaded = await context.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.DecoGraphicsElements.ShouldBeEmpty();
}
// Removing UpdateDecoHandler.WatermarkIdsMustExist alone from Validate is row 37 of the
// mutation table in docs/graphics-elements.md.
[Test]
public async Task Should_Reject_Unknown_WatermarkId_With_A_Validation_Error_Not_A_Throw()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateDeco(
1,
1,
"D",
DecoMode.Override,
[999],
false,
DecoMode.Inherit,
[],
false,
DecoMode.Inherit,
[],
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null,
false,
DecoMode.Inherit,
CollectionType.Collection,
null,
null,
null,
null),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain("999");
}
// The mode, not the id list, decides whether an id is data. ApplyUpdateRequest reconciles the
// join table only under Override/Merge and Clear()s it otherwise, so validating unconditionally
// would reject a save the apply path was going to discard. Removing the ConsumesGraphicsElementIds
// guard alone from UpdateDecoHandler.GraphicsElementIdsMustExist is row 38 of the mutation table
// in docs/graphics-elements.md.
[Test]
public async Task Should_Ignore_An_Unknown_GraphicsElementId_When_The_Mode_Does_Not_Consume_It()
{
await SeedDeco();
int elementId = await SeedGraphicsElement();
await AttachGraphicsElement(elementId);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: [999]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
// the apply path discards the ids under Inherit, and the existing attachment with them
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.GraphicsElementsMode.ShouldBe(DecoMode.Inherit);
reloaded.DecoGraphicsElements.ShouldBeEmpty();
}
// Twin of the above for the watermark half; removing the ConsumesWatermarkIds guard alone from
// UpdateDecoHandler.WatermarkIdsMustExist is row 39 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Ignore_An_Unknown_WatermarkId_When_The_Mode_Does_Not_Consume_It()
{
await SeedDeco();
int watermarkId = await SeedWatermark();
await AttachWatermark(watermarkId);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: [999]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoWatermarks).SingleAsync(d => d.Id == 1);
reloaded.WatermarkMode.ShouldBe(DecoMode.Disable);
reloaded.DecoWatermarks.ShouldBeEmpty();
}
[Test]
public async Task Should_Accept_A_Known_GraphicsElementId()
{
await SeedDeco();
int elementId = await SeedGraphicsElement();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [elementId]),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext reload = _db.CreateContext();
Deco reloaded = await reload.Decos.Include(d => d.DecoGraphicsElements).SingleAsync(d => d.Id == 1);
reloaded.DecoGraphicsElements.Select(x => x.GraphicsElementId).ShouldBe(new[] { elementId });
}
// Both deco id lists go through the same Validators.IdsMustExist as the channel's, so both
// inherit the same raw-count cap; the channel fixture pins its edges, these two pin that each
// deco field is actually behind it and names itself when it rejects.
[Test]
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_GraphicsElementIds()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
[Test]
public async Task Should_Reject_More_Than_The_Maximum_Number_Of_WatermarkIds()
{
await SeedDeco();
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(
watermarkMode: DecoMode.Override,
watermarkIds: Enumerable.Range(1, Validators.MaximumIdListCount + 1).ToList()),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The mode gate is handed to Validators.IdsMustExist rather than short-circuiting the call,
// because only the EXISTENCE half is the apply path's business: a list the reconcile discards
// was still parsed and materialized out of the request body. These two pin that the cap holds
// under a mode that consumes nothing -- row 47 of the mutation table in
// docs/graphics-elements.md. Note the ids all EXIST here, so nothing but the cap can reject
// them: a rejection is the cap's, not a smuggled existence check.
[Test]
public async Task Should_Reject_Too_Many_GraphicsElementIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
{
await SeedDeco();
List<int> ids = await SeedGraphicsElements(Validators.MaximumIdListCount + 1);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementsMode: DecoMode.Inherit, graphicsElementIds: ids),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
[Test]
public async Task Should_Reject_Too_Many_WatermarkIds_Even_Under_A_Mode_That_Does_Not_Consume_Them()
{
await SeedDeco();
List<int> ids = await SeedWatermarks(Validators.MaximumIdListCount + 1);
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Disable, watermarkIds: ids),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(Validators.MaximumIdListCount.ToString(CultureInfo.InvariantCulture));
}
// The deco twin of the channel handler's lost-race translation: an element deleted between
// Validate and SaveChangesAsync must come back as the validator's own 422, not the FK
// exception. Removing the DbUpdateException catch from
// UpdateDecoHandler.ApplyUpdateRequestTranslatingLostRace is row 44 of the mutation table in
// docs/graphics-elements.md.
[Test]
public async Task Should_Translate_A_Deco_Element_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
var interceptor = new ArmedSaveFailureInterceptor();
await _db.DisposeAsync();
_db = await InMemoryTvContext.CreateAsync(interceptor);
await SeedDeco();
int elementId = await SeedGraphicsElement();
interceptor.SqlBeforeFailing =
$"DELETE FROM GraphicsElement WHERE Id = {elementId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(graphicsElementIds: [elementId]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[GraphicsElementIds]");
error.Value.ShouldContain(elementId.ToString(CultureInfo.InvariantCulture));
}
// The watermark half of the same recovery. Without it, removing the watermark question from the
// recheck would redden nothing -- and the recheck re-asks the whole of Validate exactly so that
// neither id list is the only one covered. Row 46 of the mutation table.
[Test]
public async Task Should_Translate_A_Deco_Watermark_Deleted_Between_Validation_And_Save_Into_The_Same_422()
{
var interceptor = new ArmedSaveFailureInterceptor();
await _db.DisposeAsync();
_db = await InMemoryTvContext.CreateAsync(interceptor);
await SeedDeco();
int watermarkId = await SeedWatermark();
interceptor.SqlBeforeFailing =
$"DELETE FROM ChannelWatermark WHERE Id = {watermarkId.ToString(CultureInfo.InvariantCulture)}";
interceptor.Armed = true;
var handler = new UpdateDecoHandler(_db.Factory, _channel);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(watermarkMode: DecoMode.Override, watermarkIds: [watermarkId]),
CancellationToken.None);
IsLeft(result).ShouldBeTrue();
BaseError error = result.Match(Left: e => e, Right: _ => throw new AssertionException("expected Left"));
error.Value.ShouldContain("[WatermarkIds]");
error.Value.ShouldContain(watermarkId.ToString(CultureInfo.InvariantCulture));
}
}
@@ -372,7 +372,7 @@ public class GetSearchFieldValuesHandlerTests
[Test]
public async Task List_Valued_Walk_Reads_Live_Rows_Regardless_Of_Id_Density()
{
// Bounding the Id KEYSPACE is the killer here: keyspace is not rows with 20,000
// THE round-4 killer. That revision bounded the Id KEYSPACE, and keyspace is not rows: with 20,000
// historical rows deleted and one live song at Id 20001, the walk spent its whole allowance on empty
// ranges and returned [] for a table containing exactly one row. Capacity degraded linearly with
// deletion ratio, and no ratio was safe -- one placed gap hid the next match.
@@ -217,9 +217,7 @@ public class FFmpegProfileControllerTests
false,
true,
false,
true,
null,
null);
true);
private static CreateFFmpegProfileRequest MakeCreateRequest() =>
new(
@@ -1,52 +0,0 @@
{
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts \u2014 the replay deserializes each body with ApiJsonSettings, the configuration Startup applies to AddNewtonsoftJson. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent. add_duration and pad_until_exact both end BETWEEN two content boundaries on purpose: an instruction that happens to end on one never reaches the engine's trim branch, and its trim flag is then witnessed by nothing.",
"script": [
{
"action": "add_collection",
"body": {
"key": "content",
"collection": "Test Collection",
"order": "chronological"
}
},
{
"action": "start_epg_group",
"body": {
"advance": true,
"customTitle": "Morning Block"
}
},
{
"action": "add_count",
"body": {
"content": "content",
"count": 2
}
},
{
"action": "stop_epg_group"
},
{
"action": "add_duration",
"body": {
"content": "content",
"duration": "2:00:00",
"trim": true
}
},
{
"action": "pad_until_exact",
"body": {
"content": "content",
"when": "2026-01-15T09:55:00+00:00",
"trim": true
}
},
{
"action": "add_all",
"body": {
"content": "content"
}
}
]
}

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