Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fcb5e9b28 |
@@ -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="${CLAUDE_PROJECT_DIR:-$(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
|
||||
|
||||
@@ -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="${CLAUDE_PROJECT_DIR:-$(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="${CLAUDE_PROJECT_DIR:-$(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="${CLAUDE_PROJECT_DIR:-$(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
|
||||
|
||||
|
||||
@@ -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="${CLAUDE_PROJECT_DIR:-$(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
|
||||
|
||||
@@ -9,13 +9,6 @@
|
||||
# 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="${CLAUDE_PROJECT_DIR:-$(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
|
||||
|
||||
|
||||
@@ -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="${CLAUDE_PROJECT_DIR:-$(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)
|
||||
|
||||
@@ -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="${CLAUDE_PROJECT_DIR:-$(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
|
||||
|
||||
|
||||
@@ -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="${CLAUDE_PROJECT_DIR:-$(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)
|
||||
|
||||
|
||||
@@ -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="${CLAUDE_PROJECT_DIR:-$(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.$$
|
||||
|
||||
@@ -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 this comment used to say
|
||||
# "sound", which 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,30 +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:-...}` (ersatztv#858, #891).
|
||||
# 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.
|
||||
#
|
||||
# The other twelve tracked hooks still carry the env-var-first form and are deliberately NOT changed
|
||||
# here; that sweep needs its own population and review (ersatztv#891, where the reachable case is
|
||||
# measured — husky launches the prepush hooks as `./.claude/hooks/...`, a RELATIVE path independent
|
||||
# of `$CLAUDE_PROJECT_DIR`, so the two roots genuinely diverge there). This copy is fixed because
|
||||
# leaving a total gate bypass 500 lines above the gate this PR hardens would make the rest decorative.
|
||||
|
||||
# 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
|
||||
@@ -113,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
|
||||
@@ -197,81 +168,10 @@ fi
|
||||
# after which a later, successful status read could still auto-grant. A transient failure would then
|
||||
# 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. Cold
|
||||
# review demonstrated the consequence with 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
|
||||
@@ -338,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."
|
||||
@@ -445,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
|
||||
@@ -466,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 cold review found what that left
|
||||
# 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. The first draft gave it an arm that set `unreadable-rules`, the same value
|
||||
# the catch-all sets, and that arm was measured to be a no-op: deleting it 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
|
||||
@@ -705,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). 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. The twelve other hooks still resolve it from
|
||||
# the env var and are ersatztv#891.
|
||||
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
|
||||
@@ -746,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", and cold review found 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
|
||||
|
||||
@@ -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="${CLAUDE_PROJECT_DIR:-$(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="${CLAUDE_PROJECT_DIR:-$(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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -4,69 +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, and nothing mechanically enforces that since the shared self-reference went —
|
||||
# ersatztv#855.
|
||||
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).
|
||||
#
|
||||
@@ -90,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
|
||||
@@ -113,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
|
||||
|
||||
@@ -141,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,51 +102,7 @@ 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".
|
||||
# Every credentialed thing this file does uses the scoped REGISTRY_* PAT, never the injected token:
|
||||
# its registry pushes, its five `container:` image pulls, its three commit-status GET steps
|
||||
# (`ETV_STATUS_AUTH` in jobs `test`, `migrations` and `functional-e2e`, each a read-only GET via
|
||||
# scripts/ci-detect-already-validated.sh) and its registry tag READ (`ETV_REGISTRY_AUTH` in job
|
||||
# `toolchain-preflight`, via scripts/ci-toolchain-image-resolves.sh). The injected token therefore
|
||||
# serves only its eight `actions/checkout` steps. Note this file needs no `packages:` unit for that
|
||||
# same reason: the `container:` blocks carry explicit `credentials:`.
|
||||
# (Sites above are named by JOB, not by line number: this file is ~1150 lines, so any edit above a
|
||||
# citation silently invalidates it — which is how the first version of this comment went stale two
|
||||
# lines after it was 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
|
||||
env:
|
||||
ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
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
|
||||
@@ -168,14 +111,10 @@ jobs:
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
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
|
||||
@@ -444,14 +383,10 @@ 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
|
||||
@@ -614,14 +549,10 @@ jobs:
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
env:
|
||||
CI_EXECUTION_CLASS: toolchain
|
||||
CI_JOB_ROLE: guard
|
||||
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
|
||||
@@ -739,36 +670,12 @@ jobs:
|
||||
# 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:
|
||||
@@ -784,38 +691,12 @@ jobs:
|
||||
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. Review disarmed the entire gate with one
|
||||
# repo-root `pytest.ini` (`addopts = -k "not delimiter_banned"`) or `conftest.py`
|
||||
# (`pytest_collection_modifyitems`), which deselects 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, which made the first version of this script report healthy while the
|
||||
# job's real invocation was 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
|
||||
--always deps ban
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
@@ -828,8 +709,8 @@ 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
|
||||
@@ -838,14 +719,10 @@ jobs:
|
||||
# where an image is published and never booted.
|
||||
needs: [test, migrations, scan]
|
||||
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),
|
||||
@@ -865,27 +742,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
|
||||
@@ -1048,28 +905,18 @@ jobs:
|
||||
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"
|
||||
@@ -1153,28 +1000,18 @@ jobs:
|
||||
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
|
||||
|
||||
+39
-333
@@ -42,79 +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.
|
||||
# Nothing MECHANICALLY couples this pathspec to `ci-image.yml`'s `on.push.paths`; before
|
||||
# #744 the shared self-reference kept them in step. Divergence is silent and green in the
|
||||
# dangerous direction — tracked in ersatztv#855.
|
||||
# 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)"
|
||||
@@ -146,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
|
||||
@@ -160,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."
|
||||
@@ -173,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
|
||||
@@ -214,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
|
||||
@@ -259,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
|
||||
@@ -274,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
|
||||
@@ -301,235 +206,22 @@ 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
|
||||
@@ -540,6 +232,20 @@ jobs:
|
||||
# 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+194
-1632
File diff suppressed because it is too large
Load Diff
-20
@@ -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/
|
||||
|
||||
@@ -83,7 +83,7 @@ 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.
|
||||
|
||||
@@ -97,30 +97,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.
|
||||
|
||||
|
||||
@@ -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,43 +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 filename, never its user-editable Name (the #67 lesson).
|
||||
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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,4 @@ public record FFmpegFullProfileResponseModel(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder,
|
||||
double? ReadRate,
|
||||
double? ReadRateCatchup);
|
||||
bool QsvPreferNativeDecoder);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -4,7 +4,4 @@ public static class GraphicsElementDefaults
|
||||
{
|
||||
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
|
||||
public const string OnNowNextFileName = "on-now-next.yml";
|
||||
|
||||
// Display name only. Never use it for identity -- that is the filename above (#67 / #74).
|
||||
public const string OnNowNextName = "On Now / Next";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
|
||||
ErsatzTV.Infrastructure.MySql/Migrations/20260826191057_Add_FFmpegProfile_ReadRatePacing.Designer.cs
Generated
-7348
File diff suppressed because it is too large
Load Diff
-38
@@ -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");
|
||||
|
||||
|
||||
-7173
File diff suppressed because it is too large
Load Diff
-38
@@ -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,218 +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;
|
||||
}
|
||||
|
||||
bool exists = await context.GraphicsElements.AnyAsync(e => e.Path == target, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Name is display-only (identity is the filename), 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 filename, never the user-editable Name (the #67 lesson carried into #74).
|
||||
/// The Kind is part of it: the five template folders are separate namespaces, so an unrelated
|
||||
/// image/motion/subtitle/script element may legitimately be named `on-now-next.yml` too, and
|
||||
/// filename alone would hand back whichever row the unordered query happened to return first.
|
||||
/// </summary>
|
||||
public static async Task<Option<int>> GetBuiltInElementId(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<(int Id, string Path)> candidates = await context.GraphicsElements
|
||||
.Where(e => e.Kind == GraphicsElementKind.Text)
|
||||
.Select(e => new { e.Id, e.Path })
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(rows => rows.Select(r => (r.Id, r.Path)).ToList());
|
||||
|
||||
var matches = candidates
|
||||
.Where(c => System.IO.Path.GetFileName(c.Path) == GraphicsElementDefaults.OnNowNextFileName)
|
||||
.OrderBy(c => c.Id)
|
||||
.ToList();
|
||||
|
||||
// Lowest id wins if two text templates somehow share the filename, so the choice is stable
|
||||
// across restarts rather than dependent on query order.
|
||||
return matches.Count == 0 ? Option<int>.None : matches[0].Id;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
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;
|
||||
@@ -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 = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
context.GraphicsElements.Add(element);
|
||||
await context.SaveChangesAsync();
|
||||
return element.Id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
// because three earlier drafts of this comment each supplied a confident mechanism for a
|
||||
// correct observation and two of them were 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();
|
||||
}
|
||||
}
|
||||
@@ -217,9 +217,7 @@ public class FFmpegProfileControllerTests
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
null);
|
||||
true);
|
||||
|
||||
private static CreateFFmpegProfileRequest MakeCreateRequest() =>
|
||||
new(
|
||||
|
||||
@@ -978,7 +978,7 @@ public class PlayoutControllerTests
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1002,7 +1002,7 @@ public class PlayoutControllerTests
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1025,7 +1025,7 @@ public class PlayoutControllerTests
|
||||
|
||||
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 0, 0, null, 13, 32, null)]);
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, [], [], [], false, 0, 0, null, 13, 32, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1079,110 +1079,6 @@ public class PlayoutControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- #880: absent recurrence means UNRESTRICTED, an explicit [] is rejected -----
|
||||
|
||||
// Reddens if ToReplaceItem's `?? All*()` is reverted to `?? []`: the counts drop to 0. That is the
|
||||
// point of the test -- the normalization is the fix, so it is what must be pinned.
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Normalize_Absent_Recurrence_To_Unrestricted()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeScheduleVm(7)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeAltVm(1, 0, 7)]);
|
||||
|
||||
// All three recurrence arrays omitted -- the shape an API client sends and the SPA never does.
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 1, 1, null, 12, 31, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
|
||||
c.Items.Count == 1 &&
|
||||
c.Items[0].DaysOfWeek.Count == 7 &&
|
||||
c.Items[0].DaysOfMonth.Count == 31 &&
|
||||
c.Items[0].MonthsOfYear.Count == 12),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// The complement of the test above: normalization must fill in ONLY what was absent. Without this a
|
||||
// fix that substituted All*() unconditionally would still pass the normalization test.
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeScheduleVm(7)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeAltVm(1, 0, 7)]);
|
||||
|
||||
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
||||
[
|
||||
new PlayoutAlternateScheduleItemRequest(
|
||||
0,
|
||||
7,
|
||||
[DayOfWeek.Monday, DayOfWeek.Tuesday],
|
||||
null,
|
||||
[6],
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
12,
|
||||
31,
|
||||
null)
|
||||
]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
|
||||
c.Items[0].DaysOfWeek.Count == 2 &&
|
||||
c.Items[0].DaysOfWeek.Contains(DayOfWeek.Monday) &&
|
||||
c.Items[0].DaysOfMonth.Count == 31 &&
|
||||
c.Items[0].MonthsOfYear.Count == 1 &&
|
||||
c.Items[0].MonthsOfYear.Contains(6)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Normalize_Absent_Recurrence_To_Unrestricted()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateViewModel(7)]);
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateVm(1, 0, 7, null)]);
|
||||
|
||||
var request = new ReplacePlayoutTemplatesRequest(
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 1, 1, null, 12, 31, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlayoutTemplateItems>(c =>
|
||||
c.Items.Count == 1 &&
|
||||
c.Items[0].DaysOfWeek.Count == 7 &&
|
||||
c.Items[0].DaysOfMonth.Count == 31 &&
|
||||
c.Items[0].MonthsOfYear.Count == 12),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Playout templates -----
|
||||
|
||||
[Test]
|
||||
@@ -1291,7 +1187,7 @@ public class PlayoutControllerTests
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
||||
|
||||
var request = new ReplacePlayoutTemplatesRequest(
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], true, startMonth, startDay, null, endMonth, endDay, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1316,7 +1212,7 @@ public class PlayoutControllerTests
|
||||
|
||||
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
|
||||
var request = new ReplacePlayoutTemplatesRequest(
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 0, 0, null, 13, 32, null)]);
|
||||
[new PlayoutTemplateItemRequest(0, 7, null, [], [], [], false, 0, 0, null, 13, 32, null)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
||||
|
||||
@@ -1504,7 +1400,7 @@ public class PlayoutControllerTests
|
||||
new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static PlayoutAlternateScheduleItemRequest MakeAltRequest(int programScheduleId) =>
|
||||
new(0, programScheduleId, null, null, null, false, 1, 1, null, 12, 31, null);
|
||||
new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
|
||||
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, null, 0);
|
||||
@@ -1530,7 +1426,7 @@ public class PlayoutControllerTests
|
||||
null);
|
||||
|
||||
private static PlayoutTemplateItemRequest MakeTemplateRequest(int templateId, int? decoTemplateId) =>
|
||||
new(0, templateId, decoTemplateId, null, null, null, false, 1, 1, null, 12, 31, null);
|
||||
new(0, templateId, decoTemplateId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static PlayoutNameViewModel MakePlayout(int id) =>
|
||||
new(
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using LanguageExt;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// Pixel-level cover for the #732 background box. These assert the RENDERED BITMAP rather than the
|
||||
/// parsed model, because every failure mode this feature has is a silent no-op: the YAML parses, the
|
||||
/// element initializes, and nothing is drawn. Geometry assertions are all RELATIVE to a no-box
|
||||
/// baseline so they do not depend on which typeface the host resolves.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class TextElementBackgroundBoxTests
|
||||
{
|
||||
private const int FrameWidth = 1920;
|
||||
|
||||
// Derived from the documented rule, not a hardcoded 1080p result: hardcoding it makes correct
|
||||
// production code fail the moment the test frame size changes.
|
||||
private static int FrameInsetCap => Math.Min(FrameWidth, FrameHeight) / 2;
|
||||
private const int FrameHeight = 1080;
|
||||
|
||||
private static TextGraphicsElement BaseElement() =>
|
||||
new()
|
||||
{
|
||||
Name = "test",
|
||||
Location = WatermarkLocation.BottomLeft,
|
||||
BaseStyle = "body",
|
||||
Styles =
|
||||
[
|
||||
new StyleDefinition
|
||||
{
|
||||
Name = "body",
|
||||
// Required: a null font_family makes CustomFontMapper throw on a null dictionary
|
||||
// key, which TextElement swallows into "disable for this content" (the #570 trap).
|
||||
// The family need not resolve -- an unknown one falls back to Skia's default.
|
||||
FontFamily = "Roboto",
|
||||
FontSize = 40,
|
||||
TextColor = "#FFFFFF"
|
||||
}
|
||||
],
|
||||
Text = "Hello"
|
||||
};
|
||||
|
||||
// TextElement swallows every initialization failure into a logged warning, so a broken render
|
||||
// would otherwise surface as a null bitmap with no explanation. Capture the warning and rethrow.
|
||||
private sealed class ThrowingLogger : ILogger
|
||||
{
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => null!;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception exception,
|
||||
Func<TState, Exception, string> formatter)
|
||||
{
|
||||
if (logLevel >= LogLevel.Warning && exception is not null)
|
||||
{
|
||||
throw new InvalidOperationException(formatter(state, exception), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SKBitmap Render(TextGraphicsElement element)
|
||||
{
|
||||
var fonts = new GraphicsEngineFonts(new CustomFontMapper(NullLogger<CustomFontMapper>.Instance));
|
||||
var textElement = new TextElement(fonts, element, new ThrowingLogger());
|
||||
|
||||
var context = new GraphicsEngineContext(
|
||||
"1",
|
||||
null,
|
||||
[],
|
||||
new Dictionary<string, object>(),
|
||||
new Resolution { Width = FrameWidth, Height = FrameHeight },
|
||||
new Resolution { Width = FrameWidth, Height = FrameHeight },
|
||||
new FrameRate("30"),
|
||||
DateTimeOffset.UnixEpoch,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(1),
|
||||
TimeSpan.FromMinutes(1));
|
||||
|
||||
textElement.InitializeAsync(context, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Option<PreparedElementImage> maybeImage = textElement
|
||||
.PrepareImage(TimeSpan.Zero, TimeSpan.Zero, TimeSpan.FromMinutes(1), TimeSpan.Zero, CancellationToken.None)
|
||||
.AsTask().GetAwaiter().GetResult();
|
||||
|
||||
PreparedElementImage prepared = maybeImage.IfNone(() => throw new InvalidOperationException(
|
||||
"the element produced no image; initialization failed"));
|
||||
|
||||
return prepared.Image;
|
||||
}
|
||||
|
||||
// The whole suite is meaningless if the host cannot lay out any text at all, so prove the
|
||||
// baseline is non-degenerate rather than letting a 0x0 bitmap pass every relative assertion.
|
||||
[Test]
|
||||
public void Baseline_Renders_A_Non_Empty_Bitmap()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
baseline.Width.ShouldBeGreaterThan(0);
|
||||
baseline.Height.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Background_Fields_Leaves_Corner_Transparent()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
baseline.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
||||
baseline.GetPixel(baseline.Width - 1, baseline.Height - 1).Alpha.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Background_Color_Fills_The_Box()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
SKColor corner = rendered.GetPixel(0, 0);
|
||||
corner.Alpha.ShouldBe((byte)255);
|
||||
corner.Red.ShouldBe((byte)255);
|
||||
corner.Green.ShouldBe((byte)0);
|
||||
corner.Blue.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Background_Padding_Grows_The_Bitmap_On_Every_Side()
|
||||
{
|
||||
const int Padding = 12;
|
||||
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = Padding;
|
||||
|
||||
SKBitmap padded = Render(element);
|
||||
|
||||
padded.Width.ShouldBe(baseline.Width + (2 * Padding));
|
||||
padded.Height.ShouldBe(baseline.Height + (2 * Padding));
|
||||
}
|
||||
|
||||
// Growing the bitmap is not the same as moving the text into it. If the text were still painted
|
||||
// at (0,0) the geometry assertions above would all still hold while the glyphs sat on the box
|
||||
// edge, so pin the padding band itself as pure background.
|
||||
[Test]
|
||||
public void Background_Padding_Actually_Insets_The_Text()
|
||||
{
|
||||
const int Padding = 16;
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = Padding;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// A full-width band inside the top padding must contain nothing but the fill colour.
|
||||
for (var x = 0; x < rendered.Width; x++)
|
||||
{
|
||||
SKColor pixel = rendered.GetPixel(x, Padding / 2);
|
||||
pixel.Red.ShouldBe((byte)255, $"pixel at x={x} in the padding band is not the fill colour");
|
||||
pixel.Green.ShouldBe((byte)0, $"pixel at x={x} in the padding band is not the fill colour");
|
||||
pixel.Blue.ShouldBe((byte)0, $"pixel at x={x} in the padding band is not the fill colour");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Background_Opacity_Percent_Scales_The_Alpha()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundOpacityPercent = 50;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// 255 * 0.5, rounded. Skia stores premultiplied alpha, so assert the alpha channel only.
|
||||
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)128);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Corner_Radius_Rounds_The_Corner_Away()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = 20;
|
||||
element.BackgroundCornerRadius = 20;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// The extreme corner falls outside a 20px radius, the middle of the left edge does not.
|
||||
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
||||
rendered.GetPixel(0, rendered.Height / 2).Alpha.ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Border_Color_Draws_A_Border_Distinct_From_The_Fill()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = 20;
|
||||
element.BorderColor = "#00FF00";
|
||||
element.BorderWidth = 4;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// On the border ring...
|
||||
SKColor edge = rendered.GetPixel(1, rendered.Height / 2);
|
||||
edge.Green.ShouldBeGreaterThan((byte)200);
|
||||
edge.Red.ShouldBeLessThan((byte)100);
|
||||
|
||||
// ...and inside it, still the fill.
|
||||
SKColor inside = rendered.GetPixel(10, rendered.Height / 2);
|
||||
inside.Red.ShouldBe((byte)255);
|
||||
inside.Green.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Border_Width_Is_Included_In_The_Bitmap_So_The_Border_Is_Not_Clipped()
|
||||
{
|
||||
const int BorderWidth = 6;
|
||||
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BorderColor = "#00FF00";
|
||||
element.BorderWidth = BorderWidth;
|
||||
|
||||
SKBitmap bordered = Render(element);
|
||||
|
||||
bordered.Width.ShouldBe(baseline.Width + (2 * BorderWidth));
|
||||
bordered.Height.ShouldBe(baseline.Height + (2 * BorderWidth));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unparseable_Background_Color_Draws_No_Box_Rather_Than_Substituting_One()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "not-a-color";
|
||||
element.BackgroundPadding = 25;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// No box means no padding either: the geometry is the untouched baseline.
|
||||
rendered.Width.ShouldBe(baseline.Width);
|
||||
rendered.Height.ShouldBe(baseline.Height);
|
||||
rendered.GetPixel(0, 0).Alpha.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Border_Color_Without_An_Explicit_Width_Draws_A_Hairline()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BorderColor = "#00FF00";
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
rendered.Width.ShouldBe(baseline.Width + 2);
|
||||
rendered.GetPixel(0, rendered.Height / 2).Green.ShouldBeGreaterThan((byte)200);
|
||||
}
|
||||
|
||||
// A box with no padding or border must not move anything: this is the seam where "adding a
|
||||
// background" could silently change an existing overlay's geometry.
|
||||
[Test]
|
||||
public void A_Fill_With_No_Padding_Or_Border_Leaves_The_Geometry_Untouched()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
|
||||
SKBitmap filled = Render(element);
|
||||
|
||||
filled.Width.ShouldBe(baseline.Width);
|
||||
filled.Height.ShouldBe(baseline.Height);
|
||||
}
|
||||
|
||||
// The inset is rounded up to a whole pixel and the SAME integer is used on both sides. Subtracting
|
||||
// the unrounded value from the wrap budget while adding its ceiling to the bitmap overflows
|
||||
// width_percent by a rounding remainder, which an integer padding cannot expose.
|
||||
[Test]
|
||||
public void Fractional_Padding_Still_Respects_Width_Percent()
|
||||
{
|
||||
const double WidthPercent = 20;
|
||||
|
||||
foreach (double padding in new[] { 12.3, 14.0, 29.7, 0.5 })
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog and keeps running past the edge";
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = WidthPercent;
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = padding;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
||||
rendered.Width.ShouldBeLessThanOrEqualTo(budget, $"padding {padding} overflowed the budget");
|
||||
}
|
||||
}
|
||||
|
||||
// An inset wider than the budget cannot be honoured AND stay inside it, so the INSET is clamped
|
||||
// rather than the text being squeezed to nothing. Asserting a width bound here would be wrong:
|
||||
// wrapping cannot break below one glyph, so a narrow width_percent overflows with or without a
|
||||
// box (pre-existing). What the clamp guarantees is that the box's own contribution stops growing
|
||||
// at the budget -- so a runaway padding renders identically to the largest one that fits.
|
||||
[Test]
|
||||
public void An_Oversized_Padding_Is_Clamped_To_The_Largest_That_Fits()
|
||||
{
|
||||
const double WidthPercent = 20;
|
||||
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
||||
int maxInset = (budget - 1) / 2;
|
||||
|
||||
static TextGraphicsElement WithPadding(double padding)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = WidthPercent;
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = padding;
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap runaway = Render(WithPadding(400));
|
||||
SKBitmap clamped = Render(WithPadding(maxInset));
|
||||
|
||||
runaway.Width.ShouldBe(clamped.Width);
|
||||
runaway.Height.ShouldBe(clamped.Height);
|
||||
|
||||
// and the clamp actually bit -- an unclamped 400px padding would add 800px of box
|
||||
runaway.Width.ShouldBeLessThan(budget + (2 * maxInset));
|
||||
}
|
||||
|
||||
// FitTextBlock rebuilds every style from scratch. It used to drop the halo, which only became
|
||||
// reachable once a box's insets could push a previously-fitting element into the Scale path --
|
||||
// adding a background would then silently remove the halo behind the text.
|
||||
[Test]
|
||||
public void The_Scale_Path_Preserves_The_Halo()
|
||||
{
|
||||
static TextGraphicsElement Scaled(bool withHalo)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Scale;
|
||||
element.WidthPercent = 15;
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = 12;
|
||||
element.Styles[0].HaloColor = withHalo ? "#00FF00" : null;
|
||||
element.Styles[0].HaloWidth = withHalo ? 3 : null;
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap withHalo = Render(Scaled(true));
|
||||
SKBitmap withoutHalo = Render(Scaled(false));
|
||||
|
||||
withHalo.Width.ShouldBe(withoutHalo.Width);
|
||||
withHalo.Height.ShouldBe(withoutHalo.Height);
|
||||
|
||||
var differing = 0;
|
||||
for (var x = 0; x < withHalo.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < withHalo.Height; y++)
|
||||
{
|
||||
if (withHalo.GetPixel(x, y) != withoutHalo.GetPixel(x, y))
|
||||
{
|
||||
differing++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the Scale path dropped the halo, the two renders would be pixel-identical.
|
||||
differing.ShouldBeGreaterThan(0, "the halo made no difference through the Scale path");
|
||||
}
|
||||
|
||||
// width_percent bounds the ELEMENT. If the insets were not subtracted from the wrap width the
|
||||
// box would overflow the budget by 2*inset, which is exactly the bug this pins.
|
||||
[Test]
|
||||
public void Width_Percent_Bounds_The_Whole_Box_Including_Padding()
|
||||
{
|
||||
const double WidthPercent = 20;
|
||||
const int Padding = 30;
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog and keeps on running well past the edge";
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = WidthPercent;
|
||||
element.BackgroundColor = "#000000";
|
||||
element.BackgroundPadding = Padding;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
var budget = (int)Math.Round(WidthPercent / 100.0 * FrameWidth);
|
||||
rendered.Width.ShouldBeLessThanOrEqualTo(budget);
|
||||
}
|
||||
|
||||
// YAML yields doubles, so 1e100 and NaN are reachable from a config file. Cast to float they
|
||||
// become Infinity/NaN, and (int)Math.Ceiling of those is an unspecified value that sails past
|
||||
// every clamp and can wrap the doubled inset back to zero.
|
||||
[Test]
|
||||
public void Non_Finite_And_Absurd_Box_Values_Do_Not_Corrupt_The_Geometry()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
foreach (double bad in new[] { 1e100, double.NaN, double.PositiveInfinity, -5.0 })
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = bad;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
rendered.Width.ShouldBeGreaterThan(0, $"padding {bad} produced a degenerate width");
|
||||
rendered.Height.ShouldBeGreaterThan(0, $"padding {bad} produced a degenerate height");
|
||||
rendered.Width.ShouldBeGreaterThanOrEqualTo(baseline.Width, $"padding {bad} shrank the element");
|
||||
rendered.Width.ShouldBeLessThanOrEqualTo(baseline.Width + (2 * FrameInsetCap), $"padding {bad} was not clamped");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Non_Finite_Width_Percent_Is_Treated_As_No_Budget()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
// This pins the OUTCOME -- a non-finite budget behaves like no budget -- not the
|
||||
// float.IsFinite guard, which no mutation can distinguish because .NET saturates float-to-int
|
||||
// conversion. See the "Not covered by any mutation" note in docs/graphics-elements.md.
|
||||
const int Padding = 10;
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = 1e300;
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = Padding;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
rendered.Width.ShouldBe(baseline.Width + (2 * Padding));
|
||||
rendered.Height.ShouldBe(baseline.Height + (2 * Padding));
|
||||
}
|
||||
|
||||
// Clamping insetPixels alone leaves DrawBackgroundBox stroking at the ORIGINAL border width,
|
||||
// centred on a rect that no longer has room for it -- the stroke then floods the whole element
|
||||
// and paints over the interior. The box's own fields must be clamped too.
|
||||
[Test]
|
||||
public void An_Oversized_Border_Is_Clamped_And_Does_Not_Flood_The_Element()
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Wrap;
|
||||
element.WidthPercent = 20;
|
||||
element.BackgroundColor = "#0000FF";
|
||||
element.BorderColor = "#FF0000";
|
||||
element.BorderWidth = 400;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// Somewhere inside the element there must still be fill, not solid border.
|
||||
var fillPixels = 0;
|
||||
for (var x = 0; x < rendered.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < rendered.Height; y++)
|
||||
{
|
||||
SKColor px = rendered.GetPixel(x, y);
|
||||
if (px.Blue > 200 && px.Red < 100)
|
||||
{
|
||||
fillPixels++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fillPixels.ShouldBeGreaterThan(0, "the border flooded the element; no fill survived");
|
||||
}
|
||||
|
||||
// Sanitize bounds each field on its own, but padding and border ADD UP, and with no
|
||||
// width_percent nothing else bounded them -- two fields could allocate a bitmap far larger than
|
||||
// the frame it is drawn onto. This pins the frame cap itself; the non-finite test above cannot,
|
||||
// because its bound is looser than the growth this clause prevents.
|
||||
[Test]
|
||||
public void The_Inset_Is_Capped_Against_The_Frame_Even_With_No_Width_Percent()
|
||||
{
|
||||
SKBitmap baseline = Render(BaseElement());
|
||||
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundPadding = 4000;
|
||||
element.BorderColor = "#00FF00";
|
||||
element.BorderWidth = 4000;
|
||||
|
||||
SKBitmap rendered = Render(element);
|
||||
|
||||
// 8000px of requested inset collapses to the frame cap, not to 16000px of bitmap growth.
|
||||
rendered.Width.ShouldBe(baseline.Width + (2 * FrameInsetCap));
|
||||
rendered.Height.ShouldBe(baseline.Height + (2 * FrameInsetCap));
|
||||
}
|
||||
|
||||
// halo_* is documented as a per-style field. The merge loop used to rebuild each style from the
|
||||
// base and then override only font/colour, so a non-base style's halo silently inherited the
|
||||
// base one -- invisible in the seeded template, whose three styles declare identical halos.
|
||||
[Test]
|
||||
public void A_Non_Base_Style_Uses_Its_Own_Halo_Not_The_Base_Styles()
|
||||
{
|
||||
static TextGraphicsElement WithSubHalo(float haloWidth)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Styles.Add(new StyleDefinition
|
||||
{
|
||||
Name = "sub",
|
||||
FontFamily = "Roboto",
|
||||
FontSize = 40,
|
||||
TextColor = "#FFFFFF",
|
||||
HaloColor = "#00FF00",
|
||||
HaloWidth = haloWidth
|
||||
});
|
||||
element.Text = "[sub]Hello[/sub]";
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap thin = Render(WithSubHalo(1));
|
||||
SKBitmap thick = Render(WithSubHalo(6));
|
||||
|
||||
static int HaloPixels(SKBitmap b)
|
||||
{
|
||||
var n = 0;
|
||||
for (var x = 0; x < b.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < b.Height; y++)
|
||||
{
|
||||
SKColor px = b.GetPixel(x, y);
|
||||
if (px.Green > 150 && px.Red < 120)
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
// If the style's own halo were ignored, both renders would use the base style's (none) and
|
||||
// neither would contain halo pixels.
|
||||
HaloPixels(thin).ShouldBeGreaterThan(0, "the style's own halo was not applied");
|
||||
HaloPixels(thick).ShouldBeGreaterThan(HaloPixels(thin), "halo_width had no effect per style");
|
||||
}
|
||||
|
||||
private static int PixelsMatching(SKBitmap b, Func<SKColor, bool> predicate)
|
||||
{
|
||||
var n = 0;
|
||||
for (var x = 0; x < b.Width; x++)
|
||||
{
|
||||
for (var y = 0; y < b.Height; y++)
|
||||
{
|
||||
if (predicate(b.GetPixel(x, y)))
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
// halo_blur is a per-style field too, and the merge loop drops it just as silently as halo_color
|
||||
// did. Blur spreads the halo over more pixels at lower alpha, so a blurred halo covers more area.
|
||||
[Test]
|
||||
public void A_Non_Base_Style_Uses_Its_Own_Halo_Blur()
|
||||
{
|
||||
static TextGraphicsElement WithSubBlur(float blur)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Styles.Add(new StyleDefinition
|
||||
{
|
||||
Name = "sub",
|
||||
FontFamily = "Roboto",
|
||||
FontSize = 40,
|
||||
TextColor = "#FFFFFF",
|
||||
HaloColor = "#00FF00",
|
||||
HaloWidth = 3,
|
||||
HaloBlur = blur
|
||||
});
|
||||
element.Text = "[sub]Hello[/sub]";
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap sharp = Render(WithSubBlur(0));
|
||||
SKBitmap blurred = Render(WithSubBlur(5));
|
||||
|
||||
static bool Greenish(SKColor px) => px.Green > 60 && px.Red < 140 && px.Alpha > 0;
|
||||
|
||||
PixelsMatching(blurred, Greenish)
|
||||
.ShouldNotBe(PixelsMatching(sharp, Greenish), "halo_blur had no effect per style");
|
||||
}
|
||||
|
||||
// FitTextBlock rebuilds every style from scratch on the Scale path. line_height and halo_blur are
|
||||
// carried across there; without them a scaled element silently loses line spacing and halo blur.
|
||||
[Test]
|
||||
public void The_Scale_Path_Preserves_Line_Height()
|
||||
{
|
||||
static TextGraphicsElement Scaled(float lineHeight)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Scale;
|
||||
element.WidthPercent = 15;
|
||||
element.Styles[0].LineHeight = lineHeight;
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap tight = Render(Scaled(1.0f));
|
||||
SKBitmap loose = Render(Scaled(2.5f));
|
||||
|
||||
loose.Height.ShouldBeGreaterThan(tight.Height, "line_height was dropped by the Scale path");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void The_Scale_Path_Preserves_Halo_Blur()
|
||||
{
|
||||
static TextGraphicsElement Scaled(float blur)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.Text = "The quick brown fox jumps over the lazy dog";
|
||||
element.Fit = TextFit.Scale;
|
||||
element.WidthPercent = 15;
|
||||
element.Styles[0].HaloColor = "#00FF00";
|
||||
element.Styles[0].HaloWidth = 3;
|
||||
element.Styles[0].HaloBlur = blur;
|
||||
return element;
|
||||
}
|
||||
|
||||
SKBitmap sharp = Render(Scaled(0));
|
||||
SKBitmap blurred = Render(Scaled(5));
|
||||
|
||||
static bool Greenish(SKColor px) => px.Green > 60 && px.Red < 140 && px.Alpha > 0;
|
||||
|
||||
PixelsMatching(blurred, Greenish)
|
||||
.ShouldNotBe(PixelsMatching(sharp, Greenish), "halo_blur was dropped by the Scale path");
|
||||
}
|
||||
|
||||
// The documented range is 0-100. The sole opacity test used 50, so Math.Clamp could be removed
|
||||
// with everything green while an out-of-range value wrapped to an unrelated alpha.
|
||||
[Test]
|
||||
public void Background_Opacity_Percent_Is_Clamped_To_Its_Documented_Range()
|
||||
{
|
||||
static SKColor CornerAt(int percent)
|
||||
{
|
||||
TextGraphicsElement element = BaseElement();
|
||||
element.BackgroundColor = "#FF0000";
|
||||
element.BackgroundOpacityPercent = percent;
|
||||
return Render(element).GetPixel(0, 0);
|
||||
}
|
||||
|
||||
CornerAt(-50).Alpha.ShouldBe((byte)0, "a negative opacity did not clamp to 0");
|
||||
CornerAt(400).Alpha.ShouldBe((byte)255, "an opacity over 100 did not clamp to 100");
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using GraphicsElement = ErsatzTV.Core.Domain.GraphicsElement;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.IO.Abstractions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Testably.Abstractions.Testing;
|
||||
using ErsatzTV.Core;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// #732 part 2: the On Now / Next overlay is a default rather than an opt-in. Channels that predate
|
||||
/// that decision are backfilled once -- and only once, so a channel an operator deliberately clears
|
||||
/// is never silently re-attached on the next restart.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GraphicsElementDefaultAttachTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private static async Task<int> SeedBuiltInElement(TvContext context)
|
||||
{
|
||||
var element = new GraphicsElement
|
||||
{
|
||||
Path = $"/templates/text/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Name = "On Now / Next",
|
||||
Kind = GraphicsElementKind.Text
|
||||
};
|
||||
|
||||
await context.GraphicsElements.AddAsync(element);
|
||||
await context.SaveChangesAsync();
|
||||
return element.Id;
|
||||
}
|
||||
|
||||
private static async Task<Channel> SeedChannel(
|
||||
TvContext context,
|
||||
string number,
|
||||
StreamingMode mode = StreamingMode.HttpLiveStreamingSegmenter)
|
||||
{
|
||||
var channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Name = $"Channel {number}",
|
||||
Number = number,
|
||||
StreamingMode = mode,
|
||||
ChannelGraphicsElements = []
|
||||
};
|
||||
|
||||
await context.Channels.AddAsync(channel);
|
||||
await context.SaveChangesAsync();
|
||||
return channel;
|
||||
}
|
||||
|
||||
private static async Task<List<int>> AttachedElementIds(TvContext context, int channelId) =>
|
||||
await context.Set<ChannelGraphicsElement>()
|
||||
.AsNoTracking()
|
||||
.Where(cge => cge.ChannelId == channelId)
|
||||
.Select(cge => cge.GraphicsElementId)
|
||||
.ToListAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Attaches_The_Built_In_Element_To_Existing_Channels()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
int elementId = await SeedBuiltInElement(context);
|
||||
Channel one = await SeedChannel(context, "1");
|
||||
Channel two = await SeedChannel(context, "2");
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, one.Id)).ShouldBe([elementId]);
|
||||
(await AttachedElementIds(context, two.Id)).ShouldBe([elementId]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Skips_Hls_Direct_Channels_Where_The_Overlay_Cannot_Render()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await SeedBuiltInElement(context);
|
||||
Channel direct = await SeedChannel(context, "1", StreamingMode.HttpLiveStreamingDirect);
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, direct.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Does_Not_Duplicate_An_Existing_Attachment()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
int elementId = await SeedBuiltInElement(context);
|
||||
Channel channel = await SeedChannel(context, "1");
|
||||
|
||||
await context.AddAsync(
|
||||
new ChannelGraphicsElement { ChannelId = channel.Id, GraphicsElementId = elementId });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// The load-bearing property: a default must not fight the operator.
|
||||
[Test]
|
||||
public async Task Does_Not_Re_Attach_After_An_Operator_Clears_It()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await SeedBuiltInElement(context);
|
||||
Channel channel = await SeedChannel(context, "1");
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
|
||||
|
||||
// operator turns the overlay off for this channel
|
||||
context.Set<ChannelGraphicsElement>()
|
||||
.RemoveRange(context.Set<ChannelGraphicsElement>().Where(cge => cge.ChannelId == channel.Id));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// ...and the app restarts
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// The upgrade population this backfill exists for -- an install seeding the template for the
|
||||
// first time on this boot -- must not be stranded. The GraphicsElement row is normally created
|
||||
// by RefreshGraphicsElements, which runs long after startup, so the seeder ensures it itself.
|
||||
// Without that, the marker would be written against an unresolved element and every pre-existing
|
||||
// channel would go permanently unattached.
|
||||
[Test]
|
||||
public async Task Backfills_On_The_Same_Boot_That_First_Seeds_The_Template()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = await SeedChannel(context, "1");
|
||||
|
||||
// no ConfigElement markers and no GraphicsElement row: a pre-#74 install meeting #732
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// Filename alone is ambiguous: the five template folders are separate namespaces, so an element
|
||||
// of another kind may legitimately carry the same filename.
|
||||
[Test]
|
||||
public async Task Ignores_A_Same_Named_Element_Of_A_Different_Kind()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new GraphicsElement
|
||||
{
|
||||
Path = $"/templates/image/{GraphicsElementDefaults.OnNowNextFileName}",
|
||||
Kind = GraphicsElementKind.Image
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Channel channel = await SeedChannel(context, "1");
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ignores_A_Non_Built_In_Element_With_A_Different_Filename()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await context.GraphicsElements.AddAsync(
|
||||
new GraphicsElement { Path = "/templates/text/something-else.yml", Kind = GraphicsElementKind.Text });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Channel channel = await SeedChannel(context, "1");
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, channel.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// The marker is permanent, so writing it with nothing resolved would strand every channel. Stay
|
||||
// armed instead and pick the work up once the element exists.
|
||||
[Test]
|
||||
public async Task Stays_Armed_When_There_Is_No_Built_In_Element_To_Attach()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = await SeedChannel(context, "1");
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await context.ConfigElements
|
||||
.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key))
|
||||
.ShouldBeFalse("the marker was written with nothing to attach");
|
||||
|
||||
// the element turns up later; the backfill must still do its job
|
||||
await SeedBuiltInElement(context);
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
(await AttachedElementIds(context, channel.Id)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// The already-seeded branch of SeedOnNowNext has its own EnsureBuiltInElementRow call. Removing
|
||||
// it reddened nothing until this test existed -- the other tests all exercise a FRESH seed.
|
||||
[Test]
|
||||
public async Task An_Already_Seeded_Install_Missing_Its_Element_Row_Gets_One()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
string target = Path.Combine(
|
||||
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
|
||||
GraphicsElementDefaults.OnNowNextFileName);
|
||||
await fs.File.WriteAllTextAsync(target, "name: On Now / Next\n");
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
(await context.GraphicsElements.CountAsync()).ShouldBe(0);
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
// Assert the ROW, not just that one exists: a lookup-only assertion is satisfied by a row
|
||||
// with a null Name, which sorts the built-in element into the unnamed bucket in the SPA.
|
||||
List<GraphicsElement> rows = await context.GraphicsElements.ToListAsync();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].Path.ShouldBe(target);
|
||||
rows[0].Kind.ShouldBe(GraphicsElementKind.Text);
|
||||
rows[0].Name.ShouldBe(GraphicsElementDefaults.OnNowNextName);
|
||||
|
||||
(await GraphicsElementSeeder.GetBuiltInElementId(context, CancellationToken.None)).IsSome.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// The armed path is a real, reachable state: an operator deletes the template, refresh reaps the
|
||||
// row, and the backfill then has nothing to resolve. Pin what happens when the element comes
|
||||
// back -- a single global marker cannot both avoid stranding and avoid re-adding, and this is
|
||||
// the half we accept. See graphics.on-now-next-on-by-default.
|
||||
[Test]
|
||||
public async Task While_Armed_A_Restored_Element_Is_Attached_To_Every_Eligible_Channel()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel kept = await SeedChannel(context, "1");
|
||||
Channel cleared = await SeedChannel(context, "2");
|
||||
|
||||
// nothing to resolve yet -> stays armed, no marker
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
(await context.ConfigElements
|
||||
.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key))
|
||||
.ShouldBeFalse();
|
||||
|
||||
// The element reappears and is attached to BOTH channels; the operator then clears `cleared`.
|
||||
// Doing the attach-then-remove for real matters: seeding `cleared` with no join at all would
|
||||
// only prove an untouched channel gets backfilled, which is not the claim.
|
||||
int elementId = await SeedBuiltInElement(context);
|
||||
await context.AddAsync(new ChannelGraphicsElement { ChannelId = kept.Id, GraphicsElementId = elementId });
|
||||
await context.AddAsync(new ChannelGraphicsElement { ChannelId = cleared.Id, GraphicsElementId = elementId });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
(await AttachedElementIds(context, cleared.Id)).Count.ShouldBe(1);
|
||||
|
||||
context.Set<ChannelGraphicsElement>()
|
||||
.RemoveRange(context.Set<ChannelGraphicsElement>().Where(cge => cge.ChannelId == cleared.Id));
|
||||
await context.SaveChangesAsync();
|
||||
(await AttachedElementIds(context, cleared.Id)).ShouldBeEmpty();
|
||||
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(context, CancellationToken.None);
|
||||
|
||||
// documented consequence: the still-armed backfill cannot see that deliberate clear
|
||||
(await AttachedElementIds(context, kept.Id)).Count.ShouldBe(1);
|
||||
(await AttachedElementIds(context, cleared.Id)).Count.ShouldBe(1);
|
||||
|
||||
// ...but it is now marked, so it never fires again
|
||||
(await context.ConfigElements
|
||||
.AnyAsync(c => c.Key == ConfigElementKey.GraphicsOnNowNextDefaultAttached.Key))
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using YamlDotNet.Serialization;
|
||||
@@ -37,7 +36,7 @@ public class GraphicsElementSeederTests
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.Exists(_seededPath).ShouldBeTrue();
|
||||
fs.File.ReadAllText(_seededPath).ShouldContain("epg_entries: 2");
|
||||
@@ -52,7 +51,7 @@ public class GraphicsElementSeederTests
|
||||
await fs.File.WriteAllTextAsync(_seededPath, "name: Operator Custom\nepg_entries: 2\n");
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.ReadAllText(_seededPath).ShouldContain("Operator Custom");
|
||||
}
|
||||
@@ -63,9 +62,9 @@ public class GraphicsElementSeederTests
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
fs.File.Delete(_seededPath);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
fs.File.Exists(_seededPath).ShouldBeFalse();
|
||||
}
|
||||
@@ -78,7 +77,7 @@ public class GraphicsElementSeederTests
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, CancellationToken.None);
|
||||
|
||||
string yaml = await fs.File.ReadAllTextAsync(_seededPath);
|
||||
IDeserializer deserializer = new DeserializerBuilder()
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
using Testably.Abstractions.Testing.FileSystem;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// #732: the seeder writes the On Now / Next template once and never revisits it, so a change to the
|
||||
/// shipped default would reach new databases only. These pin the upgrade path that fixes that, and
|
||||
/// the boundary that keeps it from clobbering an operator's edits.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class GraphicsElementSeederUpgradeTests
|
||||
{
|
||||
// Byte-for-byte the default shipped before #732. Verified 2026-08-26 against the live prod
|
||||
// install at 192.168.1.29 (md5 ef9afc088cf6dba252f725babbf3334f), so this is a real
|
||||
// fingerprint rather than a copy of the constant it is meant to detect.
|
||||
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 InMemoryTvContext _db = null!;
|
||||
private string _target = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_target = Path.Combine(
|
||||
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
|
||||
GraphicsElementDefaults.OnNowNextFileName);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private async Task<MockFileSystem> RunSeederOverAlreadySeededDatabase(string existingContent)
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
|
||||
if (existingContent is not null)
|
||||
{
|
||||
await fs.File.WriteAllTextAsync(_target, existingContent);
|
||||
}
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
// The fixture DB is shared across calls within a test, so only seed the marker once.
|
||||
string key = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
if (!context.ConfigElements.Any(c => c.Key == key))
|
||||
{
|
||||
context.ConfigElements.Add(new ConfigElement { Key = key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
return fs;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Upgrades_An_Untouched_Previous_Default()
|
||||
{
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
|
||||
|
||||
string result = await fs.File.ReadAllTextAsync(_target);
|
||||
|
||||
result.ShouldNotBe(OnNowNextYamlV1);
|
||||
result.ShouldContain("background_color");
|
||||
result.ShouldContain("background_padding");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings()
|
||||
{
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(
|
||||
OnNowNextYamlV1.Replace("\n", "\r\n"));
|
||||
|
||||
string result = await fs.File.ReadAllTextAsync(_target);
|
||||
|
||||
result.ShouldContain("background_color");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Leaves_An_Operator_Modified_File_Alone()
|
||||
{
|
||||
// One changed value is enough to stop matching the fingerprint.
|
||||
string edited = OnNowNextYamlV1.Replace("width_percent: 42", "width_percent: 30");
|
||||
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(edited);
|
||||
|
||||
string result = await fs.File.ReadAllTextAsync(_target);
|
||||
|
||||
result.ShouldBe(edited);
|
||||
result.ShouldNotContain("background_color");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Leaves_The_Current_Default_Alone_So_The_Upgrade_Is_Idempotent()
|
||||
{
|
||||
MockFileSystem first = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
|
||||
string upgraded = await first.File.ReadAllTextAsync(_target);
|
||||
|
||||
MockFileSystem second = await RunSeederOverAlreadySeededDatabase(upgraded);
|
||||
string again = await second.File.ReadAllTextAsync(_target);
|
||||
|
||||
again.ShouldBe(upgraded);
|
||||
}
|
||||
|
||||
// The upgrade runs inside DatabaseMigratorService, ahead of DatabaseIsReady(). Before #732 the
|
||||
// already-seeded branch touched the filesystem not at all, so a template the app cannot read --
|
||||
// e.g. edited as root via `docker exec` while the app runs as PUID/PGID -- used to boot fine.
|
||||
// It must not become a failure to start.
|
||||
[Test]
|
||||
public async Task An_Unwritable_Template_Does_Not_Fail_Startup()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
// the file matches a shipped default, so the upgrade WILL try to rewrite it -- and that write
|
||||
// is denied, standing in for a root-owned or read-only template
|
||||
var intercepted = 0;
|
||||
fs.Intercept.Changing(
|
||||
FileSystemTypes.File,
|
||||
_ =>
|
||||
{
|
||||
intercepted++;
|
||||
throw new UnauthorizedAccessException("simulated permission denial");
|
||||
});
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
string key = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
|
||||
context.ConfigElements.Add(new ConfigElement { Key = key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await Should.NotThrowAsync(
|
||||
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
|
||||
|
||||
// Without this the test would pass just as happily if the upgrade never reached the write.
|
||||
intercepted.ShouldBeGreaterThan(0, "the write interceptor never fired");
|
||||
|
||||
// and the original template survives the denied write
|
||||
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
|
||||
}
|
||||
|
||||
// The write path is not the only one that can fault. A template the app cannot READ used to be
|
||||
// harmless on an already-seeded install; it must stay that way. An exclusive lock produces a
|
||||
// genuine ReadAllTextAsync failure rather than an intercepted write dressed up as one.
|
||||
[Test]
|
||||
public async Task A_Read_Failure_On_The_Template_Does_Not_Fail_Startup()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await using Stream exclusive = fs.File.Open(_target, FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
|
||||
// prove the lock actually denies a read, so the test cannot pass by never hitting one
|
||||
Should.Throw<IOException>(() => fs.File.ReadAllText(_target));
|
||||
|
||||
await Should.NotThrowAsync(
|
||||
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Does_Not_Create_The_File_When_It_Is_Absent()
|
||||
{
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(null);
|
||||
|
||||
fs.File.Exists(_target).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style()
|
||||
{
|
||||
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
|
||||
string yaml = await fs.File.ReadAllTextAsync(_target);
|
||||
|
||||
IDeserializer deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
var element = deserializer.Deserialize<TextGraphicsElement>(yaml);
|
||||
|
||||
element.ShouldNotBeNull();
|
||||
element.BackgroundColor.ShouldBe("#000000");
|
||||
element.BackgroundOpacityPercent.ShouldBe(65);
|
||||
element.BackgroundPadding.ShouldBe(14);
|
||||
element.BackgroundCornerRadius.ShouldBe(8);
|
||||
|
||||
// The border is what makes the box visible over dark content; without it the translucent
|
||||
// black fill is indistinguishable from the frame behind it.
|
||||
element.BorderColor.ShouldBe("#59FFFFFF");
|
||||
element.BorderWidth.ShouldBe(1);
|
||||
|
||||
// #570: every style needs a font_family, and base_style must resolve.
|
||||
element.Styles.ShouldNotBeEmpty();
|
||||
element.Styles.ShouldAllBe(s => s.FontFamily != null);
|
||||
element.Styles.ShouldContain(s => s.Name == element.BaseStyle);
|
||||
}
|
||||
|
||||
// Without the duplicate guard in EnsureBuiltInElementRow the already-seeded branch inserts a
|
||||
// fresh row on EVERY boot: RefreshGraphicsElements will neither reap them (the file exists) nor
|
||||
// dedupe them, so the row set grows without bound. Idempotence of the FILE is not idempotence
|
||||
// of the ROW, and the existing idempotence test only looks at the file.
|
||||
[Test]
|
||||
public async Task Repeated_Seeding_Does_Not_Accumulate_Element_Rows()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
|
||||
}
|
||||
|
||||
(await context.GraphicsElements.ToListAsync()).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
// A failed write must not leave a truncated template behind: it would match no fingerprint, so
|
||||
// the upgrade could never repair it, and the loader rejects malformed YAML outright.
|
||||
[Test]
|
||||
public async Task A_Failed_Write_Leaves_The_Original_Template_Intact()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
|
||||
// Capture WHICH path the write targets. Asserting only "the original survived" cannot tell
|
||||
// an atomic write from an in-place one here: Testably raises the interception BEFORE it
|
||||
// truncates, so a plain WriteAllTextAsync(target) would leave the file intact too -- on a
|
||||
// real filesystem it would not. The path is what actually distinguishes them.
|
||||
var writtenPaths = new List<string>();
|
||||
fs.Intercept.Changing(
|
||||
FileSystemTypes.File,
|
||||
c =>
|
||||
{
|
||||
writtenPaths.Add(c.Path);
|
||||
throw new IOException("simulated disk full");
|
||||
});
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await Should.NotThrowAsync(
|
||||
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
|
||||
|
||||
writtenPaths.ShouldNotBeEmpty("the write interceptor never fired");
|
||||
writtenPaths.ShouldAllBe(path => path.EndsWith(".upgrade.tmp"), "the upgrade wrote the live template in place instead of a temp file");
|
||||
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
|
||||
fs.Directory.GetFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.upgrade.tmp")
|
||||
.ShouldBeEmpty("a temp file was left behind");
|
||||
}
|
||||
|
||||
// The sibling test faults on the FIRST write, so it never reaches File.Move or the cleanup. Fault
|
||||
// the replace instead, after a complete temp write: that is the path where a non-atomic
|
||||
// implementation would already have truncated the live template.
|
||||
[Test]
|
||||
public async Task A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact()
|
||||
{
|
||||
var fs = new MockFileSystem();
|
||||
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
|
||||
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
|
||||
|
||||
var seenPaths = new List<string>();
|
||||
fs.Intercept.Event(
|
||||
c =>
|
||||
{
|
||||
seenPaths.Add($"{c.ChangeType}:{c.Path}");
|
||||
|
||||
// let the temp file be written in full; fail only when the live template is touched
|
||||
if (c.Path == _target)
|
||||
{
|
||||
throw new IOException("simulated replace failure");
|
||||
}
|
||||
},
|
||||
_ => true);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ConfigElements.Add(
|
||||
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
await Should.NotThrowAsync(
|
||||
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
|
||||
|
||||
seenPaths.ShouldContain(path => path.EndsWith(".upgrade.tmp"), "no temp file was ever written");
|
||||
|
||||
// Assert the replace is a RENAME, not merely "the target was touched after the temp was".
|
||||
// A File.Copy(temp, target, true) also touches both in that order and would leave the
|
||||
// original intact under this mock (interception runs before the change), so path ordering
|
||||
// alone cannot tell an atomic replace from a truncating one -- the change TYPE can.
|
||||
seenPaths.ShouldContain($"Renamed:{_target}", "the replace was not an atomic rename");
|
||||
|
||||
// The whole point of write-then-move: the live template is untouched by a failed replace.
|
||||
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
|
||||
|
||||
// and nothing this call created is left behind
|
||||
fs.Directory.GetFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.upgrade.tmp")
|
||||
.ShouldBeEmpty("a temp file was left behind");
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Elastic.Clients.Elasticsearch;
|
||||
using Elastic.Transport;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// ersatztv#824 — the gap ersatztv#701 named rather than papered over.
|
||||
/// <para>
|
||||
/// <c>ElasticSearchIndex.UpdateSong</c> holds an INDEPENDENT copy of the logic
|
||||
/// <see cref="SongIndexerMetadataMutationTests" /> pins on <c>LuceneSearchIndex</c>. #701 removed
|
||||
/// <c>metadata.AlbumArtists ??= []; metadata.Artists ??= [];</c> from both, but only Lucene gained
|
||||
/// a regression test — so reintroducing the mutation in the Elastic copy ALONE left the whole
|
||||
/// suite green. This fixture closes that: the assertions are the same three, driven through the
|
||||
/// real <c>ElasticSearchIndex</c> against a real <see cref="TvContext" />.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why a stubbed transport rather than a live server.</b> #824 listed "inject a non-network
|
||||
/// <c>ElasticsearchClient</c> transport" as option 1 and it is what shipped:
|
||||
/// <c>Elastic.Transport.InMemoryRequestInvoker</c> is public in the pinned Elastic.Transport, and
|
||||
/// <c>ElasticsearchClientSettings(NodePool, IRequestInvoker)</c> accepts it. No server, no socket,
|
||||
/// no new package.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The canned response body is load-bearing, not decoration.</b> A bare
|
||||
/// <c>InMemoryRequestInvoker()</c> answers with an EMPTY body, which the client cannot deserialize
|
||||
/// into an <c>IndexResponse</c>. That throw lands in <c>UpdateSong</c>'s catch, which logs a
|
||||
/// warning and assigns <c>metadata.Song = null</c> — so the fixture would measure the ERROR path
|
||||
/// while every "did not mutate" assertion below still passed, vacuously. The
|
||||
/// <see cref="ThrowOnWarningLogger{T}" /> is the belt to that brace: it fails the test if the
|
||||
/// catch ran at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The client is injected into the private <c>_client</c> field rather than obtained normally,
|
||||
/// because <c>CreateClient</c> reads the process-wide static <c>ElasticSearchIndex.Uri</c> and
|
||||
/// would open a real socket. <c>UpdateItems</c> — unlike <c>IndexExists</c> and
|
||||
/// <c>Initialize</c> — never runs <c>_client ??= CreateClient()</c>, so the injected instance is
|
||||
/// the one used and an uninjected one would simply be null.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public class ElasticSongIndexerMetadataMutationTests
|
||||
{
|
||||
private const string TestIndexName = "etv-824-test";
|
||||
|
||||
private string? _originalIndexName;
|
||||
|
||||
/// <summary>
|
||||
/// <c>ElasticSearchIndex.IndexName</c> is a process-wide static. Only <c>Startup</c> reads it today,
|
||||
/// so leaving it set leaks nothing that currently runs — but a static this fixture writes and never
|
||||
/// restores is a cross-test hazard waiting for the first test that does read it.
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp() => _originalIndexName = ElasticSearchIndex.IndexName;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => ElasticSearchIndex.IndexName = _originalIndexName;
|
||||
|
||||
/// <summary>
|
||||
/// A well-formed <c>IndexResponse</c>. See the fixture docstring: an empty body diverts the run
|
||||
/// into <c>UpdateSong</c>'s catch and makes every assertion below vacuous.
|
||||
/// </summary>
|
||||
private const string IndexResponseBody =
|
||||
"""
|
||||
{"_index":"etv-824-test","_id":"1","_version":1,"result":"created",
|
||||
"_shards":{"total":1,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}
|
||||
""";
|
||||
|
||||
[Test]
|
||||
public async Task UpdateSong_Must_Not_Mutate_Nullable_Artists_On_A_Tracked_Entity()
|
||||
{
|
||||
await using var harness = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
int metadataId;
|
||||
int songId;
|
||||
await using (TvContext context = harness.CreateContext())
|
||||
{
|
||||
var library = new LocalLibrary { Name = "Music", MediaKind = LibraryMediaKind.Songs };
|
||||
context.Add(library);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var libraryPath = new LibraryPath { Path = "/music", LibraryId = library.Id };
|
||||
context.Add(libraryPath);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var song = new Song
|
||||
{
|
||||
LibraryPathId = libraryPath.Id,
|
||||
MediaVersions = [],
|
||||
SongMetadata =
|
||||
[
|
||||
new SongMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = "Untagged Track",
|
||||
SortTitle = "untagged track",
|
||||
DateAdded = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
|
||||
// The shape FallbackMetadataProvider.GetSongMetadata leaves behind: it never
|
||||
// assigns either primitive collection, so both columns persist as NULL.
|
||||
Artists = null!,
|
||||
AlbumArtists = null!,
|
||||
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Artwork = [],
|
||||
Guids = []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
context.Add(song);
|
||||
await context.SaveChangesAsync();
|
||||
metadataId = song.SongMetadata[0].Id;
|
||||
songId = song.Id;
|
||||
}
|
||||
|
||||
// The seed must actually have produced NULL columns, or every assertion below is vacuous.
|
||||
(await ReadRawArtists(harness, metadataId)).ShouldBeNull();
|
||||
|
||||
await using (TvContext context = harness.CreateContext())
|
||||
{
|
||||
// Deliberately TRACKED -- the indexer's own contract is what is being pinned, not the
|
||||
// AsNoTracking() habit of today's two callers. See SongIndexerMetadataMutationTests.
|
||||
Song tracked = await context.Songs
|
||||
.IncludeForSearch()
|
||||
.AsSplitQuery()
|
||||
.SingleAsync();
|
||||
|
||||
SongMetadata metadata = tracked.SongMetadata[0];
|
||||
metadata.Artists.ShouldBeNull("EF must materialize the NULL column as null, not as an empty list");
|
||||
|
||||
var logger = new ThrowOnWarningLogger<ElasticSearchIndex>();
|
||||
var index = new ElasticSearchIndex(
|
||||
new SearchQueryParser(
|
||||
Substitute.For<ISmartCollectionCache>(),
|
||||
Substitute.For<ILogger<SearchQueryParser>>()),
|
||||
logger);
|
||||
|
||||
var invoker = new CapturingRequestInvoker(
|
||||
new InMemoryRequestInvoker(
|
||||
Encoding.UTF8.GetBytes(IndexResponseBody),
|
||||
200,
|
||||
exception: null,
|
||||
contentType: "application/json",
|
||||
// The X-Elastic-Product header is REQUIRED, not cosmetic. The client runs a product
|
||||
// check on its first response and throws UnsupportedProductException ("the server is
|
||||
// not a supported distribution of Elasticsearch") without it -- which lands in
|
||||
// UpdateSong's catch and makes the fixture measure the error path. Measured: this is
|
||||
// exactly how this fixture first failed.
|
||||
headers: ProductCheckHeaders()));
|
||||
|
||||
var settings = new ElasticsearchClientSettings(
|
||||
new SingleNodePool(new Uri("http://localhost:9200")),
|
||||
invoker)
|
||||
.DefaultIndex(TestIndexName);
|
||||
|
||||
ElasticSearchIndex.IndexName = TestIndexName;
|
||||
|
||||
typeof(ElasticSearchIndex)
|
||||
.GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(index, new ElasticsearchClient(settings));
|
||||
|
||||
// A bare substitute returns null from GetAllLanguageCodes, which NPEs inside AddLanguages and
|
||||
// would divert the run into UpdateSong's catch.
|
||||
var languageCodeService = Substitute.For<ILanguageCodeService>();
|
||||
languageCodeService.GetAllLanguageCodes(Arg.Any<List<string>>()).Returns([]);
|
||||
languageCodeService.GetAllLanguageCodes(Arg.Any<string>()).Returns([]);
|
||||
|
||||
await index.UpdateItems(
|
||||
Substitute.For<ISearchRepository>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
languageCodeService,
|
||||
[tracked]);
|
||||
|
||||
// Surfacing the exception rather than asserting ShouldBeNull: the catch is the fixture's
|
||||
// most likely failure mode (see the canned-response note above), and "expected null but was
|
||||
// <Exception>" without the message sends the next reader hunting for a cause the fixture
|
||||
// already had in its hand.
|
||||
if (logger.Failure is not null)
|
||||
{
|
||||
Assert.Fail("UpdateSong threw and its catch ran, so this probe measured the error path "
|
||||
+ $"rather than the indexing path: {logger.Failure}");
|
||||
}
|
||||
|
||||
// POSITIVE CONTROL, and it is not optional: every assertion below says something did NOT
|
||||
// happen, so all of them hold vacuously if UpdateSong never ran. The Lucene fixture uses
|
||||
// `writer.NumDocs == 1` for exactly this; the transport-level equivalent is that the indexer
|
||||
// actually issued the index request for THIS song. Like NumDocs, it proves the song-indexing
|
||||
// path ran -- it does NOT prove the artist reads specifically ran.
|
||||
invoker.Requests.Count.ShouldBe(
|
||||
1,
|
||||
"UpdateSong did not issue exactly one index request, so the assertions below would pass "
|
||||
+ $"without exercising the code under test. Captured: [{string.Join(", ", invoker.Requests)}]");
|
||||
|
||||
// The document id is compared as the LAST PATH SEGMENT, not with ShouldContain. A substring
|
||||
// test is a false-pass vector here: the index name itself carries digits ("etv-824-test"), so
|
||||
// ShouldContain("2") or ShouldContain("4") would be satisfied by the index name alone for a
|
||||
// song whose id happened to be 2 or 4, and the assertion would stop discriminating without
|
||||
// ever failing.
|
||||
string path = invoker.Requests[0].Split(' ')[^1].Split('?')[0];
|
||||
path.Split('/')[^1].ShouldBe(
|
||||
songId.ToString(),
|
||||
$"the index request was not for the seeded song. Captured: {invoker.Requests[0]}");
|
||||
|
||||
// 1. The indexer left the entity alone.
|
||||
metadata.Artists.ShouldBeNull();
|
||||
metadata.AlbumArtists.ShouldBeNull();
|
||||
|
||||
// 2. ...so EF has nothing to persist. This is the assertion that fails loudly the day the
|
||||
// mutation returns, even if a later refactor stopped the value from being observable above.
|
||||
context.Entry(metadata).State.ShouldBe(EntityState.Unchanged);
|
||||
|
||||
// 3. And the save that a real caller would go on to make does not rewrite the column.
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
(await ReadRawArtists(harness, metadataId)).ShouldBeNull();
|
||||
}
|
||||
|
||||
private static Dictionary<string, IEnumerable<string>> ProductCheckHeaders() =>
|
||||
new(StringComparer.OrdinalIgnoreCase) { ["x-elastic-product"] = ["Elasticsearch"] };
|
||||
|
||||
/// <summary>
|
||||
/// Records every request the client actually issues, so the fixture can prove the code under test
|
||||
/// ran. Delegates the answering to a real <see cref="InMemoryRequestInvoker" /> rather than
|
||||
/// hand-building a response.
|
||||
/// </summary>
|
||||
private sealed class CapturingRequestInvoker(InMemoryRequestInvoker inner) : IRequestInvoker
|
||||
{
|
||||
public List<string> Requests { get; } = [];
|
||||
|
||||
public ResponseFactory ResponseFactory => inner.ResponseFactory;
|
||||
|
||||
public TResponse Request<TResponse>(
|
||||
Endpoint endpoint,
|
||||
BoundConfiguration boundConfiguration,
|
||||
PostData? postData)
|
||||
where TResponse : TransportResponse, new()
|
||||
{
|
||||
Requests.Add($"{endpoint.Method} {endpoint.PathAndQuery}");
|
||||
return inner.Request<TResponse>(endpoint, boundConfiguration, postData);
|
||||
}
|
||||
|
||||
public Task<TResponse> RequestAsync<TResponse>(
|
||||
Endpoint endpoint,
|
||||
BoundConfiguration boundConfiguration,
|
||||
PostData? postData,
|
||||
CancellationToken cancellationToken)
|
||||
where TResponse : TransportResponse, new()
|
||||
{
|
||||
Requests.Add($"{endpoint.Method} {endpoint.PathAndQuery}");
|
||||
return inner.RequestAsync<TResponse>(endpoint, boundConfiguration, postData, cancellationToken);
|
||||
}
|
||||
|
||||
// IRequestInvoker extends IDisposable, but InMemoryRequestInvoker holds no disposable state and
|
||||
// exposes no Dispose of its own -- there is nothing to forward to.
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<object?> ReadRawArtists(InMemoryTvContext harness, int metadataId)
|
||||
{
|
||||
await using TvContext context = harness.CreateContext();
|
||||
await using var command = context.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = $"SELECT Artists FROM SongMetadata WHERE Id = {metadataId}";
|
||||
object? value = await command.ExecuteScalarAsync();
|
||||
|
||||
// ExecuteScalar returns CLR null both for "the column is NULL" and for "there is no such row",
|
||||
// and the second is reachable: UpdateSong's catch assigns metadata.Song = null, which severs a
|
||||
// required relationship and cascades the row to Deleted, so a SaveChanges on the error path
|
||||
// DELETES it and a plain null check would pass for the wrong reason.
|
||||
if (value is null)
|
||||
{
|
||||
Assert.Fail($"SongMetadata row {metadataId} no longer exists, so its Artists column cannot "
|
||||
+ "be read -- the probe measured a deleted row rather than a preserved NULL.");
|
||||
}
|
||||
|
||||
return value is DBNull ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Infrastructure;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.MySql.Data;
|
||||
using ErsatzTV.Infrastructure.Sqlite.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MySqlConnector;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// ersatztv#823 — the question ersatztv#701 split out rather than answered: can a runtime null reach
|
||||
/// one of the SIX collection-valued scalar columns (<c>DaysOfMonth</c>, <c>MonthsOfYear</c>,
|
||||
/// <c>DaysOfWeek</c> on <see cref="ProgramScheduleAlternate" /> and <see cref="PlayoutTemplate" />)?
|
||||
/// <para>
|
||||
/// <b>Measured, not reasoned about</b>, because the reasoning available beforehand pointed the
|
||||
/// wrong way. The two converters differ on their read side —
|
||||
/// <c>IntCollectionValueConverter</c> maps null-or-blank to <c>Array.Empty<int>()</c> while
|
||||
/// <c>EnumCollectionJsonValueConverter</c> would dereference the result of
|
||||
/// <c>JsonConvert.DeserializeObject</c> — so the expectation was that the two behave differently
|
||||
/// on a NULL row. They do not: <b>EF does not invoke a value converter for a NULL column at
|
||||
/// all</b>, so all six materialize as CLR <c>null</c> and the int converter's null-to-empty
|
||||
/// branch is dead on this path. That is the measurement this fixture pins.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It also pins the WRITE half: assigning <c>null</c> to one of the six and calling
|
||||
/// <c>SaveChanges</c> SUCCEEDS and stores SQL NULL — the converter is skipped on the way out too.
|
||||
/// State that precisely, because the overclaim is tempting: this is a property of the CODE, not a
|
||||
/// live caller. NO caller supplies a null today — every production construction of
|
||||
/// <c>ReplacePlayoutAlternateSchedule</c> / <c>ReplacePlayoutTemplate</c> goes through the HTTP
|
||||
/// request records, which normalize with <c>?? []</c>. What makes it a latent gun is that
|
||||
/// <c>ReplacePlayoutAlternateScheduleItemsHandler</c> / <c>ReplacePlayoutTemplateItemsHandler</c>
|
||||
/// assign the command value straight onto the entity, so nothing in the write path itself refuses.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The LEGACY route is narrower than "the columns are nullable", and conflating the two is the easy
|
||||
/// error: all six are <c>nullable: true</c> on both providers, but five of the six were present at
|
||||
/// <c>CreateTable</c>, so a NULL there still needs code to write one. EXACTLY ONE case is
|
||||
/// code-path-free — SQLite's <c>20240113140741_Add_PlayoutTemplate_DaysOfMonth</c> is an
|
||||
/// <c>AddColumn</c> with <c>nullable: true</c> and NO <c>defaultValue</c>, so <c>PlayoutTemplate</c>
|
||||
/// rows inserted before it hold NULL, and by construction those rows had no day-of-month
|
||||
/// restriction. On MySQL <c>PlayoutTemplate</c> arrived whole in
|
||||
/// <c>20240114034944_Add_BlockScheduling</c>, so there is no code-path-free NULL for any of the six
|
||||
/// there. This fixture manufactures its NULL with a raw <c>UPDATE</c>, which is a code path — it
|
||||
/// measures MATERIALIZATION, and the legacy route above is established by reading the migrations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What the selector then does with it is pinned by
|
||||
/// <c>ErsatzTV.Core.Tests.Scheduling.AlternateScheduleSelectorTests.GetScheduleForDate_NullCollections</c>:
|
||||
/// unguarded, <c>.Contains</c> throws <see cref="NullReferenceException" />.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Runs against BOTH providers from ONE fixture body, because the question is about provider
|
||||
/// materialization and a SQLite-only answer would not have settled it. MySQL needs a live server,
|
||||
/// supplied via <c>ETV_TEST_MYSQL_CONNECTION</c>; without it the MySQL fixture <b>ignores</b> — a
|
||||
/// visible skip, never a silent pass. <c>ETV_REQUIRE_MYSQL_TESTS</c> turns that skip into a hard
|
||||
/// failure for a runner that is supposed to have one. This mirrors
|
||||
/// <see cref="LibraryFolderDedupeMigrationTests" />, which established the pattern.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cost, stated.</b> Each test migrates a FRESH database (per-test isolation by construction —
|
||||
/// ersatztv#491 measured what a shared database and a wipe-that-must-succeed cost). Replaying every
|
||||
/// migration is the expensive part and it is deliberate: a reachability fixture should stand on the
|
||||
/// SHIPPED schema, not on one <c>EnsureCreated</c> builds from the current model.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture(TestProvider.Sqlite)]
|
||||
[TestFixture(TestProvider.MySql)]
|
||||
[NonParallelizable]
|
||||
public class SchedulingCollectionColumnNullTests(TestProvider provider)
|
||||
{
|
||||
private const string MySqlConnectionVariable = "ETV_TEST_MYSQL_CONNECTION";
|
||||
private const string MySqlRequiredVariable = "ETV_REQUIRE_MYSQL_TESTS";
|
||||
|
||||
private string _databasePath = null!;
|
||||
private string? _mySqlConnectionString;
|
||||
private DbContextOptions<TvContext> _options = null!;
|
||||
|
||||
private string _originalLastInsertedRowId = null!;
|
||||
private string _originalCollation = null!;
|
||||
private bool _originalIsSqlite;
|
||||
private Func<DbUpdateException, bool> _originalUniqueViolation = null!;
|
||||
|
||||
/// <summary>
|
||||
/// <c>TvContext</c>'s provider statics are process-wide, and the MySQL arm sets them to MySQL
|
||||
/// values. Restoring them is NOT belt-and-braces: <see cref="InMemoryTvContext" /> — the harness
|
||||
/// most of this suite uses — resets only three of the five (<c>IsSqlite</c>,
|
||||
/// <c>IsUniqueConstraintViolation</c>, <c>RegisterUnicodeCaseFunctions</c>) and leaves
|
||||
/// <c>LastInsertedRowId</c> and <c>CaseInsensitiveCollation</c> alone. So without this, a MySQL arm
|
||||
/// running before a SQLite test leaves <c>last_insert_id()</c> in place for a SQLite connection,
|
||||
/// which is an order-dependent failure in a fixture that never mentions MySQL.
|
||||
/// <c>[NonParallelizable]</c> serialises execution; it does not restore state.
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_originalLastInsertedRowId = TvContext.LastInsertedRowId;
|
||||
_originalCollation = TvContext.CaseInsensitiveCollation;
|
||||
_originalIsSqlite = TvContext.IsSqlite;
|
||||
_originalUniqueViolation = TvContext.IsUniqueConstraintViolation;
|
||||
|
||||
if (provider is TestProvider.Sqlite)
|
||||
{
|
||||
TvContext.IsSqlite = true;
|
||||
TvContext.LastInsertedRowId = "last_insert_rowid()";
|
||||
TvContext.CaseInsensitiveCollation = "NOCASE";
|
||||
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
|
||||
|
||||
_databasePath = Path.Combine(Path.GetTempPath(), $"etv823-{Guid.NewGuid():N}.sqlite3");
|
||||
_options = new DbContextOptionsBuilder<TvContext>()
|
||||
.UseSqlite(
|
||||
$"Data Source={_databasePath};Foreign Keys=False",
|
||||
o => o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"))
|
||||
.Options;
|
||||
|
||||
await using TvContext sqlite = Create(_options);
|
||||
await sqlite.Database.MigrateAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
string? baseConnectionString = Environment.GetEnvironmentVariable(MySqlConnectionVariable);
|
||||
if (string.IsNullOrWhiteSpace(baseConnectionString))
|
||||
{
|
||||
string message =
|
||||
$"{MySqlConnectionVariable} is not set, so the MySql half of the #823 measurement cannot "
|
||||
+ "run. Whether a NULL column materializes as CLR null is a provider question, so a "
|
||||
+ "SQLite-only answer does not settle it.";
|
||||
|
||||
if (IsTrue(Environment.GetEnvironmentVariable(MySqlRequiredVariable)))
|
||||
{
|
||||
Assert.Fail($"{message} {MySqlRequiredVariable} is set, so this is a failure, not a skip.");
|
||||
}
|
||||
|
||||
Assert.Ignore($"{message} Set it to run this locally.");
|
||||
}
|
||||
|
||||
// A database of our own, and a FRESH one per test: isolation by construction, per #491's finding
|
||||
// that a shared name trades isolation for a wipe that has to succeed.
|
||||
_mySqlConnectionString =
|
||||
new MySqlConnectionStringBuilder(baseConnectionString) { Database = $"etv823_{Guid.NewGuid():N}" }
|
||||
.ConnectionString;
|
||||
|
||||
TvContext.IsSqlite = false;
|
||||
TvContext.LastInsertedRowId = "last_insert_id()";
|
||||
TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci";
|
||||
TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation;
|
||||
|
||||
ServerVersion serverVersion = ServerVersion.AutoDetect(_mySqlConnectionString);
|
||||
_options = new DbContextOptionsBuilder<TvContext>()
|
||||
.UseMySql(
|
||||
_mySqlConnectionString,
|
||||
serverVersion,
|
||||
o => o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"))
|
||||
.Options;
|
||||
|
||||
// NUnit does not run [TearDown] when [SetUp] throws, and by this point the database exists — the
|
||||
// migration itself created it. Without this, a migration that fails part way strands a database
|
||||
// and its connection pool on a SHARED server, once per attempt.
|
||||
try
|
||||
{
|
||||
await using TvContext mysql = Create(_options);
|
||||
await mysql.Database.MigrateAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await DropMySqlDatabase();
|
||||
_mySqlConnectionString = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DropMySqlDatabase()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using (TvContext context = Create(_options))
|
||||
{
|
||||
await context.Database.EnsureDeletedAsync();
|
||||
}
|
||||
|
||||
await using var probe = new MySqlConnection(_mySqlConnectionString);
|
||||
await MySqlConnection.ClearPoolAsync(probe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never mask the original failure with a cleanup failure, and never fail a PASSING test in
|
||||
// teardown because the server went away -- but say so, because a silent cleanup failure is how
|
||||
// a leak becomes invisible.
|
||||
await TestContext.Out.WriteLineAsync(
|
||||
$"WARNING: could not drop the MySql test database {_mySqlConnectionString}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown()
|
||||
{
|
||||
TvContext.LastInsertedRowId = _originalLastInsertedRowId;
|
||||
TvContext.CaseInsensitiveCollation = _originalCollation;
|
||||
TvContext.IsSqlite = _originalIsSqlite;
|
||||
TvContext.IsUniqueConstraintViolation = _originalUniqueViolation;
|
||||
|
||||
if (provider is TestProvider.Sqlite)
|
||||
{
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (string path in new[] { _databasePath, $"{_databasePath}-wal", $"{_databasePath}-shm" })
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mySqlConnectionString is not null)
|
||||
{
|
||||
await DropMySqlDatabase();
|
||||
_mySqlConnectionString = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A row whose three columns are NULL materializes as CLR null on every one of them — including
|
||||
/// the two <c>IntCollectionValueConverter</c> columns, whose converter would have produced
|
||||
/// <c>Array.Empty<int>()</c> had it been invoked.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task A_Null_Column_Materializes_As_Clr_Null_On_All_Six()
|
||||
{
|
||||
await using (TvContext context = Create(_options))
|
||||
{
|
||||
await DisableForeignKeys(context);
|
||||
|
||||
context.ProgramScheduleAlternates.Add(NewAlternate(playoutId: 1));
|
||||
context.PlayoutTemplates.Add(NewTemplate(playoutId: 1));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Out of band, so the row is byte-identical to a legacy one. This UPDATE is itself a code
|
||||
// path and does NOT demonstrate the legacy route -- see the class docstring for which single
|
||||
// (column, provider) case is genuinely code-path-free. What is being measured here is what EF
|
||||
// MATERIALIZES from such a row, which is the same regardless of how the NULL got there.
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE ProgramScheduleAlternate SET DaysOfWeek = NULL, DaysOfMonth = NULL, MonthsOfYear = NULL");
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE PlayoutTemplate SET DaysOfWeek = NULL, DaysOfMonth = NULL, MonthsOfYear = NULL");
|
||||
}
|
||||
|
||||
// ANTI-VACUITY: a read that found no row would leave every ShouldBeNull below trivially true,
|
||||
// so both reads are Single and would throw on an empty table.
|
||||
await using (TvContext context = Create(_options))
|
||||
{
|
||||
ProgramScheduleAlternate alternate =
|
||||
await context.ProgramScheduleAlternates.AsNoTracking().SingleAsync();
|
||||
|
||||
alternate.DaysOfWeek.ShouldBeNull(
|
||||
"EnumCollectionJsonValueConverter must not be invoked for a NULL column");
|
||||
alternate.DaysOfMonth.ShouldBeNull(
|
||||
"IntCollectionValueConverter's null-to-empty branch must not run — EF skips the "
|
||||
+ "converter for a NULL column, so this is null rather than an empty array");
|
||||
alternate.MonthsOfYear.ShouldBeNull();
|
||||
|
||||
PlayoutTemplate template = await context.PlayoutTemplates.AsNoTracking().SingleAsync();
|
||||
|
||||
template.DaysOfWeek.ShouldBeNull();
|
||||
template.DaysOfMonth.ShouldBeNull();
|
||||
template.MonthsOfYear.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The write half. A caller that hands one of the six a null — which the two Replace* handlers do
|
||||
/// verbatim from their command, and only the HTTP request records guard against — gets a stored
|
||||
/// SQL NULL and no error at all.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Assigning_Null_Persists_Sql_Null_Rather_Than_Throwing()
|
||||
{
|
||||
await using (TvContext context = Create(_options))
|
||||
{
|
||||
await DisableForeignKeys(context);
|
||||
|
||||
ProgramScheduleAlternate alternate = NewAlternate(playoutId: 2);
|
||||
alternate.DaysOfWeek = null!;
|
||||
alternate.DaysOfMonth = null!;
|
||||
alternate.MonthsOfYear = null!;
|
||||
|
||||
context.ProgramScheduleAlternates.Add(alternate);
|
||||
|
||||
// Not Should.NotThrow: the point is that this is the SHIPPED behaviour of the write path, so
|
||||
// the assertion is that the round-trip below finds NULL, not merely that nothing blew up.
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (TvContext context = Create(_options))
|
||||
{
|
||||
ProgramScheduleAlternate reloaded =
|
||||
await context.ProgramScheduleAlternates.AsNoTracking().SingleAsync();
|
||||
|
||||
reloaded.DaysOfWeek.ShouldBeNull();
|
||||
reloaded.DaysOfMonth.ShouldBeNull();
|
||||
reloaded.MonthsOfYear.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ...and it really is SQL NULL in the column, not an empty string the converter round-trips.
|
||||
// ExecuteScalar hands back CLR null both for a NULL column and for NO SUCH ROW, so the row is
|
||||
// counted first — otherwise a fixture that silently deleted its row would report the same thing.
|
||||
await using (TvContext context = Create(_options))
|
||||
{
|
||||
(await ScalarAsync(context, "SELECT COUNT(*) FROM ProgramScheduleAlternate"))
|
||||
.ShouldNotBeNull();
|
||||
Convert.ToInt32(await ScalarAsync(context, "SELECT COUNT(*) FROM ProgramScheduleAlternate"))
|
||||
.ShouldBe(1, "the row is gone, so a null read below would prove nothing");
|
||||
|
||||
(await ScalarAsync(context, "SELECT DaysOfWeek FROM ProgramScheduleAlternate")).ShouldBeNull();
|
||||
(await ScalarAsync(context, "SELECT DaysOfMonth FROM ProgramScheduleAlternate")).ShouldBeNull();
|
||||
(await ScalarAsync(context, "SELECT MonthsOfYear FROM ProgramScheduleAlternate")).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
private static ProgramScheduleAlternate NewAlternate(int playoutId) =>
|
||||
new()
|
||||
{
|
||||
PlayoutId = playoutId,
|
||||
ProgramScheduleId = 1,
|
||||
Index = 0,
|
||||
DaysOfWeek = [],
|
||||
DaysOfMonth = [],
|
||||
MonthsOfYear = [],
|
||||
StartMonth = 1,
|
||||
StartDay = 1,
|
||||
EndMonth = 12,
|
||||
EndDay = 31
|
||||
};
|
||||
|
||||
private static PlayoutTemplate NewTemplate(int playoutId) =>
|
||||
new()
|
||||
{
|
||||
PlayoutId = playoutId,
|
||||
TemplateId = 1,
|
||||
Index = 0,
|
||||
DaysOfWeek = [],
|
||||
DaysOfMonth = [],
|
||||
MonthsOfYear = [],
|
||||
StartMonth = 1,
|
||||
StartDay = 1,
|
||||
EndMonth = 12,
|
||||
EndDay = 31
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The rows here are deliberately partial graphs (a <c>PlayoutId</c> pointing at no Playout), so
|
||||
/// foreign keys are off. SQLite takes it as a connection-string keyword; MySQL's
|
||||
/// <c>foreign_key_checks</c> is a SESSION variable, so it is set on the context's own connection
|
||||
/// and lives as long as that context does.
|
||||
/// </summary>
|
||||
private async Task DisableForeignKeys(TvContext context)
|
||||
{
|
||||
if (provider is TestProvider.MySql)
|
||||
{
|
||||
await context.Database.OpenConnectionAsync();
|
||||
await context.Database.ExecuteSqlRawAsync("SET SESSION foreign_key_checks = 0");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<object?> ScalarAsync(TvContext context, string sql)
|
||||
{
|
||||
await context.Database.OpenConnectionAsync();
|
||||
await using System.Data.Common.DbCommand command = context.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = sql;
|
||||
object? value = await command.ExecuteScalarAsync();
|
||||
return value is DBNull ? null : value;
|
||||
}
|
||||
|
||||
private static bool IsTrue(string? value) =>
|
||||
!string.IsNullOrWhiteSpace(value)
|
||||
&& (value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static TvContext Create(DbContextOptions<TvContext> options) =>
|
||||
new(
|
||||
options,
|
||||
NullLoggerFactory.Instance,
|
||||
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using NUnit.Framework;
|
||||
using NUnit.Framework.Interfaces;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// ersatztv#824. The defect that produced #824 was not that <c>ElasticSearchIndex</c> was hard to
|
||||
/// test — it was that NOTHING NOTICED it had no cover. #701 fixed two independent copies of the same
|
||||
/// <c>UpdateSong</c> logic and pinned one; the suite stayed green, and the gap survived on a
|
||||
/// hand-written list of what had been covered (namely, one entry).
|
||||
/// <para>
|
||||
/// So the covered set is compared against a population DERIVED FROM THE ASSEMBLY rather than
|
||||
/// restated: a third <see cref="ISearchIndex" /> implementation reddens this test until it is
|
||||
/// given a mutation fixture of its own. That is
|
||||
/// <c>testing.guard-derives-population-from-source</c> applied to a test population instead of a
|
||||
/// file population.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Scope, stated rather than implied.</b> The derivation is over the assembly that declares
|
||||
/// both indexers (<c>ErsatzTV.Infrastructure</c>). An implementation added in a DIFFERENT
|
||||
/// assembly is outside what this sees — it is not covered and this test cannot say so. Both
|
||||
/// implementations have lived here since the interface existed, so the narrower scope buys a
|
||||
/// guard that cannot be defeated by an unrelated assembly load order; widening it to every
|
||||
/// loaded assembly would be the false-confidence version of the same check.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class SearchIndexMutationCoverageTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The indexers whose <c>UpdateSong</c> is pinned against the ersatztv#701 mutation. Referenced by
|
||||
/// TYPE, so renaming an indexer or deleting a fixture is a compile error rather than a silent
|
||||
/// divergence.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<Type, Type> CoveredBy = new()
|
||||
{
|
||||
[typeof(LuceneSearchIndex)] = typeof(SongIndexerMetadataMutationTests),
|
||||
[typeof(ElasticSearchIndex)] = typeof(ElasticSongIndexerMetadataMutationTests)
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Every_ISearchIndex_Implementation_Has_A_Metadata_Mutation_Fixture()
|
||||
{
|
||||
List<Type> implementations = typeof(LuceneSearchIndex).Assembly
|
||||
.GetTypes()
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false })
|
||||
.Where(t => typeof(ISearchIndex).IsAssignableFrom(t))
|
||||
.OrderBy(t => t.FullName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// The set comparison below would ALREADY fail on an empty derivation, because the expected side
|
||||
// is non-empty -- so this floor is not load-bearing for correctness and saying it is would be a
|
||||
// false claim about a check. It is a DIAGNOSTIC: it separates "the reflection stopped finding
|
||||
// types" (a moved type, a renamed interface) from "someone added an indexer", which the set
|
||||
// comparison alone reports identically.
|
||||
implementations.Count.ShouldBeGreaterThanOrEqualTo(
|
||||
2,
|
||||
"the ISearchIndex population derivation found almost nothing -- this is the derivation "
|
||||
+ "breaking, not an indexer being added");
|
||||
|
||||
implementations.ShouldBe(
|
||||
CoveredBy.Keys.OrderBy(t => t.FullName, StringComparer.Ordinal),
|
||||
ignoreOrder: false,
|
||||
"every ISearchIndex implementation needs an UpdateSong metadata-mutation fixture — see "
|
||||
+ "ersatztv#824, where a second copy of the same logic went uncovered and an Elastic-only "
|
||||
+ "reintroduction of `metadata.Artists ??= []` left the whole suite green");
|
||||
|
||||
// Comparing the KEYS alone would leave the mapping half-checked: a third indexer could be pointed
|
||||
// at an EXISTING fixture, or at a fixture class holding no runnable test, and the set comparison
|
||||
// above would still pass. Both are closed here. What NO static check can establish is that the
|
||||
// named fixture actually DRIVES its indexer -- that is stated as a residual on this guard rather
|
||||
// than implied away, and it is why the record claims a third implementation cannot be added
|
||||
// WITHOUT NOTICE, not that it cannot be mis-covered.
|
||||
CoveredBy.Values.Distinct().Count().ShouldBe(
|
||||
CoveredBy.Count,
|
||||
"two indexers are mapped to the SAME fixture, so one of them is not actually covered");
|
||||
|
||||
foreach ((Type indexer, Type fixture) in CoveredBy)
|
||||
{
|
||||
// Test-ness is decided by NUnit's INTERFACES, not by a hand-listed set of attribute types.
|
||||
// A list has to be kept in step with NUnit and silently falsely-reddens whatever it omits --
|
||||
// an earlier revision listed TestAttribute alone (so a [TestCase]-only fixture failed), then
|
||||
// three types (so a [Theory] one did). ITestBuilder/ISimpleTestBuilder is what NUnit itself
|
||||
// dispatches on, so it cannot fall behind the vocabulary.
|
||||
//
|
||||
// DeclaredOnly is load-bearing: without it a fixture that merely SUBCLASSES another inherits
|
||||
// its [Test] and satisfies this while driving the wrong indexer -- and Values.Distinct()
|
||||
// cannot catch that, since the two Types differ. Static is included because NUnit runs a
|
||||
// public static test method in a non-static fixture, and omitting the flag would falsely
|
||||
// redden one.
|
||||
const BindingFlags TestMethods =
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
||||
|
||||
static bool NeverRuns(object[] attributes) =>
|
||||
attributes.Any(a => a is ExplicitAttribute or IgnoreAttribute);
|
||||
|
||||
bool declaresARunnableTest = fixture
|
||||
.GetMethods(TestMethods)
|
||||
.Select(m => m.GetCustomAttributes(inherit: true))
|
||||
.Any(attrs =>
|
||||
attrs.Any(a => a is ITestBuilder or ISimpleTestBuilder) && !NeverRuns(attrs));
|
||||
|
||||
declaresARunnableTest.ShouldBeTrue(
|
||||
$"{fixture.Name} is named as the mutation fixture for {indexer.Name} but DECLARES no "
|
||||
+ "runnable test method of its own, so it proves nothing");
|
||||
|
||||
// The same "wired is not running" failure at fixture level, in its three forms: an abstract
|
||||
// class NUnit will not instantiate, and [Explicit]/[Ignore]. The indexer population at the top
|
||||
// of this method already filters IsAbstract; the fixture side needs the mirror of that.
|
||||
// `IsAbstract` ALONE is wrong here and would have been a false red: a C# `static class`
|
||||
// compiles to `abstract sealed`, and NUnit runs a test declared in one. What must be rejected
|
||||
// is an abstract BASE (abstract and NOT sealed), which NUnit cannot instantiate. Deliberately
|
||||
// no in-repo witness is cited: `AlternateScheduleSelectorTests` is a static class, but it
|
||||
// merely NESTS its `[TestFixture]`es and declares no test of its own, so it would fail the
|
||||
// sibling assertion above instead -- naming it here would have been a wrong example attached
|
||||
// to a right rule.
|
||||
(fixture.IsAbstract && !fixture.IsSealed).ShouldBeFalse(
|
||||
$"{fixture.Name} is named as the mutation fixture for {indexer.Name} but is an abstract "
|
||||
+ "base class, so NUnit never runs it");
|
||||
NeverRuns(fixture.GetCustomAttributes(inherit: true)).ShouldBeFalse(
|
||||
$"{fixture.Name} is named as the mutation fixture for {indexer.Name} but is [Explicit] or "
|
||||
+ "[Ignore]d, so it never runs and proves nothing");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Lucene.Net.Analysis.Standard;
|
||||
using Lucene.Net.Index;
|
||||
using Lucene.Net.Store;
|
||||
using Lucene.Net.Util;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// ersatztv#701, EXECUTED against a real <see cref="TvContext" /> on SQLite.
|
||||
/// <para>
|
||||
/// <b>The defect.</b> <c>LuceneSearchIndex.UpdateSong</c> opened with
|
||||
/// <c>metadata.AlbumArtists ??= []; metadata.Artists ??= [];</c>. Unlike the navigation
|
||||
/// collections guarded the same way all around them, these two are SCALAR COLUMNS: the whole
|
||||
/// list lives in one column, so the property IS the column value. They are two of EIGHT such
|
||||
/// columns in the model — the population is derived from the MODEL CONFIGURATION (EF-native
|
||||
/// primitive collections plus the six <c>HasConversion<*CollectionValueConverter></c>
|
||||
/// columns on <c>ProgramScheduleAlternate</c>/<c>PlayoutTemplate</c>), NOT by grepping the
|
||||
/// domain classes for <c>IList<string></c>, which finds only two of the eight. See
|
||||
/// the decision record <c>media.nullable-primitive-collection-mutation</c>.
|
||||
/// Assigning one on a TRACKED entity flips it to <see cref="EntityState.Modified" />, and the next
|
||||
/// <c>SaveChanges</c> writes <c>[]</c> over what the database held as <c>NULL</c> — the exact
|
||||
/// mechanism an adversarial review demonstrated in ersatztv#691, which is why that issue's
|
||||
/// entity-level guard was reverted in favour of guarding at the READ SITE.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the fixture loads the song TRACKED even though production does not.</b> Both feeds into
|
||||
/// the indexer are <c>AsNoTracking()</c> today — <c>SearchRepository.GetItemToIndex</c> and
|
||||
/// <c>SearchRepository.GetAllSongs</c> — so no shipped caller loses data. That is a property of
|
||||
/// today's two callers, not of the indexer, and it is exactly what ersatztv#691 recorded as "a
|
||||
/// loaded gun". This fixture therefore pins the INDEXER's own contract: handed a tracked entity it
|
||||
/// must not mutate it. Run against the real pre-fix file, the FIRST of the numbered assertions
|
||||
/// below fails (<c>metadata.Artists should be null but was []</c>) and the run stops there;
|
||||
/// reaching the persistence half needs a probe variant with assertions 1 and 2 replaced by
|
||||
/// prints, which reports <c>Modified</c> and the column moving from <c>NULL</c> to <c>[]</c>.
|
||||
/// Each was separately shown discriminating. A future caller that drops <c>AsNoTracking</c>
|
||||
/// therefore cannot reintroduce the data loss silently.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The Lucene <see cref="IndexWriter" /> is injected into the private field rather than obtained via
|
||||
/// <c>Initialize</c>, because <c>Initialize</c> writes to <c>FileSystemLayout.SearchIndexFolder</c> —
|
||||
/// a process-wide static resolved once from <c>ETV_CONFIG_FOLDER</c>, i.e. the developer's real
|
||||
/// application data folder. Letting the writer throw instead is NOT an option here: the
|
||||
/// <c>catch</c> in <c>UpdateSong</c> assigns <c>metadata.Song = null</c>, which would itself dirty
|
||||
/// the entity under test and make the probe report the wrong cause.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class SongIndexerMetadataMutationTests
|
||||
{
|
||||
[Test]
|
||||
public async Task UpdateSong_Must_Not_Mutate_Nullable_Artists_On_A_Tracked_Entity()
|
||||
{
|
||||
await using var harness = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
int metadataId;
|
||||
await using (TvContext context = harness.CreateContext())
|
||||
{
|
||||
var library = new LocalLibrary { Name = "Music", MediaKind = LibraryMediaKind.Songs };
|
||||
context.Add(library);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var libraryPath = new LibraryPath { Path = "/music", LibraryId = library.Id };
|
||||
context.Add(libraryPath);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var song = new Song
|
||||
{
|
||||
LibraryPathId = libraryPath.Id,
|
||||
MediaVersions = [],
|
||||
SongMetadata =
|
||||
[
|
||||
new SongMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = "Untagged Track",
|
||||
SortTitle = "untagged track",
|
||||
DateAdded = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
|
||||
// The shape FallbackMetadataProvider.GetSongMetadata leaves behind: it never
|
||||
// assigns either primitive collection, so both columns persist as NULL.
|
||||
Artists = null!,
|
||||
AlbumArtists = null!,
|
||||
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Artwork = [],
|
||||
Guids = []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
context.Add(song);
|
||||
await context.SaveChangesAsync();
|
||||
metadataId = song.SongMetadata[0].Id;
|
||||
}
|
||||
|
||||
// The seed must actually have produced NULL columns, or every assertion below is vacuous.
|
||||
(await ReadRawArtists(harness, metadataId)).ShouldBeNull();
|
||||
|
||||
await using (TvContext context = harness.CreateContext())
|
||||
{
|
||||
// Deliberately TRACKED -- see the fixture docstring.
|
||||
Song tracked = await context.Songs
|
||||
.IncludeForSearch()
|
||||
.AsSplitQuery()
|
||||
.SingleAsync();
|
||||
|
||||
SongMetadata metadata = tracked.SongMetadata[0];
|
||||
metadata.Artists.ShouldBeNull("EF must materialize the NULL column as null, not as an empty list");
|
||||
|
||||
// UpdateSong wraps its whole body in a catch that logs a warning and assigns
|
||||
// `metadata.Song = null` -- which severs a required relationship and cascades the metadata to
|
||||
// Deleted. A silently-exercised catch would therefore make every assertion below report the
|
||||
// wrong cause, so the logger fails the test instead of swallowing.
|
||||
var logger = new ThrowOnWarningLogger<LuceneSearchIndex>();
|
||||
var index = new LuceneSearchIndex(
|
||||
new SearchQueryParser(
|
||||
Substitute.For<ISmartCollectionCache>(),
|
||||
Substitute.For<ILogger<SearchQueryParser>>()),
|
||||
logger);
|
||||
|
||||
using var directory = new RAMDirectory();
|
||||
using var writer = new IndexWriter(
|
||||
directory,
|
||||
new IndexWriterConfig(LuceneVersion.LUCENE_48, new StandardAnalyzer(LuceneVersion.LUCENE_48)));
|
||||
|
||||
typeof(LuceneSearchIndex)
|
||||
.GetField("_writer", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(index, writer);
|
||||
|
||||
// A bare substitute returns null from GetAllLanguageCodes, which NPEs inside AddLanguages and
|
||||
// would divert the run into the catch above.
|
||||
var languageCodeService = Substitute.For<ILanguageCodeService>();
|
||||
languageCodeService.GetAllLanguageCodes(Arg.Any<List<string>>()).Returns([]);
|
||||
languageCodeService.GetAllLanguageCodes(Arg.Any<string>()).Returns([]);
|
||||
|
||||
await index.UpdateItems(
|
||||
Substitute.For<ISearchRepository>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
languageCodeService,
|
||||
[tracked]);
|
||||
|
||||
logger.Failure.ShouldBeNull("UpdateSong threw and its catch ran, so this probe measured the "
|
||||
+ "error path rather than the indexing path");
|
||||
|
||||
// POSITIVE CONTROL. Every assertion below asserts that something did NOT happen, so all of
|
||||
// them hold vacuously if UpdateSong never ran at all -- and it silently stops running if a
|
||||
// future refactor gates UpdateItems on `_initialized`, which this fixture deliberately
|
||||
// bypasses by injecting the writer. Verified BOTH ways by adding
|
||||
// `if (!_initialized) { return Unit.Default; }` to UpdateItems (a bare `return;` does not
|
||||
// compile there -- CS0126): with this line present it is the only failure, and with it
|
||||
// removed the whole test PASSES while the code under test is unreachable.
|
||||
// NumDocs == 1 proves the song-indexing path ran; it does NOT prove the artist loops
|
||||
// specifically ran, which would need a second seeded song asserting ArtistField.
|
||||
writer.NumDocs.ShouldBe(1, "UpdateSong did not index the song, so the assertions below "
|
||||
+ "would pass without exercising the code under test");
|
||||
|
||||
// 1. The indexer left the entity alone.
|
||||
metadata.Artists.ShouldBeNull();
|
||||
metadata.AlbumArtists.ShouldBeNull();
|
||||
|
||||
// 2. ...so EF has nothing to persist. This is the assertion that fails loudly the day the
|
||||
// mutation returns, even if a later refactor stopped the value from being observable above.
|
||||
context.Entry(metadata).State.ShouldBe(EntityState.Unchanged);
|
||||
|
||||
// 3. And the save that a real caller would go on to make does not rewrite the column.
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
(await ReadRawArtists(harness, metadataId)).ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task<object?> ReadRawArtists(InMemoryTvContext harness, int metadataId)
|
||||
{
|
||||
await using TvContext context = harness.CreateContext();
|
||||
await using var command = context.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = $"SELECT Artists FROM SongMetadata WHERE Id = {metadataId}";
|
||||
object? value = await command.ExecuteScalarAsync();
|
||||
|
||||
// ExecuteScalar returns CLR null both for "the column is NULL" and for "there is no such row",
|
||||
// and the second is reachable: UpdateSong's catch assigns metadata.Song = null, which severs a
|
||||
// required relationship and cascades the row to Deleted, so a SaveChanges on the error path
|
||||
// DELETES it and a plain null check would pass for the wrong reason.
|
||||
if (value is null)
|
||||
{
|
||||
Assert.Fail($"SongMetadata row {metadataId} no longer exists, so its Artists column cannot "
|
||||
+ "be read -- the probe measured a deleted row rather than a preserved NULL.");
|
||||
}
|
||||
|
||||
return value is DBNull ? null : value;
|
||||
}
|
||||
}
|
||||
@@ -126,70 +126,6 @@ public class ApiKeyProviderTests
|
||||
key.ShouldMatch("^[0-9a-f]{64}$");
|
||||
}
|
||||
|
||||
// ---- Api:RequireKeyForReads, read through the REAL provider (ersatztv#779, detector F) ----
|
||||
//
|
||||
// Every other assertion about the read-gating posture goes through a hand-written
|
||||
// FakeApiKeyProvider that is HANDED the bool (ApiAuthorizationFilterTests,
|
||||
// ApiKeyEndpointRequiresKeyTests). Those fakes prove the FILTER reacts to the flag; they cannot
|
||||
// see the line that DERIVES it, because they never run it. Until these tests, nothing in the
|
||||
// suite constructed ApiKeyProvider at all, so a mistyped configuration key or a flipped default
|
||||
// would have left the whole suite green while shipping anonymous reads (#280/#282).
|
||||
//
|
||||
// Api:WriteKey is set in every case purely so ResolveKey returns before touching the real
|
||||
// FileSystemLayout.ApiKeyPath — the constructor would otherwise generate and persist a key into
|
||||
// the live config volume. It is deliberately NOT the subject of these tests.
|
||||
private static ApiKeyProvider ProviderWith(params (string Key, string Value)[] settings)
|
||||
{
|
||||
// The WriteKey entry is appended LAST so a caller cannot override it to empty. That is not
|
||||
// hypothetical tidiness: an empty Api:WriteKey sends ResolveKey down the real path, which
|
||||
// reads, generates and PERSISTS a key into the live config volume (FileSystemLayout
|
||||
// .ApiKeyPath) from a unit test.
|
||||
var withKey = new List<(string, string)>(settings) { (ApiKeyProvider.WriteKeyConfigurationKey, "test-key") };
|
||||
return new ApiKeyProvider(Config(withKey.ToArray()), NullLogger<ApiKeyProvider>.Instance);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Read_Gating_Is_Required_When_The_Setting_Is_Absent()
|
||||
{
|
||||
// The shipped default, and the case a fixture that simply OMITS the field would test by
|
||||
// accident. Asserted explicitly so it is a pinned decision rather than a coincidence.
|
||||
ProviderWith().RequireKeyForReads.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[TestCase("true")]
|
||||
[TestCase("True")]
|
||||
[TestCase("TRUE")]
|
||||
public void Read_Gating_Is_Required_At_The_Explicit_Production_Value(string configured)
|
||||
{
|
||||
// The DENY path at the production value, which is the half #756 showed can stay invisible:
|
||||
// the absent case behaving correctly says nothing about the configured one.
|
||||
ProviderWith((ApiKeyProvider.RequireKeyForReadsConfigurationKey, configured))
|
||||
.RequireKeyForReads.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase("1")]
|
||||
[TestCase("yes")]
|
||||
public void A_Non_Boolean_Read_Gating_Value_Fails_Startup_Rather_Than_Reads(string configured)
|
||||
{
|
||||
// The fourth cell of the matrix, and the one an operator actually hits: `Api__RequireKeyForReads=`
|
||||
// with nothing after it in a compose file, or a habitual `1`/`yes`. ConfigurationBinder returns
|
||||
// the default ONLY for a null section value, so any present-but-unparseable string goes through
|
||||
// BooleanConverter and throws. That is fail-CLOSED — the app refuses to start rather than
|
||||
// quietly choosing a posture — and it is pinned here so a future switch to a lenient parse
|
||||
// (TryParse with a fallback) cannot silently turn a typo into anonymous reads.
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
ProviderWith((ApiKeyProvider.RequireKeyForReadsConfigurationKey, configured)));
|
||||
}
|
||||
|
||||
[TestCase("false")]
|
||||
[TestCase("False")]
|
||||
public void Read_Gating_Is_Waived_Only_By_An_Explicit_Opt_Out(string configured)
|
||||
{
|
||||
ProviderWith((ApiKeyProvider.RequireKeyForReadsConfigurationKey, configured))
|
||||
.RequireKeyForReads.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_A_Usable_Key_Even_When_Persist_Fails()
|
||||
{
|
||||
|
||||
@@ -88,8 +88,7 @@ public abstract class ChannelHandlerTestBase
|
||||
bool showInEpg = false,
|
||||
string logoPath = "",
|
||||
string name = "Test",
|
||||
string group = "ErsatzTV",
|
||||
StreamingMode streamingMode = StreamingMode.TransportStreamHybrid) =>
|
||||
string group = "ErsatzTV") =>
|
||||
new(
|
||||
name,
|
||||
number,
|
||||
@@ -106,7 +105,7 @@ public abstract class ChannelHandlerTestBase
|
||||
ChannelPlayoutMode.Continuous,
|
||||
null,
|
||||
null,
|
||||
streamingMode,
|
||||
StreamingMode.TransportStreamHybrid,
|
||||
null,
|
||||
null,
|
||||
string.Empty,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Captures the first warning-or-worse a component logs, so a fixture can fail on it instead of
|
||||
/// silently measuring an error path.
|
||||
/// <para>
|
||||
/// Both search indexers wrap each <c>Update*</c> body in a <c>catch</c> that logs a warning and
|
||||
/// assigns <c>metadata.Song = null</c> — which severs a required relationship and cascades the
|
||||
/// metadata row to <see cref="Microsoft.EntityFrameworkCore.EntityState.Deleted" />. A fixture
|
||||
/// that let that catch run quietly would report the wrong cause for every assertion after it, and
|
||||
/// on the first run of the ersatztv#701 probe it did exactly that (a bare
|
||||
/// <c>ILanguageCodeService</c> substitute NPEs inside <c>AddLanguages</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ThrowOnWarningLogger<T> : ILogger<T>
|
||||
{
|
||||
public Exception? Failure { get; private set; }
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel >= LogLevel.Warning)
|
||||
{
|
||||
Failure ??= exception ?? new InvalidOperationException(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.ComponentModel;
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -14,11 +13,6 @@ public record CreateFFmpegProfileRequest(
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
[property: Description(
|
||||
"Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool "
|
||||
+ "leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a "
|
||||
+ "value equal to the one already stored is accepted unchanged, so a profile written before this "
|
||||
+ "validation existed stays editable.")]
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
@@ -41,16 +35,7 @@ public record CreateFFmpegProfileRequest(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool? QsvPreferNativeDecoder = null,
|
||||
[property: Description(
|
||||
"Realtime pacing multiplier for the input. Unset keeps the built-in pacing "
|
||||
+ "(1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set.")]
|
||||
double? ReadRate = null,
|
||||
[property: Description(
|
||||
"Rate a lagging realtime input may read at until it is level again. Unset keeps the "
|
||||
+ "built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is "
|
||||
+ "zero headroom, which is functionally no catchup.")]
|
||||
double? ReadRateCatchup = null)
|
||||
bool? QsvPreferNativeDecoder = null)
|
||||
{
|
||||
public CreateFFmpegProfile ToCommand() =>
|
||||
new(
|
||||
@@ -84,7 +69,5 @@ public record CreateFFmpegProfileRequest(
|
||||
NormalizeFramerate,
|
||||
NormalizeColors,
|
||||
DeinterlaceVideo,
|
||||
QsvPreferNativeDecoder ?? true,
|
||||
ReadRate,
|
||||
ReadRateCatchup);
|
||||
QsvPreferNativeDecoder ?? true);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record PlayoutAlternateScheduleItemRequest(
|
||||
int Id,
|
||||
int ProgramScheduleId,
|
||||
List<DayOfWeek>? DaysOfWeek,
|
||||
List<int>? DaysOfMonth,
|
||||
List<int>? MonthsOfYear,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear,
|
||||
bool LimitToDateRange,
|
||||
int StartMonth,
|
||||
int StartDay,
|
||||
@@ -18,26 +16,14 @@ public record PlayoutAlternateScheduleItemRequest(
|
||||
int EndDay,
|
||||
int? EndYear)
|
||||
{
|
||||
// An ABSENT recurrence array means UNRESTRICTED, not "matches nothing" (#880). The three sets are
|
||||
// conjunctive in AlternateScheduleSelector.GetScheduleForDate -- a miss on any one `continue`s -- so
|
||||
// the previous `?? []` stored an item that could never apply on any date, and returned 200 while
|
||||
// doing it. Absence now normalizes to the same All*() sets the READ side substitutes for a NULL
|
||||
// column (AlternateScheduleSelector's read guard and both Mapper.ProjectToViewModel overloads), so
|
||||
// the two halves of "this field is absent" finally agree -- residual (3) of
|
||||
// `media.nullable-primitive-collection-mutation`.
|
||||
//
|
||||
// These are nullable so that ABSENT is distinguishable from an explicitly-sent `[]`, which is a
|
||||
// different request and is REJECTED with a 422 in PlayoutController rather than normalized here.
|
||||
// Newtonsoft maps both a missing property and an explicit `null` to null, so both read as absent;
|
||||
// only a literal `[]` survives as empty.
|
||||
public ReplacePlayoutAlternateSchedule ToReplaceItem(int index) =>
|
||||
new(
|
||||
Id,
|
||||
index,
|
||||
ProgramScheduleId,
|
||||
DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
DaysOfWeek ?? [],
|
||||
DaysOfMonth ?? [],
|
||||
MonthsOfYear ?? [],
|
||||
LimitToDateRange,
|
||||
StartMonth,
|
||||
StartDay,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
@@ -8,9 +6,9 @@ public record PlayoutTemplateItemRequest(
|
||||
int Id,
|
||||
int TemplateId,
|
||||
int? DecoTemplateId,
|
||||
List<DayOfWeek>? DaysOfWeek,
|
||||
List<int>? DaysOfMonth,
|
||||
List<int>? MonthsOfYear,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear,
|
||||
bool LimitToDateRange,
|
||||
int StartMonth,
|
||||
int StartDay,
|
||||
@@ -19,18 +17,15 @@ public record PlayoutTemplateItemRequest(
|
||||
int EndDay,
|
||||
int? EndYear)
|
||||
{
|
||||
// Same contract as PlayoutAlternateScheduleItemRequest: ABSENT means UNRESTRICTED (the All*() sets
|
||||
// the read side substitutes for a NULL column), an explicit `[]` is rejected with a 422 in
|
||||
// PlayoutController. See that record for the full rationale (#880).
|
||||
public ReplacePlayoutTemplate ToReplaceItem(int index) =>
|
||||
new(
|
||||
Id,
|
||||
index,
|
||||
TemplateId,
|
||||
DecoTemplateId,
|
||||
DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
DaysOfWeek ?? [],
|
||||
DaysOfMonth ?? [],
|
||||
MonthsOfYear ?? [],
|
||||
LimitToDateRange,
|
||||
StartMonth,
|
||||
StartDay,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.ComponentModel;
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -14,11 +13,6 @@ public record UpdateFFmpegProfileRequest(
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
string VaapiDevice,
|
||||
[property: Description(
|
||||
"Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool "
|
||||
+ "leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a "
|
||||
+ "value equal to the one already stored is accepted unchanged, so a profile written before this "
|
||||
+ "validation existed stays editable.")]
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
@@ -41,16 +35,7 @@ public record UpdateFFmpegProfileRequest(
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool? QsvPreferNativeDecoder = null,
|
||||
[property: Description(
|
||||
"Realtime pacing multiplier for the input. Unset keeps the built-in pacing "
|
||||
+ "(1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set.")]
|
||||
double? ReadRate = null,
|
||||
[property: Description(
|
||||
"Rate a lagging realtime input may read at until it is level again. Unset keeps the "
|
||||
+ "built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is "
|
||||
+ "zero headroom, which is functionally no catchup.")]
|
||||
double? ReadRateCatchup = null)
|
||||
bool? QsvPreferNativeDecoder = null)
|
||||
{
|
||||
public UpdateFFmpegProfile ToCommand(int id) =>
|
||||
new(
|
||||
@@ -85,7 +70,5 @@ public record UpdateFFmpegProfileRequest(
|
||||
NormalizeFramerate,
|
||||
NormalizeColors,
|
||||
DeinterlaceVideo,
|
||||
QsvPreferNativeDecoder ?? true,
|
||||
ReadRate,
|
||||
ReadRateCatchup);
|
||||
QsvPreferNativeDecoder ?? true);
|
||||
}
|
||||
|
||||
@@ -90,8 +90,7 @@ public class DatabaseMigratorService : BackgroundService
|
||||
await DbInitializer.Initialize(dbContext, stoppingToken);
|
||||
|
||||
var fileSystem = scope.ServiceProvider.GetRequiredService<System.IO.Abstractions.IFileSystem>();
|
||||
await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, _logger, stoppingToken);
|
||||
await GraphicsElementSeeder.AttachOnNowNextByDefault(dbContext, stoppingToken);
|
||||
await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, stoppingToken);
|
||||
|
||||
_systemStartup.DatabaseIsReady();
|
||||
|
||||
|
||||
@@ -25391,7 +25391,6 @@
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a value equal to the one already stored is accepted unchanged, so a profile written before this validation existed stays editable.",
|
||||
"format": "int32"
|
||||
},
|
||||
"resolutionId": {
|
||||
@@ -25479,22 +25478,6 @@
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"description": "Realtime pacing multiplier for the input. Unset keeps the built-in pacing (1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set.",
|
||||
"format": "double"
|
||||
},
|
||||
"readRateCatchup": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"description": "Rate a lagging realtime input may read at until it is level again. Unset keeps the built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is zero headroom, which is functionally no catchup.",
|
||||
"format": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -26502,9 +26485,7 @@
|
||||
"normalizeFramerate",
|
||||
"normalizeColors",
|
||||
"deinterlaceVideo",
|
||||
"qsvPreferNativeDecoder",
|
||||
"readRate",
|
||||
"readRateCatchup"
|
||||
"qsvPreferNativeDecoder"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -26623,20 +26604,6 @@
|
||||
},
|
||||
"qsvPreferNativeDecoder": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"readRate": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"readRateCatchup": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"format": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -32049,7 +32016,6 @@
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"description": "Extra surfaces in the QSV upload pool. Must be at least 64 when set; a smaller pool leaves no headroom for frames in flight and the transcode writes nothing at all. On update, a value equal to the one already stored is accepted unchanged, so a profile written before this validation existed stays editable.",
|
||||
"format": "int32"
|
||||
},
|
||||
"resolutionId": {
|
||||
@@ -32137,22 +32103,6 @@
|
||||
"null",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"readRate": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"description": "Realtime pacing multiplier for the input. Unset keeps the built-in pacing (1.05, or 1.0 for a stream copy). Must be between 1.0 and 2.0 when set.",
|
||||
"format": "double"
|
||||
},
|
||||
"readRateCatchup": {
|
||||
"type": [
|
||||
"null",
|
||||
"number"
|
||||
],
|
||||
"description": "Rate a lagging realtime input may read at until it is level again. Unset keeps the built-in 6.0. Must be between 1.0 and 10.0, and GREATER than the read rate — equal is zero headroom, which is functionally no catchup.",
|
||||
"format": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -816,7 +816,7 @@
|
||||
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
|
||||
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
|
||||
<span style={{ ...mono, minWidth: 42, textAlign: "right", font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
|
||||
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
|
||||
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
|
||||
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
|
||||
|
||||
@@ -816,7 +816,7 @@
|
||||
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
|
||||
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
|
||||
<span style={{ ...mono, minWidth: 42, textAlign: "right", font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
|
||||
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
|
||||
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
|
||||
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user