Compare commits

..
Author SHA1 Message Date
timothyandClaude Opus 4.8 c1c6486321 ci(338): distinguish ZAP warning (exit 2) from failure (exit 1) in security-scan
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m36s
fixes #338

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:36:45 +02:00
753 changed files with 5996 additions and 171751 deletions
+53 -11
View File
@@ -1,12 +1,54 @@
#!/usr/bin/env bash
# ersatztv#521 — the line-level append-only mechanic is retired. Decision integrity is now enforced by
# the lifecycle validator. A `Decisions-Edit: yes` trailer survives ONLY for rationale-prose edits (validator
# 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
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
rc=$?
[ "$rc" -eq 1 ] && exit 1 # only a real validation failure blocks
exit 0 # crashes/other codes -> fail-open
# ersatztv#303 H9 — docs/decisions.md is append-only. This blocks a commit / PR that DELETES or
# MODIFIES an existing line of that file; pure INSERTIONS anywhere are always allowed (adding a new
# entry inserts a TOC line near the top AND appends a block at the bottom — both are insertions, so
# numstat reports 0 deleted lines). A genuine factual fix to a past entry is the one legitimate edit:
# put the literal token [decisions-edit] in the commit message to override.
#
# Fail-open: any tooling trouble (unknown mode, non-numeric numstat, missing refs) -> allow. The point
# is to catch the accidental rewrite-history case, never to wedge a legitimate commit.
#
# Assumes decisions.md ends with a trailing newline (it does; .editorconfig enforces it). If that final
# newline were ever dropped, git would render the next append as a modify of the last line (deleted=1)
# and this would false-block the append until the author adds [decisions-edit] — cheap and self-correcting.
#
# Modes:
# staged <msgfile> pre-commit/commit-msg — staged diff vs HEAD; trailer read from <msgfile>
# range <base> <head> CI (PR) — merge-base diff base...head; trailer scanned across base..head msgs
set -euo pipefail
FILE="docs/decisions.md"
mode="${1:-}"
case "$mode" in
staged)
deleted=$(git diff --cached --numstat -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(cat "${2:-/dev/null}" 2>/dev/null || true)
;;
range)
base="${2:-}"; head="${3:-}"
[ -n "$base" ] && [ -n "$head" ] || exit 0 # missing refs -> fail-open
deleted=$(git diff --numstat "$base...$head" -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(git log --format='%B' "$base..$head" 2>/dev/null || true)
;;
*)
exit 0 # unknown mode -> fail-open
;;
esac
# Empty (no change to the file) or '-' (binary) -> treat as 0 (fail-open / nothing to guard).
deleted="${deleted:-0}"
case "$deleted" in ''|*[!0-9]*) deleted=0 ;; esac
[ "$deleted" -gt 0 ] || exit 0 # pure insertion / no change -> allow
# Explicit override for a documented factual fix.
if printf '%s' "$msg" | grep -qiF '[decisions-edit]'; then
exit 0
fi
{
echo "decisions-guard (ersatztv#303 H9): docs/decisions.md is append-only — this change deletes/modifies ${deleted} existing line(s)."
echo " Append new entries at the bottom (plus a TOC line in the Index); do not rewrite settled entries."
echo " To fix a genuine factual error in a past entry, add the token [decisions-edit] to the commit message."
} >&2
exit 1
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
# PreToolUse / Agent — ask when an agent is dispatched without an explicit `model`.
#
# The kickoff prompt (docs/handoffs/chicorytv-issue-queue.md) says to route by capability: cheap/fast
# for bounded recon, mid tier for a mechanical slice against a documented contract, orchestrator tier
# for judgment-heavy work. That rule lived only in prose, and on 2026-07-25 an orchestrator dispatched
# two implementers with `model` omitted — both silently inherited the Opus orchestrator tier. Nothing
# in the session report revealed it; the operator had to ask.
#
# WHY a hook: omitting `model` is the SILENT path. Every other constraint in that kickoff has a hook,
# a CI job or a script behind it, and those were all followed in the same session — the one rule with
# no forcing function was the one that got defaulted. A check that runs beats a rule you must remember
# (the same reasoning as pretooluse-bom-guard.sh).
#
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. The first cut
# tried to be clever: it fired only when the prompt text matched implementer signals (`git commit`,
# `worktree`, `fixes #`…). Review of that version (#583) confirmed the heuristic both over- and
# under-fired — a read-only recon brief mentioning "worktree" nagged, while "author the change and
# open a PR", "land this on the branch" and "make the changes and commit them" all sailed through
# silently, i.e. it missed the exact case it existed to catch. Prompt prose is not a reliable signal
# for authority, and a gate with an unreliable catch rate is worse than an honest one.
#
# Two further reasons the broad form is correct here:
# - The HARD CONSTRAINT itself says "every dispatched agent". A narrower hook contradicted the rule
# it was built to enforce.
# - Routing matters MOST for the cheap cases. The old exemption list ("read-only, so routing barely
# matters") had it backwards: bounded recon is precisely what should be explicitly routed DOWN to
# a fast tier, and that review also showed the premise was false — Explore, Plan and
# claude-code-guide all carry Bash, so none of them provably "cannot commit".
#
# The prompt costs nothing to avoid: name a tier and this never fires. That is the habit being built.
#
# Exempt: `fork` only — a fork ALWAYS inherits the parent model and the tool IGNORES a `model`
# override, so asking would demand something unachievable.
#
# "ask", never "deny": routing is a judgment call with no derivable right answer, unlike the
# merge-consent gate (H6/H10) which derives a verifiable state. This gate exists to make an invisible
# default visible, not to impose a tier.
#
# Fail-open by design: any parse trouble -> allow (exit 0, no output).
set -uo pipefail
input=$(cat)
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null || true)
[ "$tool" = "Agent" ] || exit 0
# An explicit choice was made — nothing to surface. This is the path to prefer.
model=$(printf '%s' "$input" | jq -r '.tool_input.model // ""' 2>/dev/null || true)
[ -z "$model" ] || exit 0
subagent=$(printf '%s' "$input" | jq -r '.tool_input.subagent_type // ""' 2>/dev/null || true)
# A fork's model is fixed to the parent's by the tool; a prompt here could not be acted on.
[ "$subagent" = "fork" ] && exit 0
label="${subagent:-general-purpose}"
reason="Dispatching an agent (subagent_type: ${label}) with no explicit \`model\`.
It will silently inherit this session's model — which may be right, but it is a default, not a choice.
Name the tier (and say so in the dispatch message), per the kickoff routing rule
\`process.per-agent-model-routing\`:
- bounded recon / inventory / log triage -> cheapest fast tier (haiku)
- mechanical slice against a documented contract -> mid tier (sonnet)
- judgment-heavy: design, compiler/parser, security,
migrations, review arbitration -> orchestrator tier (opus)
Independent review should also prefer a DIFFERENT model family than the implementer — a cold
same-family review is worth less than a cross-family one.
Pass \`model\` on the Agent call and this never fires. Approve as-is only if inheriting the
orchestrator tier is the deliberate call."
jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'
exit 0
+48 -241
View File
@@ -7,14 +7,6 @@
# (c) a review-verdict comment on the PR references the CURRENT head sha (H10) — proving the
# 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. 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
# additionally refuses to SCHEDULE an auto-merge unless that status is already green on head, so the
# two mechanisms agree at the only moment they can both observe the same commit.
# 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
@@ -85,58 +77,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 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
# prompt); the workflow's copy is ENFORCED (it writes the branch-protection-required
# `review-verdict/h10` status). Four rounds of ersatztv#643 hardening landed here and never reached
# there, leaving the copy with real authority strictly weaker than the copy without — and its safe
# behaviour resting on a bash arithmetic error rather than an intentional guard. Two copies of a
# security predicate drift; one cannot.
#
# What is NOT shared, deliberately: the docs-only allow-list below. This one also lets .claude/,
# .gitea/ and .husky/ through, which is safe HERE only because a match falls through to a human
# prompt rather than auto-granting. The workflow's list is narrower for exactly that reason. Sharing
# the enumeration fixes the drift; sharing the classification would erase an intended difference.
#
# A non-zero exit means "could not tell" and MUST withhold the exemption — never read stdout without
# checking the status. An empty `$sha` (unparseable PR JSON) reaches the script as an empty argument
# and is rejected there, so that path also fails closed.
#
# The 5th argument binds the enumeration to a base branch (ersatztv#698 route 1), because
# `/pulls/{n}/files` diffs against the PR's LIVE base and retargeting moves that without moving the
# head. Be precise about what it buys HERE, which is less than what it buys in the workflow: the
# workflow passes the base from a `pull_request_target` event payload, fixed at event time and beyond
# a retarget's reach, so it detects a retarget outright. This hook has no such trusted snapshot — it
# passes the base it just read from the live PR, so what it asserts is that the base did not move
# between that read and the enumeration. Narrower, and still worth having: without it the hook cannot
# tell a mid-flight retarget from an honest read at all. An empty/unparseable `.base.ref` reaches the
# script as an empty argument and is rejected there, so that path fails closed too.
base_ref=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
files=""; files_complete=no
if files=$("$repo_root/scripts/pr-changed-files.sh" "$owner" "$repo" "$pr" "$sha" "$base_ref" 2>/dev/null); then
files_complete=yes
fi
# HOW THIS PREDICATE IS EVALUATED, matching the enforced gate (ersatztv#698,
# `ci.grep-q-pipefail-inversion`). `printf … | grep -q` INVERTS under `set -o pipefail`: grep -q exits
# at its first match, printf then takes SIGPIPE (141), and a MATCH is reported as a failed pipeline —
# so this negated test would grant a spurious docs-only exemption for any PR whose path list exceeds
# the pipe buffer. A here-string fixes that but is materialised via temporary storage for large inputs,
# so it can fail when temp space is full or unwritable and flip the predicate the same way. Counting
# with `grep -c` drains stdin (no SIGPIPE) over an ordinary pipe (no temp file); `grep -c` exits 1 for
# a zero count, which is a legitimate answer, so only a status >1 is a real error and is treated as
# "cannot tell" -> no exemption.
# Advisory here, so the blast radius is a missing prompt rather than a green required check; the
# construct is identical on purpose, because the two copies drifting is what ersatztv#649 was about.
docs_nonmatching=$(printf '%s\n' "$files" | grep -cvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)') || docs_grep_status=$?
if [ "${docs_grep_status:-0}" -gt 1 ]; then
docs_nonmatching=1 # grep itself failed: cannot tell, so withhold the exemption
fi
if [ "$files_complete" = yes ] && [ -n "$files" ] && [ "${docs_nonmatching:-1}" -eq 0 ]; then
files=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=100" | jq -r '.[].filename // empty' 2>/dev/null || true)
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
# Docs/process-only PR: the Done-when + review-verdict gate doesn't apply — but this exemption is a
# file-TYPE bypass, NOT the a+b+c "provably reviewed & ready" proof, so it does NOT auto-grant. It
# passes through to normal permissioning (one prompt). This deliberately keeps a human in the loop for
@@ -146,76 +88,6 @@ if [ "$files_complete" = yes ] && [ -n "$files" ] && [ "${docs_nonmatching:-1}"
decide allow "" # passthrough (exit 0 → normal prompt), NOT grant
fi
# --- Base-change detection: a verdict is bound to a head AND to a base (ersatztv#632). ---
# `review-verdict/h10` is per-sha, which makes "the head moved under a fixed verdict" impossible by
# construction. Retargeting a PR's base is the mirror case and slips through: it changes neither the
# head sha nor the status, so a verdict formed while the PR targeted `main` still reads green after
# the PR is pointed at a branch with a very different merge-base. The diff moves while the verdict
# and the head both hold still.
#
# DETECTION, NOT PREVENTION, and only on this path. A commit status carries no base, so the
# server-side required check cannot see this; a merge driven through the Gitea UI or API is
# unaffected. That is the accepted exposure — base changes are rare, manual, and this is a
# two-account repo — but it is now recorded in a place that fails LOUD rather than only in a doc.
#
# GRACEFUL ADOPTION, mirroring (b) and (c): a description with no `(base: …)` field is a verdict
# posted before ersatztv#632 and gets NO opinion, rather than denying every in-flight PR the day
# this lands. The window closes on its own — verdicts are per-head and short-lived, so every verdict
# posted after this carries the field.
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded". Cold
# review found the first draft collapsing it into the latter: an unreadable status response yielded
# an empty `recorded_base`, which took the graceful-adoption path and skipped validation silently —
# after which a later, successful status read could still auto-grant. A transient failure would then
# 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.
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 "$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
# same-run window where two reads disagree, but the later branches derive different decisions from
# a failed read than this one does, so threading a shared response through them is a change to
# pre-existing logic rather than to ersatztv#632's. Left deliberately, noted so it is not
# rediscovered as an oversight: every `decide` exits immediately, so the reads cannot produce a
# single self-contradictory message — only a later decision made on a fresher snapshot.
vjson_base=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
# Same jq-1.6 rule as everywhere else in this file: check emptiness in SHELL first, never via
# `jq -e`'s exit status over empty input.
# VALIDATE EVERY FIELD THE EXTRACTION CONSUMES, on EVERY row — the same rule the file-enumeration
# guard learned the hard way. Checking only that `.statuses` is an array left a hole one level
# down: `{"statuses":[1]}` passes a top-level type check, then `.context` on a number errors, and
# a `|| true` on the extraction turned that error into an empty `vdesc` — i.e. straight back onto
# the graceful-adoption path this block exists to distinguish from. That is the identical
# swallow-the-error shape fixed a few lines up, surviving one level deeper.
if [ -z "${vjson_base//[[:space:]]/}" ] \
|| ! printf '%s' "$vjson_base" \
| jq -e '.statuses | type == "array"
and all(.[]; type == "object"
and (.context | type == "string")
and (.description == null or (.description | type == "string")))' \
>/dev/null 2>&1; then
decide ask "H10 merge gate: could not read the commit statuses for PR #$pr head ${sha:0:7}, so the verdict could not be checked against the PR's base branch (ersatztv#632). Confirm the review covered the branch this PR currently targets ('$live_base') before merging."
fi
# No `|| true` here. The validation above makes an error unreachable, but a swallowed error would
# be indistinguishable from "no base recorded" — the exact confusion this block removes — so the
# failure is handled explicitly rather than left to a fallback that reads as a benign result.
if ! vdesc=$(printf '%s' "$vjson_base" \
| jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .description // ""' \
2>/dev/null); then
decide ask "H10 merge gate: the commit statuses for PR #$pr head ${sha:0:7} could not be parsed to find the review verdict, so it could not be checked against the PR's base branch (ersatztv#632). Confirm the review covered the branch this PR currently targets ('$live_base') before merging."
fi
# The field is written by scripts/post-review-verdict.sh as a trailing `(base: <ref>)`. Its
# ABSENCE is the one benign case: a verdict posted before ersatztv#632 could not have carried it,
# and denying those would block every in-flight PR the day this lands. The window closes on its
# own, since verdicts are per-head and short-lived.
recorded_base=$(printf '%s' "$vdesc" | sed -n 's/.*(base: \(.*\))$/\1/p')
if [ -n "$recorded_base" ] && [ "$recorded_base" != "$live_base" ]; then
decide deny "H10 merge gate: BLOCKED — the review verdict on head ${sha:0:7} was formed while PR #$pr targeted '$recorded_base', but it now targets '$live_base'. Retargeting a base does not move the head sha, so the per-sha verdict status still reads green even though the effective diff has changed (ersatztv#632). Re-review against the new base and run: scripts/post-review-verdict.sh $pr MERGEABLE"
fi
fi
# --- Linked issue: Gitea auto-close keywords in the PR body. ---
issues=$(printf '%s' "$body" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
[ -n "$issues" ] || decide ask "H6 merge gate: PR #$pr has no linked issue (no 'fixes #N' / 'closes #N' in its body), so there is no Done-when checklist to derive consent from. Confirm the work is complete + reviewed, then approve."
@@ -241,75 +113,11 @@ done
# --- (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."
cistatus=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
state=$(printf '%s' "$cistatus" | jq -r '.state // ""' 2>/dev/null || true)
state=$(gq "repos/$owner/$repo/commits/$sha/status" | jq -r '.state // ""' 2>/dev/null || true)
case "$state" in
success) : ;;
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
*)
# `review-verdict/h10` is itself one of the contexts folded into the COMBINED state, so a PR
# awaiting its verdict reports combined 'pending' and would otherwise be reported as a CI
# problem — sending the reader to build logs when the missing thing is the review. Name the
# real blocker when the verdict is the only thing outstanding.
#
# "Not green" is anything that is not `success`, NOT just pending/failure: Gitea also has
# `error` (and `warning`), and omitting those would let an errored build hide behind the
# verdict and produce the flatly false claim "every CI check is green". `skipped` IS treated
# as green — the image-push job skips on every PR (ersatztv#593: a skipped context is not red).
nongreen=$(printf '%s' "$cistatus" \
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
| map("\(.context)=\(.status)") | join(", ")' 2>/dev/null || true)
# The verdict's OWN state decides the wording: absent/pending means nobody has reviewed this
# head, while failure/error means someone reviewed it and said no. Telling a reviewer to "post
# a verdict" when they already posted a BLOCKED one would be actively misleading.
vonly=$(printf '%s' "$cistatus" \
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
| if (length == 1 and .[0].context == "review-verdict/h10") then .[0].status else "" end' 2>/dev/null || true)
case "$vonly" in
pending)
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green; the only outstanding context is 'review-verdict/h10' on head ${sha:0:7}, i.e. this head has no review verdict yet. Review it and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
failure|error)
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green, but 'review-verdict/h10' is '$vonly' on head ${sha:0:7}: this head was reviewed and REJECTED. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
esac
decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success' (not green: ${nongreen:-unknown}). Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging."
;;
esac
else
# --- SCHEDULED auto-merge: everything this hook proves is a SNAPSHOT (ersatztv#622). ----------
# With merge_when_checks_succeed, Gitea performs the merge later, against whatever head is green
# at THAT moment — but (b) and (c) below are evaluated against the head that exists right now.
# Any commit pushed in between would merge with no verdict covering it. Demonstrated as a
# controlled A/B (#622): with a slow CI check pending so Gitea waits, an unreviewed commit pushed
# after scheduling MERGED without the required verdict context and was REFUSED with it.
#
# The durable fix is server-side and lives outside this hook: `review-verdict/h10` is a REQUIRED
# status check on `main`, and a commit status belongs to exactly ONE sha, so a later commit cannot
# inherit it and Gitea's own gate refuses to merge until that head is re-reviewed.
#
# What we add HERE is the matching precondition at SCHEDULING time: refuse to arm an auto-merge
# unless the sha-bound status already exists on this head. Checking the comment alone (condition
# (c) below) is not enough for this path — the comment is what a human reads, the status is what
# the server enforces, and only the latter survives a new push. Deny rather than ask: the remedy
# is a single documented command, so there is nothing here for a human to adjudicate.
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check the review-verdict status. Verify the review covered the latest commit before scheduling an auto-merge."
# Read the COMBINED endpoint, not `/statuses/{sha}`: the latter returns one row per status POST
# rather than per context and pages at 50, so a head with a few CI reruns can push the verdict off
# the first page and read as absent — a confusing false deny. The combined endpoint returns
# latest-per-context, which is exactly the question being asked.
vjson=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
# Same portability point as the file-pagination guard above: do not let jq's empty-input exit
# 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.
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
success) : ;;
"") decide deny "H6/H10 merge gate: BLOCKED — PR #$pr has no 'review-verdict/h10' commit status on head ${sha:0:7}, so scheduling an auto-merge would freeze consent at a head Gitea may not be the one to merge (ersatztv#622). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
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" ;;
*) decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success'. Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging." ;;
esac
fi
@@ -324,53 +132,52 @@ comments=$(gq "repos/$owner/$repo/issues/$pr/comments?limit=100")
if [ -z "$comments" ]; then
decide ask "H10 merge gate: could not fetch PR #$pr comments to verify a head-referencing review verdict ($short). Confirm the adversarial/Codex review covered the latest commit before merging."
fi
# Classification is delegated to `scripts/check-review-verdict.sh` — the single source of truth for
# the H10 grammar, extracted in #629 so it could be TESTED. While it lived here it had none, and three
# false-opens survived in it: a prefix-matched token (`MERGEABLE-LATER` graded positive), a verdict
# 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.
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."
# Verdict lines across all comment bodies: a real verdict line STARTS with the marker (after optional
# leading whitespace). Anchoring to line-start is deliberate — it rejects a comment that merely QUOTES
# the positive template mid-sentence (an instruction "please post: Review-verdict: MERGEABLE @ <sha>",
# or the gate's own suggestion text echoed back), which would otherwise self-approve the merge.
verdicts=$(printf '%s' "$comments" | jq -r '.[].body // empty' 2>/dev/null | grep -iE '^[[:space:]]*review-verdict:' || true)
if [ -z "$verdicts" ]; then
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve."
fi
# An input error (exit 2) is NOT a classification — fall through to a human rather than guessing.
if ! class=$(printf '%s' "$comments" | "$verdict_script" --head "$sha" 2>/dev/null); then
decide ask "H10 merge gate: could not classify the review verdicts on PR #$pr (malformed comments payload or unreadable head). Confirm the review covered the latest commit ($short) before merging."
# Classify each verdict line by the sha it references (its "@ <sha>" field) and its verdict word.
# A line references the CURRENT head iff head BEGINS WITH that sha token AND the token is >=7 chars
# (git short-sha prefix semantics) — NOT a loose substring test: an older sha that merely contains
# the head prefix, or the head prefix appearing in an unrelated URL on the line, must NOT count
# (adversarial false-opens). The verdict token must sit right after the marker on the same line.
head_pos=0; head_neg=0; stale=0
while IFS= read -r line; do
[ -n "$line" ] || continue
# The sha the line references: the hex token in its "@ <sha>" field (>=7 chars), lowercased.
ref=$(printf '%s' "$line" | grep -ioE '@[[:space:]]*[0-9a-f]{7,40}' | head -1 \
| grep -oiE '[0-9a-f]{7,40}' | tr 'A-F' 'a-f' || true)
is_pos=0
# Positive iff the line's OWN leading verdict word (right after the line-start marker) is positive —
# anchored so a second, later `review-verdict: mergeable` substring on a BLOCKED line can't flip it.
if printf '%s' "$line" | grep -iqE '^[[:space:]]*review-verdict:[[:space:]]*(mergeable|approved|lgtm)'; then is_pos=1; fi
[ -z "$ref" ] && continue # marker present but no @<sha> -> falls through to the final ask
case "$sha" in
"$ref"*) if [ "$is_pos" = 1 ]; then head_pos=1; else head_neg=1; fi ;;
*) stale=1 ;;
esac
done <<VERDICTS
$verdicts
VERDICTS
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier MERGEABLE
# on the SAME head; and if the head were fixed the sha would change, so this can't wrongly block).
if [ "$head_neg" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr."
fi
case "$class" in
negative)
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier
# MERGEABLE on the SAME head; if the head were fixed the sha would change, so this can't
# wrongly block).
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr." ;;
stale)
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'." ;;
unknown)
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr uses an unrecognized verdict token (not MERGEABLE/APPROVED/LGTM/BLOCKED/NOT-MERGEABLE). It is deliberately NOT read as approval. Post a verdict using the documented vocabulary — e.g. 'Review-verdict: MERGEABLE @ $short'." ;;
no-sha)
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha in its own '@ <sha>' field. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve." ;;
absent)
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve." ;;
positive) : ;;
*)
decide ask "H10 merge gate: unrecognized verdict classification '$class' for PR #$pr. Confirm the review covered the latest commit ($short) before merging." ;;
esac
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), 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
if [ "$head_pos" = 1 ]; then
# (a) CI green + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
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
if [ "$stale" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'."
fi
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve."
# Unreachable: the `case` above exits on every class, and `positive` exits in the block above. Kept as
# a fail-safe so a future class added to the classifier without a branch here cannot fall off the end
# of the script (which would exit 0 = silent passthrough, the one outcome a gate must never produce).
decide ask "H10 merge gate: verdict classification for PR #$pr produced no decision. Confirm the review covered the latest commit ($short) before merging."
# All derivable and satisfied -> auto-grant (defensive: the head_pos branch above already exits here).
decide grant "H6/H10 merge gate: satisfied — auto-granted."
-5
View File
@@ -39,11 +39,6 @@
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-agent-ram.sh\"",
"timeout": 10
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-agent-model.sh\"",
"timeout": 10
}
]
},
-37
View File
@@ -1,37 +0,0 @@
---
name: closing-an-issue
description: The ersatztv task-completion protocol — the mandatory steps and the `## Closing record` comment template for closing a Gitea issue. Use when finishing a task that closes an issue, or when writing a closing comment. The `/done` command runs this automatically.
---
# Task Completion Protocol
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done.
Use `/done <issue>` to run through this automatically.
Merge consent is a separate, hook-enforced concern — see the `## Done-when` convention in the
root `CLAUDE.md`, which stays always-loaded.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
## `## Closing record` template
Step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for
retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval
contract this feeds.
```markdown
## Closing record
**Outcome:** <what shipped / what didn't; PR link>
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
**Verification:** <tests run, live-E2E, CI status>
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
```
+55 -351
View File
@@ -1,168 +1,41 @@
---
name: ersatztv
description: "ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when creating or modifying IPTV channels, managing collections and schedules, building playouts, adding channel logos, scanning media libraries, troubleshooting channel issues, or resetting playouts. Also use for any questions about the ErsatzTV database schema (Channel, Collection, ProgramSchedule, Playout tables), M3U/XMLTV feeds, custom TV channel setup, or the channel creation checklist. IMPORTANT: the fork has a full versioned REST API at /api/v1 including write paths — prefer it over SQLite scripting, which is a recovery fallback only."
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
---
> **Canonical copy: `~/ersatztv/.claude/skills/ersatztv/SKILL.md`** (ersatztv owns this skill per that
> 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
Container: `ersatztv` | Port: `8409`
Web UI: `https://ersatztv.tblindustries.be` (via bumblebee's `external-proxy``192.168.1.29:8409`) or `http://localhost:8409` on the host
Host: **jazz** (`192.168.1.29`) since 2026-07-20 (#633) — moved off bumblebee together with Jellyfin. `dispatcharr` and `plex` stayed on bumblebee, so Dispatcharr now reaches ErsatzTV **by IP** (`http://192.168.1.29:8409`), not by Docker DNS name.
Compose env: `ForwardedHeaders__KnownNetworks=192.168.1.99/32` (proxied traffic arrives SNAT'd from bumblebee's LAN address; wrong value breaks Authelia OIDC login only, plain HTTP still works)
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` (owned by root — use `sudo sqlite3`)
Image: `192.168.1.95:3000/timothy/ersatztv:prod` (our fork; **floating** release tag — check `git tag -l 'v*' --sort=-v:refname | head -1` in `~/ersatztv` for the current release rather than trusting a version written here). Upstream `ghcr.io/ersatztv/ersatztv` was archived at v26.3.0 and is **not** what runs here.
Release tags are `vYY.<release-seq>.<patch>` — year · sequential release-within-year · patch — **not** year.month.
## Test/Prod topology — fork CI images (#481)
We maintain an **ErsatzTV fork** (`~/ersatztv`); its Gitea Actions pipeline builds and pushes images to the
private Gitea registry `192.168.1.95:3000/timothy/ersatztv` on every push to `main` (`:latest` + `:<short-sha>`)
and, on a `v*` tag, additionally `:prod` + `:<version>`. jazz is `docker login`'d to that registry and has `192.168.1.95:3000` in `insecure-registries`.
| | Prod | Test |
|---|---|---|
| Container | `ersatztv` | `ersatztv-test` |
| Host port | 8409 | 8410 |
| Stack | Komodo **`jazz-media`**; source `docker/jazz/stacks/media-servers/compose.yaml` (stack name ≠ directory — `media-servers` is bumblebee's; Komodo stack names are globally unique) | Komodo `ersatztv`; source `docker/jazz/stacks/ersatztv/compose.yaml` |
| Image | `192.168.1.95:3000/timothy/ersatztv:prod` (floating release tag) | `192.168.1.95:3000/timothy/ersatztv:latest` (fork CI) |
| Config (host) | `~/downloadswarm/ersatztv/``/config` | `~/downloadswarm/ersatztv-test/``/config` (one-time prod snapshot, refresh on demand) |
| Jellyfin/Dispatcharr tuner | connected (live lineup) | **NOT** wired downstream (avoids ghost channels) |
| Media mounts | RO | same mounts, RO |
| `/dev/dri` | yes (**VAAPI on Intel iHD**, jazz — see hw note) | yes (`/dev/dri` + `group_add: '992'`) |
| Auto-update | **None** (`auto_update: false`) — promotion is a manual `DeployStack jazz-media`, with no 03:00 fallback | Komodo auto-update, daily 03:00 (tracks `:latest`) |
| Env | `TZ`, restricted forwarded-header network, empty-by-default local-admin seed hook | `TZ`, `ETV_CONFIG_FOLDER=/config`, `ETV_TRANSCODE_FOLDER=/transcode`, `ETV_DISABLE_VULKAN=1` |
**Watchtower is retired.** Test auto-updates via Komodo; **prod does not**`auto_update: false`, so
promoting a release is always a manual `DeployStack jazz-media`. Prod's stack has a
fail-closed pre-deploy hook: a changed compose block or `:prod` digest triggers a PBS-backed snapshot and then a
migration rehearsal against a throwaway copy of that snapshot before container recreation (#585/#589).
**Refresh test snapshot from prod** (zero prod downtime — WAL online backup):
```bash
ssh timothy@192.168.1.29
docker stop ersatztv-test
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 ".backup '/home/timothy/downloadswarm/ersatztv-test/ersatztv.sqlite3'"
sudo rsync -a --exclude='ersatztv.sqlite3*' --exclude='logs/' ~/downloadswarm/ersatztv/ ~/downloadswarm/ersatztv-test/
docker start ersatztv-test
```
**Prod cutover to the fork** — ✅ DONE 2026-06-27 (#481). Prod runs `…/timothy/ersatztv:prod` (v26.3.1);
validated `:prod` on test first, then `etv-prod-deploy.sh` backed up + cut over (43 channels, healthy,
clean migrations). Downstream (Dispatcharr M3U acct 3 + EPG src 9) is name-based, so the container IP
change was transparent. Prod stays a **manual** gate (no Watchtower label) and still lives in the
`media-servers` stack (the optional move into the `ersatztv` stack was not done).
**Future prod releases** (push `v*` tag in `~/ersatztv` → CI builds `:prod`/`:<version>`): scan the immutable
`:<version>` image on jazz first, then execute Komodo `DeployStack` for `jazz-media`. The pre-deploy hook
backs up and runs the migration-on-prod-copy smoke before recreation. **There is no auto-update fallback for
prod** — if you don't `DeployStack`, nothing ships. Note the stack is named **`jazz-media`** even though the
compose *project* is still `media-servers`; a dead `media-servers` stack lingers on bumblebee and deploying it
fails silently. Roll back with the immutable prior image plus the pre-deploy DB snapshot; migrations are
forward-only. See the `komodo` skill and `docs/Docker/ErsatzTV.md` for the current procedure.
## Backup & deploy safety (#482)
Every prod deploy runs forward-only EF Core migrations against the live 285 MB SQLite DB — a bad one
can't be undone by re-deploying the old image, so the **only** rollback is restoring a pre-deploy DB
snapshot. Three scripts in `~/scripts/` (source of truth: `scripts/` in this repo) handle
them. **⚠️ These were installed on bumblebee, where ErsatzTV no longer runs (#633) — verify they exist on
jazz and that the Komodo `pre_deploy` hook is set on the `jazz-media` stack before relying on
"no backup, no deploy". Until confirmed, take a manual `etv-backup.sh` snapshot before every prod deploy.**
this. **Run as root** (DB + PBS creds are root-owned) except the deploy wrapper (run as `timothy`).
| Script | Run as | What it does |
|---|---|---|
| `etv-backup.sh [--target prod\|test] [--no-offbox]` | root (sudo) | Online `sqlite3 .backup` (zero-downtime) + `integrity_check`, provenance `manifest.txt` (image ref/digest + last `__EFMigrationsHistory` id), bundles `data-protection/` + `*-secrets.json`. Local **keep-last-5** under `~/downloadswarm/ersatztv-backups/<UTC-ts>/`; prod also pushes off-box to PBS. Prints the snapshot dir on stdout. |
| `etv-prod-deploy.sh` | **timothy** (needs private-registry creds; sudo's for the backup) | Backup (abort deploy if it fails) → `compose pull` + `up -d ersatztv` → health + M3U gate → prints a copy-paste rollback block on trouble. |
| `etv-restore.sh --target prod\|test --from <snapshot-dir>` | root (sudo) | Verifies snapshot → stop → saves current DB aside (`*.pre-restore-<ts>`) → swaps DB, drops stale `-wal/-shm`, restores `data-protection` → start → health/channel check. |
- **Off-box:** prod backups go to PBS `data-local` (.68) as backup-id **`ersatztv-predeploy`** (own
group, dedups against the nightly host backup), via the existing `/root/.proxmox-backup-client.env`.
- **Retention:** local keep-last-5 (instant rollback); PBS via the datastore-wide `data-local-prune`
job (7 daily / 4 weekly / 6 monthly), no separate prune job needed.
- **Restore from PBS** instead of a local dir:
```bash
source /root/.proxmox-backup-client.env
proxmox-backup-client restore ersatztv-predeploy/<snapshot> etv.pxar <outdir>
sudo ~/scripts/etv-restore.sh --target prod --from <outdir>
```
- `docker exec` always curls the container-internal port **8409** (even for test, whose host port is
8410). `etv-restore.sh` leaves a `*.pre-restore-<ts>` safety copy in `/config` — delete once happy.
- Validated 2026-06-27: first prod backup → PBS group created; full restore round-trip on `ersatztv-test`
returned 43 channels. Design: `plans/2026-06-27-ersatztv-backup-before-deploy-design.md`.
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
Web UI: internal only (`http://localhost:8409` via SSH)
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
## Architecture
**ErsatzTV is for channel creation only.** Consumers (Jellyfin, Kodi) never connect to ErsatzTV directly — everything goes through Dispatcharr as the single aggregation point. Pipeline: ErsatzTV → Dispatcharr → Jellyfin/Kodi.
ErsatzTV uses **MediatR + the ChicoryTV React SPA**. The legacy Blazor UI was removed in v26.7.0 (#91
phase b) — the SPA at `/app` is the **only** UI, and legacy routes 302 there. The versioned `/api/v1`
surface provides full CRUD — channels, collections, schedules, playouts and media sources; browser calls
use a local-admin/OIDC session cookie plus `X-CSRF` on mutations, and machine clients use `X-Api-Key`.
**Do not hand-edit SQLite for something the API can do** — direct SQLite writes are a recovery fallback,
not the normal management path, and the DB recipes below survive only for gaps with no endpoint.
Controllers stay thin and delegate to MediatR handlers. **Authoritative endpoint list:
`docs/endpoint-index.md` (generated) + `docs/api-conventions.md` in the ersatztv repo — prefer those
over any list in this file**, which is hand-maintained and drifts.
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
- **POST endpoints**: library scan, playout reset, show scan
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
## REST API
```bash
# Via docker exec (api.key is readable inside the container)
docker exec ersatztv curl -s -H "X-Api-Key: $(docker exec ersatztv cat /config/api.key)" \
http://localhost:8409/api/v1/ENDPOINT
```
From the **host**, the key file is root-owned `0600`, so an unsudo'd `cat` fails *silently* and sends an
empty header. Read it with `sudo`, inline, so the value is never printed:
```bash
# prod (8409); test is identical with .../ersatztv-test/api.key and port 8410
ssh timothy@192.168.1.29 'K=$(sudo -n cat /home/timothy/downloadswarm/ersatztv/api.key); \
curl -s -H "X-Api-Key: $K" http://localhost:8409/api/v1/channels'
```
### Paging — 0-based (ersatztv#616, `api.paging-zero-based`)
- **`pageNum` is 0-based** across the whole `/api/v1` surface and every wrapper of it (MCP tools, SPA
hooks, docs). Starting at 1 silently skips a page and returns a short set **with no error**.
- **`pageSize` is clamped per-endpoint** — 100 typical, 200 auto-tune members, 1000 search/all-items —
and the offset derives from the *effective* (clamped) size, not the requested one. Page to
completeness against `totalCount`; never conclude "that's all of them" from a single page.
- **`POST /api/v1/channels/{id}/playout/reset` takes a CHANNEL id, not the playout id.** The id spaces
overlap numerically, so passing a playout row's `Id` returns a plausible 202 against a *different*
channel. Playout rows carry `channelId` — use that.
Settings live under `/api/v1/settings/*` — `settings/ffmpeg` (`workAheadSegmenterLimit`,
`qsvExtraHardwareFrames`) and `settings/logging` (`streamingMinimumLogLevel`). Note the order: it is
`settings/ffmpeg`, **not** `ffmpeg/settings`.
Refresh test to the newest `:latest` without waiting for the 03:00 auto-update — scope it to the
service, since a bare `up -d` would recreate everything else in the compose project:
```bash
D=/etc/komodo/stacks/ersatztv/docker/jazz/stacks/ersatztv
docker compose -f $D/compose.yaml pull ersatztv-test
docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
# Via docker exec
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
```
### Read Endpoints (GET)
```
/api/v1/channels # List channels
/api/v1/collections # List collections
/api/v1/schedules # List schedules
/api/v1/playouts # List playouts
/api/v1/media-items # List media items
/api/v1/search # Search items
/api/v1/ffmpeg/profiles # FFmpeg profiles
/api/v1/settings/ffmpeg # Global FFmpeg settings — workAheadSegmenterLimit,
# initialSegmentCount, hlsSegmenterIdleTimeout
/api/v1/watermarks # Watermarks
/api/channels # List channels
/api/collections # List collections
/api/schedules # List schedules
/api/playouts # List playouts
/api/shows # List shows
/api/movies # List movies
/api/artists # List artists
/api/search # Search items
/api/ffmpeg/profiles # FFmpeg profiles
/api/watermarks # Watermarks
/iptv/channels.m3u # M3U playlist (for Jellyfin)
/iptv/xmltv.xml # XMLTV guide data
```
@@ -170,14 +43,14 @@ docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
### Mutation Endpoints (POST)
```bash
# Library scan
POST /api/v1/libraries/{id}/scan
POST /api/libraries/{id}/scan
# Scan single show
POST /api/v1/libraries/{id}/scan-show \
POST /api/libraries/{id}/scan-show \
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
# Reset channel playout (rebuilds schedule)
POST /api/v1/channels/{channelId}/playout/reset
POST /api/channels/{channelNumber}/playout/reset
```
## SQLite DB Operations
@@ -197,56 +70,25 @@ docker start ersatztv
-- List channels
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
-- List collections with item counts (CollectionItem has no Id column — use rowid)
SELECT c.Id, c.Name, COUNT(ci.rowid) as items
FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
-- List collections with item counts
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
-- List schedules
SELECT Id, Name FROM ProgramSchedule;
-- Playout with item count (check if playout is actually built)
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule, p.ScheduleKind, COUNT(pi.Id) as items
FROM Playout p JOIN Channel c ON p.ChannelId = c.Id
LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id
LEFT JOIN PlayoutItem pi ON pi.PlayoutId = p.Id
GROUP BY p.Id ORDER BY CAST(c.Number AS INTEGER);
-- Playout (channel-schedule links)
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
-- Media counts
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
-- Collection content (via file paths — Movie table has only Id, metadata is via MediaVersion→MediaFile)
SELECT ci.MediaItemId, mf.Path
FROM CollectionItem ci
JOIN MediaVersion mv ON mv.MovieId = ci.MediaItemId
JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE ci.CollectionId = <id>
ORDER BY mf.Path;
-- Jellyfin source
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
-- Library sync status
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
-- Music library folder breakdown
SELECT DISTINCT substr(mf.Path, 1, instr(substr(mf.Path, 13), '/') + 12) as folder, COUNT(*) as items
FROM MediaFile mf WHERE mf.Path LIKE '/data/music/%' GROUP BY folder ORDER BY folder;
```
### Table Schema Notes
**CollectionItem**: Has `CollectionId` + `MediaItemId` columns only (no `Id` column — use `rowid` for counting).
**MediaVersion**: Links to content via `MovieId`, `EpisodeId`, `MusicVideoId` columns (NOT a generic `MediaItemId`). Use `mv.MovieId = ci.MediaItemId` for movie/music video collections.
**Movie / Show / Episode / MusicVideo**: Inheritance from `MediaItem`. These tables have only an `Id` column (PK = MediaItem.Id). Titles and metadata are in separate `*Metadata` tables.
**Artwork**: Channel logos use `ArtworkKind=2` with `ChannelId` set. `Path` column is SHA256 hash (uppercase) of the image file. Files stored at `/config/cache/artwork/logos/{Path[0:2]}/{Path}`.
**ChannelWatermark**: Global watermark config (Id=1, "Channel Bug"). All channels share this via `Channel.WatermarkId=1`. This is the burn-in watermark overlay, NOT the channel logo.
**ProgramScheduleItem subtype tables**: `ProgramScheduleOneItem`, `ProgramScheduleDurationItem`, `ProgramScheduleFloodItem`, `ProgramScheduleMultipleItem`. MUST insert into the matching subtype table (usually `ProgramScheduleOneItem`).
### Channel Setup Workflow (DB)
**Show-specific channel** (single TV show, shuffled):
@@ -258,65 +100,26 @@ VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
-- 3. Channel (StreamingMode=4 = HLS Segmenter — ETV default; works fine through Dispatcharr. See Gotchas → Streaming mode.)
-- 3. Channel
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
-- 4. Playout (ScheduleKind=1 required — 0 is broken)
-- 4. Playout
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
VALUES (<id>, <channel_id>, <schedule_id>, 1, abs(random()) % 1000000);
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
```
**Collection-based channel** (multiple movies/videos, shuffled):
**Collection-based channel** (multiple shows, shuffled):
```sql
-- 1. Collection + items (MediaItemId = Movie.Id from MediaVersion→MediaFile lookup)
-- 1. Collection + items (MediaItemId = Show.Id)
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <movie_id>);
-- To bulk-add items from a folder:
INSERT INTO CollectionItem (CollectionId, MediaItemId)
SELECT <coll_id>, mv.MovieId FROM MediaFile mf
JOIN MediaVersion mv ON mf.MediaVersionId = mv.Id
WHERE mf.Path LIKE '/data/music/<folder>/%'
AND mv.MovieId NOT IN (SELECT MediaItemId FROM CollectionItem WHERE CollectionId = <coll_id>);
-- 2. Schedule + item (CollectionType=0, PlaybackOrder=3)
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
VALUES (<id>, 0, 0, '<name>', 1, 1, 0);
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, PlaybackOrder, ProgramScheduleId)
VALUES (<id>, <coll_id>, 0, 0, 0, 0, 0, 0, 0, 3, <schedule_id>);
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
-- 3-4. Channel + Playout same as show-specific (ScheduleKind=1)
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
-- 3-4. Channel + Playout same as show-specific
```
After creating: `POST /api/v1/channels/{id}/playout/reset`
### Channel Logo Workflow
Logos are stored as `Artwork` rows (ArtworkKind=2) with images in the cache directory.
```bash
# 1. Create logo PNG (transparent background, white text)
magick -size 512x180 xc:transparent -font "DejaVu-Sans-Bold" -pointsize 48 \
-fill white -stroke black -strokewidth 2 -gravity center \
-annotate +0+0 "CHANNEL NAME" PNG32:/tmp/logo.png
# 2. Calculate SHA256 and place in ErsatzTV cache
HASH=$(sha256sum /tmp/logo.png | cut -d' ' -f1 | tr 'a-f' 'A-F')
LOGO_DIR=~/downloadswarm/ersatztv/cache/artwork/logos
sudo mkdir -p "$LOGO_DIR/${HASH:0:2}"
sudo cp /tmp/logo.png "$LOGO_DIR/${HASH:0:2}/$HASH"
# 3. Insert Artwork row (stop container first for writes)
docker stop ersatztv
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "
INSERT INTO Artwork (ArtworkKind, ChannelId, DateAdded, DateUpdated, Path)
VALUES (2, <channel_db_id>, datetime('now'), datetime('now'), '$HASH');
"
docker start ersatztv
# 4. After ETV restarts, push logos to Jellyfin (see docs/Docker/ErsatzTV.md for fix_logos.py)
```
**Important**: Channel DB Id (from Channel table) is NOT the channel number. E.g., channel #407 might have DB Id 43.
After creating: `POST /api/channels/{number}/playout/reset`
## Volume Mounts (matches Jellyfin)
@@ -331,133 +134,34 @@ docker start ersatztv
## FFmpeg & Hardware
- **QSV encode + VA-API decode on Intel (iHD)** — ErsatzTV runs on **jazz** (i7-10700K, Intel iGPU) since #633. The single `FFmpegProfile` row (`Id = 1`, referenced by all 43 channels) has `HardwareAcceleration = 1` (**Qsv**), `QsvPreferNativeDecoder = 1` (ON), `QsvExtraHardwareFrames = 64`, `VaapiDevice = /dev/dri/renderD128`. Verified live 2026-07-26. The profile is still *named* "1080p VAAPI h264 aac" — cosmetic, ignore the name.
- **The old "do NOT set QSV" rule is RETIRED — #498 fixed the blocker it was based on.** The 2026-07-20 regression was real (QSV's *decoder* is far stricter than VAAPI about malformed NAL units and failed 3 of 6 cold-starts: `Error splitting the input into NAL units`), and the stated cause was that one `HardwareAcceleration` column governed both decode and encode. **#498 added `QsvPreferNativeDecoder` (default ON, Linux-only)**, which splits them exactly like Jellyfin: decode with the tolerant VA-API decoder, encode with QSV. That is what prod runs now. Do not "fix" prod back to `3` (Vaapi) on the strength of the old note.
- **Two QSV traps already paid for, both fixed in code — don't re-derive them:**
- `QsvExtraHardwareFrames` must never be `0`: the software→QSV `hwupload` bridge has no headroom and the transcode writes **zero segments** on any unthrottled read (#523/#529). Code now floors it at 64 (`ffmpeg.qsv-extra-hw-frames-floor`).
- **HDR tonemapping never uses `vpp_qsv=tonemap`** — on this Gen9.5 iGPU that filter is a *silent no-op* (byte-identical output, exit 0, no warning), so it looked like GPU tonemapping while doing nothing. ErsatzTV now tonemaps via VA-API→OpenCL (#505, `ffmpeg.qsv-hdr-tonemap-opencl`). Same trap applies to Jellyfin's `EnableVppTonemapping` on this host — keep it off.
- Fallback if VAAPI also misbehaves (see #631, VAAPI `hwupload -22` on 10-bit): `HardwareAcceleration = 0` (software). jazz has 16 threads at load ~2, so it is affordable and maximally tolerant of imperfect sources.
- QSV (Intel Quick Sync) hardware acceleration
- Resolution: 1920x1080, H264, AAC stereo
- Device: `/dev/dri` passed through (`renderD128`)
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf — **jazz uses 1 (Qsv)** with `QsvPreferNativeDecoder` ON (see above)
- jazz's iGPU is shared with Jellyfin only (Frigate stayed on bumblebee); render GID is 992 on both hosts, so `group_add: '992'` carried over unchanged
- Device: `/dev/dri` passed through
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
## Jellyfin Integration
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
- **ErsatzTV** library ids (verified 2026-07-26): Jellyfin source → Movies **10**, TV Shows **11**,
Music Videos **16**; Local source → Standup **14**. These are *ErsatzTV* ids and are **not** the same
as Jellyfin's own library ids — don't reuse one for the other. Re-derive with
`GET /api/v1/media-sources` rather than trusting this list.
- Scan a library with `POST /api/v1/libraries/{id}/scan` (there is no `PUT …/sync`).
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
## Gotchas
### Post-move to jazz (#633)
- **Any rsync from bumblebee's `~/downloadswarm/ersatztv/` re-reverts the QSV setting** — it overwrites `ersatztv.sqlite3`, restoring bumblebee's AMD-era values. Apply config changes **after** the final sync, then re-verify. (Same trap for Jellyfin's `encoding.xml` and `livetv.xml`.)
- **The config dir has root-owned files** (`ersatztv.sqlite3`, `cache/channel-guide/*`), so rsync needs sudo at **both** ends:
```bash
sudo rsync -a --delete -e "ssh -i /home/timothy/.ssh/id_rsa" --rsync-path="sudo rsync" \
timothy@192.168.1.99:/home/timothy/downloadswarm/ersatztv/ /home/timothy/downloadswarm/ersatztv/
```
- **Dispatcharr caches ErsatzTV's XMLTV.** Repointing its DB rows is not enough — it keeps serving a stale EPG full of dead `ersatztv:8409` artwork URLs (breaks Kodi artwork). Force a refresh (EPG source 9):
```bash
ssh timothy@192.168.1.99 'docker exec dispatcharr python manage.py shell -c \
"from apps.epg.tasks import refresh_epg_data; refresh_epg_data(9)"'
```
- **`/api/health` returns 401** (needs an API key). The Telegraf probe has no `response_string_match`, so ErsatzTV reads as **unhealthy in Grafana** — a false alarm, and **pre-existing**, not caused by the move. The container healthcheck uses the unauthenticated internal `/health` and is unaffected.
- **A Komodo deploy alone may not apply bind-mounted config changes** — containers kept serving the pre-checkout inode despite a current `deployed_hash`. `docker restart` explicitly and verify inside the container.
### Common Mistakes (check every time)
- **Playout not building**: Three things must all be correct: (1) `ProgramScheduleOneItem` row exists for the schedule item, (2) `PlaybackOrder=3` (Shuffle), (3) `ScheduleKind=1` on Playout. Missing any one results in 0 playout items — this is the most common issue.
- **Collection queries fail**: `CollectionItem` has no `Id` column — use `rowid` for counting. Content lookup goes through `MediaVersion.MovieId` → `MediaFile.Path` (not a generic MediaItemId join).
- **Channel logos forgotten**: After creating a channel, add an Artwork row (ArtworkKind=2) + logo file, then run `fix_logos.py` to push to Jellyfin. Without this, the channel shows no logo in the EPG.
- **Playout reset required**: After any schedule/collection change, run `POST /api/v1/channels/{id}/playout/reset`. Wait 5-10s for the playout to build before verifying item count.
### Streaming mode + the Dispatcharr reliability fix — #500
Consumers reach ETV **only through Dispatcharr** (`ErsatzTV → Dispatcharr → Jellyfin/Kodi`), which proxies every channel with `ffmpeg -i <etv-url> -c copy -f mpegts`. **Both HLS Segmenter (`StreamingMode=4`) and MPEG-TS (`StreamingMode=1`, `ts-legacy`) work** — Dispatcharr remuxes either to mpegts, and ETV's HLS segments are themselves mpegts with in-band SPS/PPS, so `-c copy` carries codec init either way. We run **42 channels on HLS** (ETV default; ts-legacy showed more visual glitching) + Jungle(407) on TS.
- **What the ~6 s cold-start actually was — ersatztv#350 (fixed 2026-07-20).** `-readrate 1.05` paces input at wall clock so the channel behaves like live TV, and it applies from the **first** read; with 4 s HLS segments a throttled session could not serve the playlist sooner than ~3.8 s. Only `workAheadSegmenterLimit` sessions (prod: **1**, see `/api/v1/settings/ffmpeg`) start unthrottled, so **concurrent tune-ins are the slow ones** — measured 866 ms for the slot winner vs 3845/6357 ms for two simultaneous tunes. Subtitle burn-in, source GOP length and NFS were investigated and **ruled out** (accurate-seek costs 30100 ms). Fixed with `-readrate_initial_burst` (5369 → 648 ms at the ffmpeg level); end-to-end verification tracked in `timothy/ersatztv#519`, so until that lands treat it as expected rather than confirmed. Diagnose with `docker logs ersatztv | grep "HLS cold-start"` — the line splits `setup / startup (prep + ffmpegInit + firstGop) / fill`.
- **The reliability bug was NOT the streaming mode — it was a Dispatcharr teardown race.** Any tune spins up a fresh ETV transcode (historically ~6 s cold-start, same for HLS and TS — see above). With Dispatcharr's default `channel_shutdown_delay=0`, the instant a client's open-timeout drops it the channel tears down, and the retry hits a 503 → ETV cold-starts again → death-spiral (Dispatcharr#503/#851). **Fix lives in Dispatcharr: `channel_shutdown_delay=15`** (see dispatcharr skill → Gotchas). Verified by reverting all channels to HLS while keeping the delay → reliable starts + correct audio sync (2026-06-28).
- **Corrected theory:** the first #500 pass blamed HLS for `Invalid avcC`/codec-init and switched everything to MPEG-TS. **That was wrong** — `-c copy` of mpegts HLS segments carries SPS/PPS fine; the `avcC` log line was transient/info-level and appeared on TS too. The isolation test (HLS + the delay) proved `channel_shutdown_delay` was the actual fix, and we reverted to HLS for better quality.
- Flip a channel's mode live (no restart — ETV reads it per M3U request): `UPDATE Channel SET StreamingMode=4 WHERE …;` then sync Dispatcharr's stored stream URL for that channel (`.m3u8?mode=segmenter` ↔ `.ts?mode=ts-legacy`).
- **Open / in progress:** through Dispatcharr's `-c copy` proxy, HLS showed a one-time skip-back shortly after start (Dispatcharr's `new_client_behind_seconds` repositioning the client behind live — set to 0 to test) and TS showed more glitching. Artifact tuning continues — see the dispatcharr skill and the #500 follow-up.
### Measuring what is actually deployed / what actually happened
- **The api.key file is root-owned, and an unsudo'd read fails SILENTLY.** `cat` returns nothing, the
header goes out empty, and the 401 body parses as a dict — so a naive script reports "0 channels"
rather than an auth error. If a query returns a suspiciously empty result, **check auth before
believing it.** (Cost a wrong reading on 2026-07-21.)
- **A container's OCI labels lie about what is running** — they are inherited from the base image (they
claimed `2026-06-27` on an image built minutes earlier). Tags and `StartedAt` lie too. To prove which
build is live, compare `docker inspect <c> --format '{{.Image}}'` (the manifest digest on jazz) to the
registry's `Docker-Content-Digest` header for that tag — not `.config.digest`. (ersatztv#350)
- **Container log lines carry a LOCAL-time bracket (`[18:48:13 DBG]`) while `docker logs -t` emits
UTC**, so `--since` windows silently mis-slice. For before/after measurements capture by **line
offset** instead (`wc -l` before, `tail -n +N` after).
### DB & Architecture
- DB owned by root — always use `sudo sqlite3`
- WAL mode: reads OK while running, stop container for writes
- Full REST CRUD is available under `/api/v1`; prefer it over direct DB writes
- No REST API for channel/collection/schedule CRUD — DB scripting only
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — inserting into the subtype table is required or EF Core won't recognize the row
- `/health` is the unauthenticated container-health gate; use an authenticated `/api/v1` read to verify the API
### Enums
- PlaybackOrder: 2=Chronological (broken for collections — produces empty playouts), 3=Shuffle, 6=SeasonEpisode — use 3 for reliable results
- CollectionType: 0=Collection, 1=Show (direct show reference via MediaItemId)
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
- ScheduleKind: 0=None (broken — playout never builds), 1=Fixed — use 1
- StreamingMode: 4=HLS Segmenter (`…/channel/N.m3u8?mode=segmenter`) — **ETV default, what we run** (42 channels); 1=MPEG-TS (`…/channel/N.ts?mode=ts-legacy`, Jungle/407 only). Both work through Dispatcharr (it remuxes either to mpegts via `-c copy`). Read live per M3U request → flipping needs **no container restart**. The #500 reliability fix was a Dispatcharr setting (`channel_shutdown_delay`), NOT the mode — see "Streaming mode" gotcha.
### Channel Creation Checklist
1. Collection + CollectionItems (for collection-based) OR MediaItemId (for show-specific)
2. ProgramSchedule (all NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
3. ProgramScheduleItem (PlaybackOrder=3) + ProgramScheduleOneItem subtype row
4. Channel (SongVideoMode=0, WatermarkId=1, all required columns)
5. Playout (ScheduleKind=1)
6. Artwork (ArtworkKind=2) + logo file in cache
7. `POST /api/v1/channels/{id}/playout/reset`
8. Run `fix_logos.py` to push logo to Jellyfin
### Logo System
- **External-URL logos now work for the on-screen bug too** — fixed in ersatztv#502 (2026-07-20,
`ffmpeg.external-logo-graphics-engine`). The old claim that they work for M3U but not watermark
burn-in described a `WatermarkSelector` `File.Exists()` gate that is gone; an external logo is
fetched, decode-budget-validated and stored in the image cache at **save** time
(`graphics.channel-logo-caching`), so the render path never fetches over HTTP and a bad URL fails
the save with a 422.
- **M3U/XMLTV absolute URLs are no longer stuck on the request-derived host.** They used to bake in
whatever host fetched the feed (the historical `http://localhost:8409` symptom, Gitea #1/#171),
which Jellyfin can't resolve from inside its container. Set the optional advertised base URL —
`GET`/`PUT /api/v1/settings/iptv` (`iptv.base_url`, ersatztv#340, `iptv.base-url`) — to pin them to
a fixed public origin; unset falls back byte-identical to the old behavior. The base64-upload
workaround in `docs/Docker/ErsatzTV.md` is only needed if that setting is left unset.
- **No usable logo ⇒ no on-screen bug, from every attachment point** (ersatztv#510, 2026-07-26,
`ffmpeg.watermark-resolution-unified`). A `ChannelLogo` watermark resolves through one shared
`WatermarkSelector.ResolveWatermark` whether it came from a playout item, the channel, the global
setting, **or a deco**. A missing cached file, an un-migrated external URL, and a channel with no logo
artwork each render *without* a bug and log a warning. So when debugging "this channel has a watermark
configured but no bug appears", grep the log for `has no logo artwork` / `no longer exists` before
suspecting the ffmpeg pipeline.
- Before #510 the **deco** path alone was unchecked and returned the generated-initials nameplate
(`/iptv/logos/gen`) for a logoless channel — it genuinely rendered. That fallback is now off
everywhere; reviving it via the image cache is ersatztv#652.
- **Not covered:** the song-progress overlay is built as a `WatermarkOptions` directly by the
streaming/troubleshooting handlers, bypassing the resolver, and is still unchecked — ersatztv#653.
- **`/iptv/logos/gen` is unauthenticated**, unlike the rest of `/iptv`: `ConditionalIptvAuthorizeFilter`
is a class-level attribute on `IptvController` only, and that route lives on `ArtworkController`.
Handy for probing, and the reason a container-internal self-fetch of a generated logo succeeds.
- **Seeding a deco watermark for testing is fully API-driven** (no SQLite needed): `POST /api/v1/watermarks`
(needs the full required field set — check `v1.json`), `POST /api/v1/decos/groups`, `POST /api/v1/decos`,
`PUT /api/v1/decos/{id}` (set `watermarkMode` + `watermarkIds`), then `PUT /api/v1/playouts/{id}/deco`.
Use `watermarkMode: "Override"` to make the deco watermark the only one selected. Note branding is
**not** testable through the troubleshooting-playback API (`testing.troubleshoot-path-cannot-test-branding`)
— drive a real channel playout and capture a frame.
- `logo_XX.png` files in the logos root dir are HTML garbage (broken downloads), not actual logos — ignore them
### Other
- Upstream was archived in Feb 2026; `timothy/ersatztv` is the maintained fork and release source
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
-1
View File
@@ -1 +0,0 @@
../../../server-management/.claude/skills/jellyfin
+105
View File
@@ -0,0 +1,105 @@
---
name: jellyfin
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
---
# Jellyfin Management
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
API Token: `978033be716d46678a5d3c54ae0e0ff9`
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
## Access Pattern
```bash
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
```
## Volume Mounts
| Host Path | Container Path | Content |
|-----------|---------------|---------|
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
| `/mnt/episodes` | `/data/episodes` | More episodes |
| `/mnt/media/movies` | `/data/movies` | Movies |
| `/mnt/media/standup` | `/data/standup` | Standup |
| `/mnt/media/music_videos` | `/data/music` | Music videos |
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
## API Endpoints
### System
```
GET /System/Info # Server info, version
GET /System/Info/Public # Public info (no auth needed)
POST /System/Restart # Restart server
```
### Items (Search & Browse)
```bash
# Search items
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
# Get item details
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
# Get all movies
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
# Get series
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
# Get episodes for a series
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
# Filter by library (parentId)
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
```
### Libraries
```
GET /Library/VirtualFolders # List all libraries
POST /Library/Refresh # Trigger full library scan
POST /Items/{id}/Refresh # Refresh single item metadata
```
### Streaming
```bash
# Test stream URL
GET /Videos/{itemId}/stream?static=true
# Get playback info
GET /Items/{itemId}/PlaybackInfo
```
### Users
```
GET /Users # List users
GET /Users/{userId} # User details
```
## Library IDs
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
## Live TV
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
- Configured in Jellyfin Admin > Live TV
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
## Gotchas
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
- Music videos are typed as "Movie" in Jellyfin
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
- Items return 404 on stream if source volume is unmounted
- Jellyfin preserves item IDs across restarts unless files are renamed
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
+3 -8
View File
@@ -53,14 +53,9 @@ env:
jobs:
build:
name: Build & push CI image
# Moved off `small` with docker-build.yml's `build` (server-management#639). Being
# "docker-only" made it look lightweight, but it is a full buildx of the .NET
# 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 (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
# `small` = the small-jobs runner lane. This is a docker-only job (no toolchain needed —
# it *builds* the toolchain), same as docker-build.yml's `build` job.
runs-on: small
steps:
- name: Checkout
uses: actions/checkout@v4
+243 -162
View File
@@ -6,10 +6,6 @@ name: Build ErsatzTV Image
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
#
# The PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard) live in the sibling
# .gitea/workflows/pr-checks.yml (`on: pull_request`). They were split out of this file so they
# are not dispatched-and-killed on a tag/main push (ersatztv#535 — see that file's header).
#
# Runner + registry provisioned in server-management#172. The Gitea registry is
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
# inherit the host daemon's insecure-registries setting).
@@ -29,7 +25,7 @@ name: Build ErsatzTV Image
# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md ->
# "CI toolchain image" for the two-step procedure.
#
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
#
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
@@ -40,14 +36,6 @@ name: Build ErsatzTV Image
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
#
# 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
# byte-identical to a PR head that already has a green Gitea combined status — i.e. the exact
# source was already validated in the PR run. Every heavy step in those three jobs additionally
# gates on `steps.revalidate.outputs.skip != 'true'`. `build` is untouched and always runs on
# main, so the image is still built (from already-validated source) even when the skip fires.
on:
workflow_dispatch:
@@ -107,7 +95,7 @@ jobs:
name: Build & test (.NET)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -115,9 +103,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# 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
# only the test job's steps below need the working tree; git history/tags
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
@@ -125,14 +113,9 @@ jobs:
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -140,13 +123,13 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
# the SPA's package downloads are project deps, so they stay cached per lockfile.
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -154,55 +137,45 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm ci
- name: Check generated SPA API client
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run check:api
- name: Lint SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run lint
- name: Typecheck SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run typecheck
- name: Test SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm test -- --run
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
# Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so
# its high-water mark spans them (SPA build/test above are comparatively light). Paired with the
# "Report peak container memory" step below. continue-on-error + a fail-open script => this
# instrumentation never reddens a build. Why anon and not memory.peak: ersatztv#412 /
# scripts/ci-peak-anon.sh header / docs/ci-cd.md "CI build memory".
- name: Start peak-anon sampler (ersatztv#412)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
continue-on-error: true
run: scripts/ci-peak-anon.sh start
- name: Build
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build --configuration Release --no-restore
- name: Test
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: >-
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
@@ -213,7 +186,7 @@ jobs:
# floor later"), so this step is purely informational — continue-on-error keeps a missing
# report or a transient tool-install failure from ever blocking a build.
- name: Coverage summary
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
continue-on-error: true
run: |
set -euo pipefail
@@ -237,32 +210,77 @@ jobs:
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
fi
# Memory of THIS job container, reported every run (ersatztv#406/#412, server-management#604).
# #604 sizes the runners' per-job caps on these numbers. The headline is the TRUE PEAK ANON
# sampled by the "Start peak-anon sampler" step above — NOT `memory.peak`, which is the
# high-water mark of memory.current and charges reclaimable page cache to the cgroup (a build
# job does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak). Page cache is
# reclaimed under a tighter cap, not OOM-killed, so sizing a cap off `memory.peak` inverts the
# decision. peak anon is the OOM-forcing number. Full rationale + the bumblebee demo:
# scripts/ci-peak-anon.sh header and docs/ci-cd.md "CI build memory".
# Memory of THIS job container, reported every run (ersatztv#406, server-management#604).
# #604 sizes the runners' per-job caps on these numbers, and until now they were inherited
# rather than measured: the 10g cap traces back to server-management#570 observing the image
# build peg 5.999/6 GiB, which is a different job entirely.
#
# Runs LAST on purpose (after Coverage summary / reportgenerator, the job's last real workload)
# and stops the sampler. `always()` so a failed Build/Test still gets a peak reading; the split
# is read here (end-of-job = composition then, not at the peak instant — that is exactly why the
# sampler exists). Skipped on docs-only/already-validated runs (nothing ran to measure).
# ⚠️ READ THE BREAKDOWN, NOT JUST THE PEAK. `memory.peak` is the high-water mark of
# `memory.current`, which charges **page cache** to the cgroup as well as anonymous memory —
# it is NOT "peak RSS", and for a build job (NuGet/npm/obj/bin/coverage I/O) the cache
# dominates. Demonstrated on bumblebee: a container with anon=0 that merely reads an 800 MB
# file reports memory.peak=826 MiB, of which file=800 MiB. This matters because the naive
# reading inverts the decision: page cache is **reclaimed** under a tighter cap, not
# OOM-killed, so a large peak that is mostly `file` is NOT evidence that the cap must stay
# high. `anon` (+ a little kernel/sock) is the part that actually forces an OOM.
#
# The split below is read at end-of-job, so it is the *current* composition rather than the
# composition at the peak instant — indicative, not exact. Sizing a cap off one run is still
# wrong; take a few runs, and treat anon as the floor and peak as the (cache-inflated)
# ceiling. Refining this into a true peak-anon sample is ersatztv#412.
#
# Runs LAST on purpose: memory.peak read at step N reports the peak only up to N, so this
# sits after Coverage summary to include reportgenerator, the job's last real workload.
# cgroup v2 first, v1 fallback.
#
# Skipped on docs-only runs (ersatztv#416): nothing ran, so there is nothing to measure.
- name: Report peak container memory
# `always()` controls whether this step RUNS, not whether its failure fails the job. With
# `defaults.run.shell: bash` (`-e -o pipefail`) a stray non-zero here would redden a green
# test job, so `continue-on-error` makes it advisory — the same guarantee Coverage summary uses.
if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }}
# `always()` controls whether this step RUNS, not whether its failure fails the job — and
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
# the same guarantee the Coverage summary step above uses.
if: ${{ always() && steps.detect.outputs.docs_only != 'true' }}
continue-on-error: true
run: scripts/ci-peak-anon.sh report
run: |
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
peak=""; src=""
for f in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory/memory.max_usage_in_bytes; do
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
done
if [ -z "$peak" ]; then
echo "No cgroup peak-memory file readable in this container -- skipping."
exit 0
fi
anon=""; file=""
if [ -r /sys/fs/cgroup/memory.stat ]; then
anon=$(awk '/^anon /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
file=$(awk '/^file /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
fi
echo "::group::Container memory (ersatztv#406 / server-management#604)"
printf 'peak (incl. page cache): %s MiB [%s bytes, %s]\n' "$(mib "$peak")" "$peak" "$src"
if [ -n "$anon" ]; then
printf 'end-of-job anon (the part that OOMs): %s MiB\n' "$(mib "$anon")"
printf 'end-of-job file (page cache, reclaimable): %s MiB\n' "$(mib "${file:-0}")"
echo 'NOTE: peak counts reclaimable page cache. Size caps on anon, not on peak.'
else
echo 'NOTE: no memory.stat breakdown available; peak includes reclaimable page cache.'
fi
echo "::endgroup::"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
printf '**Container memory (test job):** peak %s MiB *(incl. reclaimable page cache)*' \
"$(mib "$peak")"
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' \
"$(mib "$anon")" "$(mib "${file:-0}")"
printf '\n'
} >> "$GITHUB_STEP_SUMMARY" || true
fi
migrations:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -321,24 +339,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# 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
# default fetch-depth: 1 -- this job never runs git describe/log, only
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -346,11 +357,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
- name: Build
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build --configuration Release --no-restore
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
@@ -358,7 +369,7 @@ jobs:
# SQLite is the prod provider; both checks validated locally.
- name: SQLite — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: |
set -euo pipefail
echo "::group::SQLite model drift (has-pending-model-changes)"
@@ -374,7 +385,7 @@ jobs:
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
- name: MySql — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
env:
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
@@ -408,18 +419,8 @@ jobs:
done
echo "::endgroup::"
# NOTE (ersatztv#491 -> #627): running the LibraryFolder dedupe fixture against the live `mysql`
# service was implemented here and then REMOVED. The coverage gap it closes is real — the two
# checks above only ever apply migrations to a fresh EMPTY database, so they execute no rows of any
# data-migration logic, and two MySql-only collation defects escaped exactly this gate. But the
# fixture proved non-deterministic in CI across three attempts (stale pooled session after a drop,
# then lost isolation from a shared database name, then a connect-before-create), and an
# intermittently-red gate is worse than none: it trains everyone to re-run instead of read, which is
# how the original defects escaped. The fixture itself is retained and is opt-in via
# ETV_TEST_MYSQL_CONNECTION (skipped, visibly, without it). Re-arming it here is tracked by #627.
functional-e2e:
name: Functional E2E (curl + UI contracts)
name: Functional E2E (curl contracts)
runs-on: ubuntu-latest
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
# flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
@@ -431,7 +432,7 @@ jobs:
# v* tag builds.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -439,22 +440,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# 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
fetch-depth: 1
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -462,11 +456,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: dotnet restore
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -474,17 +468,17 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm ci
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
working-directory: web
run: npm run build
- name: Build (Release)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
@@ -495,7 +489,7 @@ jobs:
# the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent.
- name: Boot instance and run functional-E2E harness
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
if: steps.detect.outputs.docs_only != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
@@ -508,43 +502,14 @@ jobs:
trap 'kill "$PID" 2>/dev/null || true' EXIT
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
# ersatztv#445: the UI-interactive flows the curl harness structurally CANNOT express —
# client-side form validation, AuthGate's rendered states, the session cookie authenticating the
# SPA's own /api XHRs, and sign-out through the UserMenu.
#
# Why in THIS job rather than its own: the dominant cost here is `npm ci` + the Release build,
# which are already done above. A separate job would duplicate both to add ~5s of browser work.
# The browser itself is baked into the toolchain image (docker/ci/Dockerfile —
# chromium-headless-shell), so this step installs nothing.
#
# It boots its OWN fresh instance on a DIFFERENT port: the first spec asserts the one-shot Setup
# gate, which the curl harness's auth section has already claimed on its own config dir, and a
# separate port keeps this independent of the previous step's teardown timing.
- name: Run UI-E2E Playwright flows (headless)
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8410
# e2e-ui.sh owns the whole lifecycle: fresh config dir, boot, run specs, always kill the
# server. Its exit status is Playwright's.
scripts/e2e-ui.sh
build:
name: Build & push image (amd64)
# Moved back off `small` (server-management#639). This is the one HEAVY job that
# was still in that lane, and its 10g requirement was what pinned the lane's
# per-job cap at 10g — which in turn capped the lane at ONE slot on a 25 GiB
# host. Four jobs sharing one slot is what starved the git-only checks in act's
# setup phase (>10 min, no logs, then fail). With this job gone, `small` is
# git-only and can run wide and tiny on two hosts.
#
# 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]` 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
# `small` = the dedicated small-jobs runner lane (server-management#574).
# On PR runs this job only resolves its skip, but Gitea still dispatches it
# as a task — on the ubuntu-latest runners that skip queued behind long
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
# capped at 4 CPUs / 10g.
runs-on: small
needs: [test, migrations]
if: github.event_name != 'pull_request'
steps:
@@ -677,6 +642,120 @@ jobs:
exit 1
fi
# BLOCKING (ersatztv#390): the CI toolchain image pin in this file must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#303 H9): docs/decisions.md is an append-only log. Fails a PR that deletes or
# rewrites a settled entry (numstat reports >0 deleted lines) unless a commit in the range carries
# the [decisions-edit] override token for a documented factual fix. Same script the Husky commit-msg
# hook calls, so local and CI enforcement can't drift. Seconds-long git diff -> keep it off the build runners.
decisions-guard:
name: decisions.md append-only
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Enforce append-only
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
./.claude/hooks/decisions-guard.sh range "origin/${base_ref}" HEAD
- name: Consolidation-floor reminder (non-blocking)
run: |
# Consolidation is primarily a release step; this is the between-releases floor. The metric is
# the file's LINE COUNT — the context an agent actually burns reading the log — not entry count.
# Floor 1800 keeps the whole log inside one default 2000-line Read (headroom for the reader's
# own overhead). Nudge (never fail) past it so append-only can't grow past what agents can read.
n=$(wc -l < docs/decisions.md | tr -d ' ')
echo "docs/decisions.md is ${n} lines (consolidation floor: 1800; one Read caps at 2000)."
if [ "${n:-0}" -gt 1800 ]; then
echo "::warning::docs/decisions.md is ${n} lines (>1800) — larger than agents can comfortably read in one pass. Do a consolidation pass (prune/merge superseded entries with [decisions-edit]); don't wait for the next release. See the decisions.md header."
fi
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
# surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated
@@ -706,7 +785,7 @@ jobs:
# 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source.
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -779,29 +858,19 @@ jobs:
echo "Generated API artifacts are in sync."
# Formatting-as-you-touch gate (ersatztv#311): verify the .cs files THIS PR changed conform to
# .editorconfig whitespace + charset=utf-8 (i.e. no UTF-8 BOM). Scoped to changed files so it
# enforces "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500
# pre-existing BOM files. A PR that touches no .cs skips the check and passes trivially (always
# reports a status, so it is safe as a required check).
#
# ersatztv#469: uses `dotnet format whitespace . --folder`, NOT the full `dotnet format <sln>`.
# `--folder` treats the tree as a plain folder of files and skips the MSBuild/Roslyn workspace load
# + per-project compilation that dominated the old recipe (~8 min locally on a whole-solution run) —
# `--include` only ever narrowed *which* files were checked, never what got loaded. Folder mode
# reads .editorconfig and still flags WHITESPACE (indent/EOL/trailing/final-newline) and CHARSET
# (BOM) violations — exactly what this gate exists to catch — in ~0.5s with no `dotnet restore`.
# What it drops is the style/analyzer pass (naming/`var`/qualification), which this gate never
# meaningfully enforced: those .editorconfig rules are :suggestion/:none severity. Full rationale +
# non-vacuity evidence: docs/ci-cd.md → Formatting; docs/decisions.md.
# .editorconfig (style + charset=utf-8, i.e. no UTF-8 BOM). Scoped to changed files so it enforces
# "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500 pre-existing
# BOM files. A PR that touches no .cs skips the expensive steps and passes trivially (always reports
# a status, so it is safe as a required check).
format:
name: Formatting (changed .cs conform to .editorconfig)
# Folder-mode whitespace is now a seconds-long, low-memory job (no Roslyn workspace, unlike the
# 3.95 GiB full `dotnet format` measured in #406), so it no longer needs the memory headroom that
# kept it on `ubuntu-latest`. Left here to avoid re-touching the lane/memory-cap accounting; a
# move to a lighter lane is a server-management capacity call (#604).
# Was on the `small` lane (ersatztv#390) to dodge a ~29 min queue; reverted to `ubuntu-latest`
# in ersatztv#406 — `dotnet format` needs the .NET SDK and real memory, so it does not belong
# in a lane sized for seconds-long shell jobs. See the api-docs job above for the full
# rationale; server-management#604 grew this lane so the queue it was dodging is gone.
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -828,14 +897,26 @@ jobs:
echo "No .cs change -> skipping format verify (job passes)."
fi
- name: Cache NuGet packages
if: steps.detect.outputs.cs_changed == 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.cs_changed == 'true'
run: dotnet restore
- name: Verify formatting of changed .cs files
if: steps.detect.outputs.cs_changed == 'true'
shell: bash
run: |
mapfile -t files < /tmp/changed-cs.txt
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig (whitespace + charset)..."
if ! dotnet format whitespace . --folder --verify-no-changes --include "${files[@]}"; then
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (whitespace or a UTF-8 BOM). Run 'dotnet format whitespace . --folder --include <files>' (or the full 'dotnet format ErsatzTV.sln --include <files>') and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig..."
if ! dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"; then
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (formatting or a UTF-8 BOM). Run 'dotnet format ErsatzTV.sln --include <files>' and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
exit 1
fi
echo "All changed .cs files conform to .editorconfig."
-262
View File
@@ -1,262 +0,0 @@
name: PR Gates
# Fast, git-only PR gates split out of docker-build.yml into a dedicated `on: pull_request`
# workflow (ersatztv#535) so they are NEVER created on a tag/main push.
#
# WHY THIS FILE EXISTS. These checks are cheap `checkout + git diff` gates (or, for `script-tests`,
# checkout + pytest): they carry no `container:`, run on the `small` lane (git-only, 1 GiB;
# server-management#639), and are PR-only.
# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags —
# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because
# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the
# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase
# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened
# the tag's overall commit status even though the release built, scanned, and deployed fine
# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical
# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens
# in the dispatch window before any step or `if:`-skip runs.
#
# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow
# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole
# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders
# (they also encode "these steps need a PR base_ref"; harmless given the trigger).
#
# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of
# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/
# api-docs/format) that remain there. None of these jobs are required checks — branch protection
# requires only `Build & test (.NET)`, `EF migration integrity` and `review-verdict/h10` — so
# relocating them (which changes their status-context prefix from "Build ErsatzTV Image / …" to
# "PR Gates / …") does not affect merges. See docs/ci-cd.md -> "PR gates workflow".
on:
pull_request:
# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but
# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash.
defaults:
run:
shell: bash
# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel.
concurrency:
group: ersatztv-pr-gates-${{ github.ref }}
cancel-in-progress: true
jobs:
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -eq 0 ]; then
echo "::error::No ersatztv-ci pin found in docker-build.yml at all. Every container: job must pin ersatztv-ci:<7-char-sha>; if the grep pattern stopped matching, fix it here too (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
# LENGTH is a separate invariant from CORRECTNESS, and only this check covers it
# (ersatztv#594). The resolve + staleness checks below compare RESOLVED shas, so a
# 8/9/10-char abbreviation of the right commit sails through them green — while
# matching NO tag in the registry, because ci-image.yml tags with
# `git rev-parse --short HEAD` under `fetch-depth: 1`, which always yields exactly 7.
# The failure would otherwise surface far downstream as all five `container:` jobs
# dying at image-pull with `manifest unknown`, which reads like a registry outage.
# This is an easy mistake to make: the natural local command prints 8 chars.
#
# Deliberately a literal 7, not a derived `git rev-parse --short=7`: in this full
# clone git may widen an ambiguous abbreviation past 7, which would demand a pin
# ci-image.yml can never publish — the exact clone-depth asymmetry noted above.
# `${expected:0:7}` is plain string truncation, so it is safe to suggest.
#
# ESCAPE HATCH, if you are ever stuck: this makes 7 mandatory, so if `${expected:0:7}` ever
# became an AMBIGUOUS prefix (two objects sharing it), the resolve check below would fail
# and a longer pin — previously the workaround — is now rejected here first. There is no
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
# empirical property of today's shallow clone, not an enforced invariant. Making the
# publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of
# scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs
# the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') —
# ci-image.yml's push trigger has no branches: filter, so a feature branch does publish.
if [ "${#pins[0]}" -ne 7 ]; then
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
# supersedes/superseded-by links, no rationale-prose rewrite without a Decisions-Edit: yes git
# trailer (ersatztv#609 — never a bare substring, which prose about the marker could arm), no record
# vanishing from the active set without an archive copy) and that the generated active catalog
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
decisions-guard:
name: decisions lifecycle
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Validate decision lifecycle
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- name: Kickoff guard
run: bash scripts/check-kickoff-guard.sh
# FAILS THE RUN on a red (ersatztv#631) — like its sibling gates here it is not (yet) a required
# status check, so it reddens the PR without hard-blocking the merge button; see the header.
# Runs scripts/tests/ — the pytest suite covering the decision-corpus
# parser/validator/catalog builder, the #610 migration-equivalence harness, the merge-consent
# exemption logic and the #622 review-verdict poster. Until #631 NOTHING executed these: no
# workflow and no Husky hook invoked pytest, so the suite guarding our merge-gating machinery was
# local-only and a regression in it was caught only by luck. `decisions-guard` above runs that
# code, but never its tests.
#
# WHY ITS OWN JOB rather than a step inside decisions-guard (which the issue proposed as the
# cheapest home): `ci.decisions-lifecycle-flake` is a STANDING instruction that a lone
# `decisions lifecycle` red is a known infra flake to be ignored — "do not investigate". Folding
# the suite into that job would make a genuine pytest regression present as exactly the red every
# session is told to wave through, which is the same silently-green failure mode #631 exists to
# 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 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 tests (pytest)
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
# pytest + PyYAML. PyYAML is NOT a contradiction of the dependency-free decisions READ path:
# `decisions_lib._read_frontmatter` is hand-written precisely so validation runs where nothing
# is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by
# design — and `test_migration_equivalence.py` imports that module, so the suite needs it.
# `pytest` and `yaml` are the complete third-party set, established by an AST import scan over
# all of scripts/ rather than by reading the files that seemed relevant: the first cut of this
# job claimed "pure stdlib", passed locally on a machine that happened to have PyYAML, and
# went red in CI on a collection error.
- name: Install test dependencies
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
# test_post_review_verdict.py and test_merge_consent_exemption.py exec the REAL
# post-review-verdict.sh / pretooluse-merge-consent.sh, which shell out to `jq` ~26 times.
# `curl` those tests shim on PATH; `jq` they do NOT. If it were missing, the suite would fail
# as ~20 opaque assertion errors — this turns that into one actionable line.
- name: Preflight external tools
run: |
if ! command -v git >/dev/null 2>&1; then
echo "::error::script-tests needs git on PATH but it is absent. The suite execs real" \
"shell scripts that use it. Bake it into the runner image rather than apt-get" \
"installing here (see ersatztv#390)."
exit 1
fi
echo "Preflight OK: $(git --version)"
# jq gets its OWN step because its VERSION, not merely its presence, is load-bearing
# (ersatztv#648). `--expect` makes this a TRIPWIRE: scripts/tests exercises the jq 1.6 code path
# only because this runner ships 1.6, so an upgrade would silently delete that coverage — and
# the three divergences found in ersatztv#643/#647 all lived exactly there. Going red forces an
# explicit human decision instead of letting the coverage evaporate.
#
# The pin lives HERE and deliberately NOT in review-verdict.yml: that workflow writes the
# branch-protection-required `review-verdict/h10` status, so pinning a version there would turn
# any jq bump on the runner into a repo-wide merge deadlock. It gets the floor-only mode.
# See docs/ci-cd.md -> "The jq contract".
- name: Preflight jq version
run: ./scripts/jq-preflight.sh --expect 1.6
- name: Run scripts/tests
run: PYTHONPATH=. python3 -m pytest scripts/tests -q
-540
View File
@@ -1,540 +0,0 @@
name: Review verdict
# ersatztv#622 — server-side H10 enforcement.
#
# THE INVARIANT. `review-verdict/h10` is a REQUIRED status check on `main`, and a Gitea commit
# status belongs to exactly ONE sha. So a commit that did not exist when a verdict was written can
# never inherit that verdict: push a new head and the required context is simply absent, which
# Gitea's merge-requirement check treats as not-passing. `merge_when_checks_succeed` therefore
# refuses to fire until someone re-reviews THAT head. This is what closes the ersatztv#622 hole,
# where the PreToolUse hook proved conditions (b) and (c) against the head at SCHEDULING time and
# Gitea then merged whatever head happened to be green minutes later.
#
# WHAT THIS WORKFLOW DOES — and, importantly, does NOT do. It does NOT decide whether code was
# reviewed; only a human/agent review does that, via `scripts/post-review-verdict.sh`, which writes
# the `review-verdict/h10` status directly. This workflow only handles the two EXEMPT classes that
# would otherwise deadlock, and marks everything else `pending` so the PR shows an explicit,
# actionable blocking reason instead of a silently-missing check:
#
# 1. Bot-authored PRs (Renovate). Renovate uses `platformAutomerge: true` — i.e. Gitea's OWN
# auto-merge — to land patch bumps unattended. A required verdict context with no exemption
# would stall every dependency PR forever waiting on a human verdict.
# 2. Docs-only PRs, mirroring the merge-consent hook's existing docs-only carve-out.
#
# BOTH exemptions are void when the PR touches a PROTECTED path (see PROTECTED below): the gate,
# the CI definition, the git hooks, the scripts they call, and the CI toolchain image. A PR that
# weakens the merge gate must never be able to exempt itself from the merge gate — that is the one
# self-referential failure worth spending an explicit rule on. Note this also (deliberately) means
# Renovate's `docker/ci/Dockerfile` base bumps need a real verdict; those already require the
# manual publish-then-pin two-step (docs/ci-cd.md -> "CI toolchain image"), so unattended merge was
# never correct for them anyway.
#
# WHY ITS OWN FILE, not a job in pr-checks.yml: that workflow sets `cancel-in-progress: true`, so a
# superseding push cancels its runs. A cancelled run here would leave an EXEMPT PR with no success
# status and no further pushes to re-trigger it — Renovate would stall silently. This workflow
# therefore takes no cancelling concurrency group.
#
# This job's OWN status context ("Review verdict / Set review-verdict status (pull_request_target)")
# is NOT the required check and is not what gates merges — `review-verdict/h10`, the status it
# POSTS, is. Keeping them distinct is deliberate: a workflow cannot be allowed to satisfy the gate
# merely by running successfully. The context string carries the trigger name, so the #672 switch
# renamed it; that is safe only because it was never in branch protection's required list (which is
# the two `docker-build.yml` job contexts plus `review-verdict/h10`). Adding it there later would
# undo the distinction this paragraph exists to protect.
#
# THE CHANGED-FILE ENUMERATION IS NOT INLINE HERE (ersatztv#649). It lives in
# `scripts/pr-changed-files.sh`, the single implementation this job and the advisory hook
# `.claude/hooks/pretooluse-merge-consent.sh` both call. It used to be written twice, and drifted in
# the dangerous direction: four rounds of ersatztv#643 hardening landed on the ADVISORY copy (whose
# failure mode is a human prompt) and never reached THIS one (whose failure mode is a `success`
# write to a required status with nobody in the loop). See
# `docs/decisions/records/ci/shared-pr-file-enumeration.md`.
#
# WHY THE CHECKOUT TAKES THE PR'S **BASE** REF, NEVER THE HEAD. This job judges the PR, so the PR
# must not be able to supply the code that judges it. Checking out `head.sha` would let a PR edit
# `scripts/pr-changed-files.sh` to return an empty list and exempt itself — the `PROTECTED` list
# below would flag the edit, but only if the enumeration that feeds it were trustworthy, and it
# would be the PR's own. `base.sha` is the commit the PR merges INTO: already on `main`, already
# reviewed. `persist-credentials: false` because nothing here pushes, and a checkout that leaves a
# token in `.git/config` hands it to every script the job runs.
#
# WHY THE TRIGGER IS `pull_request_target`, NOT `pull_request` (ersatztv#672). The base-ref checkout
# above binds the SCRIPTS this job runs to the base. It does not bind the job DEFINITION. Gitea
# resolves a `pull_request` workflow definition from the PR's own head commit, so a PR editing THIS
# FILE ran its own rewritten copy — which could delete the checkout above, or skip straight to
# posting `review-verdict/h10=success` for its head sha. `PROTECTED` did not help (the rewrite
# defines `PROTECTED` too) and neither did branch protection, which requires the *context* and
# carries `required_approvals: 0`, so a self-posted success satisfied it outright.
#
# Measured on this instance (Gitea 1.25.4) rather than inferred from GitHub, because the whole point
# is that the gate's authority is derived, not asserted. A scratch PR rewriting this file to post a
# distinct probe context posted exactly that context, and the real `review-verdict/h10` was never
# written at all — the base's definition never ran. Under `pull_request_target` the same rewrite was
# ignored: the BASE definition ran and posted `h10=pending`, on both `opened` and `synchronize`,
# with `secrets` still available.
#
# `pull_request_target` is normally the DANGEROUS trigger, and it is worth being explicit about why
# that reputation does not transfer here. Its footgun is running untrusted HEAD code with a
# privileged token. This job never checks out the head and never executes anything the PR supplies:
# it checks out `base.sha` and runs only scripts from that tree. The base-ref checkout is what makes
# this trigger safe, so the two must be read as one decision — reintroducing a head checkout under
# this trigger would be far worse than the bug being fixed here.
#
# `branches: [main]` IS LOAD-BEARING, not cosmetic. Base resolution means the BASE branch supplies
# the definition, so without this filter a PR opened into an attacker-pushed base branch would run
# THAT branch's rewritten gate — trading a head-supplied definition for a base-supplied one and
# closing nothing. It matters more than it looks because a commit status is repo-global per sha
# (#663): a `success` forged on a head sha under a scratch base is inherited by a later, real PR
# into `main` carrying the same head. With the filter, a PR whose base is not `main` produces no run
# and no status at all (verified the same way).
# `edited` IS LOAD-BEARING (ersatztv#698 route 1), not completeness for its own sake. Gitea fires it
# when a PR's base is retargeted, and a retarget changes the effective diff WITHOUT moving the head
# sha — so none of the other four types fire and the per-sha status stays exactly as it was. That is
# what made route 1 persist rather than merely exist: a PR was opened into `main`, retargeted to a
# scratch base while this job was in flight so the enumeration read docs-only and posted an exemption
# `success`, then retargeted BACK to `main`, where the forged success sat unchallenged on a head whose
# diff against `main` carried a C# file (reproduced as probe PR #703; `created_at == updated_at`
# afterwards proves nothing reclassified). With `edited`, the retarget back re-runs this job — and the
# short-circuit below now re-derives machine-written successes instead of inheriting them, which is
# the half that makes the re-run actually change the answer. The two are one fix; `edited` alone would
# re-run and then bail out on the existing `success`.
#
# BE PRECISE ABOUT WHAT THIS BUYS: detection, not atomicity or ordering. Runs are NOT serialized, so
# the stale run can post `success` AFTER the reclassifying run posts `pending` — restoring the forged
# state with no further event left to correct it — and an already-scheduled auto-merge can fire in the
# green window between them. The `main -> scratch -> main` ABA transition is therefore NARROWED and
# observable, not closed. Tracked as ersatztv#706; do not read this block as claiming otherwise.
on:
pull_request_target:
branches: [main]
types: [opened, reopened, synchronize, ready_for_review, edited]
defaults:
run:
shell: bash
jobs:
set-verdict-status:
name: Set review-verdict status
runs-on: small # a few API calls; keep it off the build runners
steps:
# BASE, not head — see the header. `fetch-depth: 1` is enough: nothing here reads history,
# only the working tree's `scripts/`.
- name: Checkout the PR's BASE ref
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
fetch-depth: 1
persist-credentials: false
# FLOOR ONLY — never `--expect` in this workflow. `--expect` pins an exact version and fails
# when it drifts, which is right for `script-tests` (advisory) and catastrophic here: this job
# writes `review-verdict/h10`, a REQUIRED check on `main`, so a pin would turn any jq bump on
# the runner into a repo-wide merge deadlock. Asserting the 1.6 floor is what the gates below
# are written against; see docs/ci-cd.md -> "The jq contract".
#
# A hard failure here is correct and fails CLOSED: the job dies, no `review-verdict/h10` is
# posted, and an absent required check blocks the merge. Guarded on presence because a PR
# whose BASE predates ersatztv#658 has no such script, and "the base is old" is not a jq
# problem — that case is handled as an enumeration failure below, with an actionable status.
- name: jq preflight (floor only)
run: |
set -euo pipefail
if [ -x ./scripts/jq-preflight.sh ]; then
./scripts/jq-preflight.sh
else
echo "::warning::The PR's base ref has no scripts/jq-preflight.sh; skipping the version assertion. The enumeration step below will fail closed on its own."
fi
- name: Classify the PR and post the review-verdict status
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
BASE_URL: ${{ github.server_url }}/api/v1
# `scripts/pr-changed-files.sh` reads GITEA_BASE_URL (not BASE_URL) and takes owner/repo as
# two SEPARATE arguments (not one `owner/repo` string). Getting either wrong is silent, not
# loud: the script would fall back to its hardcoded LAN default and enumerate the wrong
# repo, or a wrong host that answers, rather than erroring. A value already ending in
# /api/v1 is used as-is by the script.
GITEA_BASE_URL: ${{ github.server_url }}/api/v1
# BOTH names, same value, on purpose. The script's precedence is
# `ETV_GITEA_URL` > `GITEA_BASE_URL` > a hardcoded LAN default (and `ETV_GITEA_TOKEN` >
# `GITEA_TOKEN`), because its other caller is a developer Mac using the ETV_* convention.
# Setting only the GITEA_* names would leave this job's explicit configuration NON-
# authoritative: a runner that happened to export a stale ETV_GITEA_URL would silently
# enumerate a different Gitea instance and post the verdict here from a diff read there.
# Cheap to make deterministic; leave both set even though only one is read.
ETV_GITEA_URL: ${{ github.server_url }}/api/v1
ETV_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
# The base BRANCH the event was raised for, passed down to the enumeration so the diff it
# reads cannot silently be one against a different base (ersatztv#698 route 1). This comes
# from the `pull_request_target` event payload, which is fixed at event time and is exactly
# what a mid-run retarget cannot rewrite — the live PR object can, which is the whole bug.
# `branches: [main]` means this is always `main` today; it is threaded through as a value
# rather than hardcoded so the two stay consistent if the filter ever widens.
BASE_REF: ${{ github.event.pull_request.base.ref }}
AUTHOR: ${{ github.event.pull_request.user.login }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
set -euo pipefail
CONTEXT="review-verdict/h10"
# Accounts whose PRs may merge without a human verdict. Renovate only — keep this list
# minimal and explicit; every entry is an account that can land code unreviewed.
BOTS="renovate"
# The bot exemption is additionally constrained by CONTENT (ersatztv#698 route 2), because
# identity alone is not attributable to whoever wrote the code. `AUTHOR` is
# `pull_request.user.login` — the PR's CREATOR, which is immutable — while the head a PR
# points at is not: force-push application code onto an open Renovate branch and the PR is
# still authored by `renovate`, still touches no protected path, and was exempted. Nothing
# in the identity check attributes the CODE to the bot.
#
# Checking the pusher instead would not fix it — a git author/committer is self-asserted
# text and forgeable. So the exemption is gated on what a dependency bump can legitimately
# BE: an unattended merge is justified only for the manifests Renovate actually edits.
#
# The set is measured, not guessed: across all 11 Renovate PRs this repo has ever had, the
# paths touched were `Directory.Packages.props` (10 of them) and `.config/dotnet-tools.json`
# (1). The npm manifests are deliberately NOT included — see the BOT_MANIFESTS note below.
#
# Deliberately EXCLUDED, with the cost stated: `*.csproj` and any source file. The one
# historical Renovate PR outside the set above is #20, which touched a `.csproj` AND two C#
# files — and received an unattended bot exemption for a source change. Under Central
# Package Management versions live in `Directory.Packages.props`, so a `.csproj` edit
# attributed to Renovate is anomalous by construction. Such a PR is not blocked, it simply
# needs a real verdict, which is the correct handling for a PR carrying source changes.
# NOTE the npm manifests are deliberately ABSENT. An earlier draft included
# `web/package.json` / `web/package-lock.json` "so a first SPA bump cannot deadlock". That was
# a self-inflicted code-execution vector for zero benefit: `renovate.json` sets
# `enabledManagers: ["nuget", "github-actions", "dockerfile"]`, so Renovate does not manage npm
# in this repo at all, while `package.json` carries `scripts` that CI EXECUTES (`npm ci`,
# `npm run build` in docker-build.yml). Exempting it would let a hijacked bot branch run
# arbitrary shell in CI while every path still "looked like a manifest". If npm is ever added
# to enabledManagers, the lockfile may be exemptible but `package.json` is not.
BOT_MANIFESTS='^(Directory\.Packages\.props|\.config/dotnet-tools\.json)$'
# Paths where NEITHER exemption applies, because a change here can alter the gate itself,
# what CI runs, or what the hooks enforce.
PROTECTED='^(\.claude/|\.gitea/|\.husky/|scripts/|docker/ci/)'
# Docs-only: prose and decision records. Deliberately narrower than the hook's pattern,
# which also lets .claude/.gitea/.husky through — that carve-out is safe there only
# because it falls through to a HUMAN PROMPT, whereas here it would post a green status
# with nobody in the loop.
DOCS_ONLY='^(docs/|[^/]*\.md$)'
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::No GITEA_TOKEN available, so the ${CONTEXT} status cannot be written. An exempt (bot/docs-only) PR will stall until this is fixed; a normal PR is unaffected — post its verdict with scripts/post-review-verdict.sh."
exit 1
fi
gh() { curl -sf -H "Authorization: token $GITEA_TOKEN" "$@"; }
# DEFINED HERE, BEFORE ANY USE. An earlier round defined these AFTER the classification
# chain that calls them, so `count_matching` was `command not found` on every run, the
# PROTECTED branch silently never fired, and three "protected path" tests still passed —
# they reached `pending` by another route, so the guard being dead was invisible.
#
# HOW THE PATH PREDICATES ARE EVALUATED, and why neither obvious spelling is used.
#
# `producer | grep -q…` is FORBIDDEN here: `grep -q` exits at its first match, the producer
# then takes SIGPIPE and exits 141 once the list exceeds the pipe buffer, and under
# `set -o pipefail` the pipeline is a FAILURE even though grep MATCHED — inverting the guard
# for exactly the large PRs that matter. Reproduced with `A.cs` + 1900 docs paths (171KB,
# inside the enumerator's 2000-file cap): `docs_only=yes`, status 141; and a `.gitea/` path
# made `PROTECTED` MISS. That construct predates #698 and was live on `main`.
#
# A here-string (`grep -q… <<< "$files"`) fixes the SIGPIPE but bash materialises a large
# here-string via TEMPORARY STORAGE, so it can fail when the runner's temp space is full or
# unwritable — and because these run inside `if`/`!`, that failure would flip the predicate
# the same way. Trading a buffer bug for an environmental one is not a fix.
#
# So: count with `grep -c`, which DRAINS stdin (no early exit, no SIGPIPE) over an ordinary
# pipe (no temp file), and treat grep's own exit status honestly — `grep -c` exits 1 when the
# count is zero, which is a legitimate answer, while anything >1 is a real error and must FAIL
# THE JOB rather than silently read as "no match". `set -e` would not catch these on its own
# because they sit inside command substitution in a conditional.
count_matching() { # how many lines of $2 match $1
local out st=0
out=$(printf '%s\n' "$2" | grep -cE "$1") || st=$?
# NOT `exit 1`: these run inside `$( )`, so an exit leaves only the SUBSHELL and, because
# the substitution sits in a conditional, `set -e` does not fire either — the job would sail
# on with the predicate silently reading as "no match". Emit a NON-NUMERIC sentinel instead
# and let the caller, at top level, refuse to classify.
if [ "$st" -gt 1 ]; then
echo "::error::grep failed (status ${st}) evaluating a path predicate." >&2
printf 'ERR'
return 0
fi
printf '%s' "${out:-0}"
}
count_not_matching() { # how many lines of $2 do NOT match $1
local out st=0
out=$(printf '%s\n' "$2" | grep -cvE "$1") || st=$?
if [ "$st" -gt 1 ]; then
echo "::error::grep failed (status ${st}) evaluating a path predicate." >&2
printf 'ERR'
return 0
fi
printf '%s' "${out:-0}"
}
# --- Is there already a verdict for THIS sha? ----------------------------------------
# NOTE the heading no longer says "never overwrite". It cannot promise that: the read below
# and the POST at the end of this job are not atomic, so a human verdict posted in between is
# still overwritten. The re-read immediately before the POST narrows that window; it does not
# close it. Tracked as ersatztv#706 rather than claimed as solved.
# Reads the CONTEXT row for $SHA and sets ex_state / ex_creator / ex_desc / ex_human.
# Factored into a function because it is now called TWICE — once here, and once immediately
# before the POST (see below). An unreadable/unparseable response must NOT be read as "no
# verdict exists": the job dies WITHOUT posting, so a transient API error can never overwrite
# a verdict.
#
# The empty case is checked EXPLICITLY, not left to jq's exit status: `jq -e` over empty input
# exits 4 on jq >= 1.7 but 0 on jq 1.6, and THE RUNNER SHIPS 1.6 (ersatztv#647) — so on a
# transient error this guard passed, the row came back "", and the job posted over a
# possibly-existing human verdict.
#
# The COMBINED endpoint is read, not `/statuses/{sha}`: the latter returns one row per POST
# (not per context) and pages at 50, so a head with a few CI reruns can push an earlier verdict
# off the first page.
read_existing_verdict() {
local json row
json=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || json=""
if [ -z "${json//[[:space:]]/}" ] || ! printf '%s' "$json" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
echo "::error::Could not read existing commit statuses for ${SHA:0:7}. Refusing to post anything rather than risk overwriting an existing verdict."
exit 1
fi
row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[.statuses[] | select(.context == $c)] | first // {}')
ex_state=$(printf '%s' "$row" | jq -r '.status // ""')
ex_creator=$(printf '%s' "$row" | jq -r '.creator.login // ""')
ex_desc=$(printf '%s' "$row" | jq -r '.description // ""')
# A `case` prefix test rather than grep: the description is a single short string, and this
# removes one more pipeline from a security predicate entirely. The PATTERN is a literal, so
# there is no glob-injection concern from $ex_desc.
# A human verdict also has to have been formed against THIS base (ersatztv#698, found in
# round-4 review). `post-review-verdict.sh` records the base it reviewed in the status
# description — `Review-verdict: MERGEABLE @ abc1234 (base: main)` — precisely because
# retargeting changes the effective diff without moving the head sha (ersatztv#632).
# Without this check the sha-binding is escapable through the HUMAN path rather than the
# exemption path: get a genuine `success` on head H while it targets a scratch base S with
# a benign diff, then retarget H onto `main`, where its diff contains unreviewed code. The
# status is real, its creator is real, and it was silently inherited. The merge-consent
# hook compares the base and would object, but that is advisory and covers only its own
# path — a merge through the Gitea UI or API just sees a green required check.
#
# An ABSENT base is deliberately NOT treated as a mismatch: verdicts predating #632 carry
# no `(base: …)`, and re-deriving over one would un-approve a genuinely reviewed head. Only
# a base that is PRESENT and DIFFERENT is rejected, which is exactly the escape above.
ex_human=no
case "$ex_desc" in
"Review-verdict:"*)
if [ -n "$ex_creator" ]; then ex_human=yes; fi
;;
esac
if [ "$ex_human" = yes ]; then
# COMPARE, NEVER PARSE. Two earlier attempts both extracted the base out of the
# description and both were defeated, the second in a way that looked like a fix for the
# first:
# * `${ex_desc##*"(base: "}` (LAST occurrence) let an APPENDED `(base: main)` override a
# genuine `(base: probe/scratch)`;
# * `${ex_desc#*"(base: "}` (FIRST occurrence) fixed that, but `${...%%)*}` still
# truncates at the first `)`. `main)evil` IS A VALID GIT BRANCH NAME
# (`git check-ref-format --branch 'main)evil'` succeeds), so a verdict earned while
# targeting it reads `(base: main)evil)`, truncates to exactly `main`, and is
# INHERITED after retargeting onto `main`. No forged description, no #697 needed.
# The comment here previously asserted a `)` in a branch name "mismatches — safe
# direction"; that was generalised from `feat/foo)bar` and is FALSE for any branch
# whose name starts with the target base.
#
# So extract nothing. `post-review-verdict.sh` writes the marker LAST, so require the
# description to END with the exact literal `(base: <this PR's base>)` and to contain
# exactly ONE marker — which kills the append trick without having to decide which
# occurrence is authoritative. Pure shell; no truncation exists to abuse.
#
# `${#}` arithmetic rather than a `grep -o | wc -l` pipeline; 7 is the length of
# "(base: ". An ABSENT marker is still not a mismatch (verdicts predate #632).
ex_stripped=${ex_desc//"(base: "/}
ex_markers=$(( (${#ex_desc} - ${#ex_stripped}) / 7 ))
if [ "$ex_markers" -ne 0 ]; then
ex_base_ok=no
if [ "$ex_markers" -eq 1 ]; then
case "$ex_desc" in
*"(base: $BASE_REF)") ex_base_ok=yes ;;
esac
fi
if [ "$ex_base_ok" != yes ]; then
ex_human=no
echo "${CONTEXT} on ${SHA:0:7} is a human verdict, but its recorded base does not match this PR's base '${BASE_REF}' (description: ${ex_desc}) — the reviewed diff is not this PR's diff, so it is NOT treated as a verdict for this base."
fi
fi
fi
}
# --- Whose verdict is it? (ersatztv#698 route 3) -------------------------------------
# This short-circuit used to exit on ANY existing `success`, which made an exemption this job
# wrote indistinguishable from a verdict a human wrote. That is what let a forged exemption
# survive: obtained once — via the route-1 retarget race, a sibling workflow holding
# status-write credentials (#697), a direct API call, or inheritance across PRs by sha (#663)
# — it was thereafter accepted unchanged on every run, because the guard exited before it
# looked at the PR, the base, the author or the files.
#
# The guard still exists for its original reason: re-posting `pending` over a real human
# verdict would un-approve a reviewed head and stall the PR. So it discriminates by PROVENANCE.
#
# MEASURED on this instance (Gitea 1.25.4), on the COMBINED endpoint this job reads: a status
# POSTed with a USER credential — how `scripts/post-review-verdict.sh` writes a verdict —
# carries `.creator.login`, while one POSTed by an Actions job with the built-in `GITEA_TOKEN`
# carries `"creator": null`. A real verdict read back `creator=timothy`; this job's own
# exemption read back `creator=null`.
#
# BOTH conditions are required, and the DIRECTION of the test is the point: we short-circuit
# only on something POSITIVELY identified as a human verdict. Anything else, including anything
# we do not recognise, is RE-DERIVED. Written the other way round ("skip if it looks
# machine-written") an unrecognised shape would be trusted — the fail-open this issue is about.
#
# What this does NOT claim: the test asks "was this POSTed by a user credential", NOT "by a
# reviewer". `ETV_STATUS_AUTH` is basic auth, so head-controlled code can POST a success with a
# non-null creator AND an attacker-chosen `Review-verdict:` description, which this guard then
# preserves. That is #697 — provenance, not authentication.
read_existing_verdict
if [ "$ex_human" = yes ] && { [ "$ex_state" = "success" ] || [ "$ex_state" = "failure" ]; }; then
echo "${CONTEXT} is already '${ex_state}' on ${SHA:0:7}, written by '${ex_creator}' as a human verdict — leaving it alone."
exit 0
fi
if [ -n "$ex_state" ]; then
echo "${CONTEXT} is '${ex_state}' on ${SHA:0:7} but is NOT an attributable human verdict (creator='${ex_creator:-null}', description='${ex_desc}') — re-deriving it from the PR's current state rather than inheriting it."
fi
# --- Changed files: the SHARED enumeration, or no exemption. -------------------------
# `scripts/pr-changed-files.sh` (from the BASE checkout) owns every guard this job used to
# carry inline and six it did not: CR/LF rejection, `..` rejection, a closed `.status`
# allow-list, `previous_filename` validated on EVERY row rather than only `renamed` ones,
# termination only on a validated EMPTY page rather than a merely short one, and head-sha
# binding across the paging round-trips. ersatztv#649.
#
# READ THE EXIT STATUS, NEVER THE STDOUT OF A FAILED RUN. exit 0 means "complete and bound
# to $SHA"; anything else means "could not tell" and stdout is meaningless. That the
# script happens to print nothing on its failure paths is redundancy, not contract —
# `files` is therefore cleared explicitly rather than trusted to be empty. stderr is left
# attached to the job log on purpose: its diagnostic is the only thing that distinguishes
# a force-push mid-enumeration from a dead API.
ENUM=./scripts/pr-changed-files.sh
files=""
complete=no
enum_error=""
if [ ! -x "$ENUM" ]; then
# Only reachable for a PR whose BASE predates ersatztv#658. Fail closed with a readable
# status rather than an absent one, so the PR shows why instead of stalling silently.
enum_error="the PR's base ref (${BASE_SHA:0:7}) has no executable ${ENUM}"
elif files=$("$ENUM" "${REPO%%/*}" "${REPO#*/}" "$PR" "$SHA" "$BASE_REF"); then
complete=yes
else
files=""
enum_error="scripts/pr-changed-files.sh could not enumerate PR #${PR} at ${SHA:0:7} exhaustively (see the step log)"
fi
files=$(printf '%s\n' "$files" | grep -v '^$' || true)
count=$(printf '%s\n' "$files" | grep -c . || true)
echo "Changed files (${count}, complete=${complete}):"
printf '%s\n' "$files" | sed 's/^/ /'
# Evaluated ONCE, at TOP LEVEL, so a failure can actually stop the job. Evaluating them
# inline inside the `if`/`elif` chain is what hid the two defects above: a bad status or a
# missing function turned into an empty string, `[ "" -gt 0 ]` errored, and the branch was
# simply skipped. A non-numeric result here is fatal and posts nothing — an absent required
# check blocks the merge, which is the correct direction.
n_protected=$(count_matching "$PROTECTED" "$files")
n_not_manifest=$(count_not_matching "$BOT_MANIFESTS" "$files")
n_not_docs=$(count_not_matching "$DOCS_ONLY" "$files")
for v in "$n_protected" "$n_not_manifest" "$n_not_docs"; do
case "$v" in
''|*[!0-9]*)
echo "::error::A path predicate returned '${v}' instead of a count — the classifier is not operating, so no ${CONTEXT} status will be written for ${SHA:0:7}."
exit 1 ;;
esac
done
exempt=no
reason=""
if [ "$complete" != yes ]; then
reason="${enum_error} — no exemption"
elif [ "${count:-0}" -eq 0 ]; then
reason="no changed files could be read from the API — no exemption"
elif [ "$n_protected" -gt 0 ]; then
reason="touches a protected path (gate/CI/hooks/scripts/ci-image) — exemptions do not apply"
else
# The two exemptions are evaluated as INDEPENDENT predicates rather than as a chain.
# An `elif` chain was wrong once the bot exemption gained a second condition
# (ersatztv#698 route 2): a Renovate PR that changes only `docs/` would enter the bot
# branch, fail the manifest test, and never reach the docs-only branch at all — silently
# withdrawing an exemption that the docs-only rule grants on its own merits, for any
# author. Composing the predicates and deciding afterwards keeps each rule's meaning
# independent of the order they happen to be written in.
#
# `grep -qv` asks "is there any line NOT in this allow-list", so an unrecognised path
# withholds the exemption instead of being ignored — the same closed-set direction the
# enumeration itself uses. Both are safe against an empty `$files` because `count -eq 0`
# is handled above.
# Written as `if`/`then`, never as `cmd && var=yes`: under `set -e` a bare `A && B`
# statement whose `A` fails takes the failure as the statement's own exit status and
# kills the job. That would fail closed here (no status posted, absent required check
# blocks the merge) but it would do so on the ORDINARY path — every non-bot PR — so the
# gate would look broken rather than strict. `cmd || var=yes` is safe for the same
# reason it is confusing; both are spelled out instead.
# BOTS is a short fixed literal, so it cannot reach the pipe buffer; it is still written
# with an explicit status capture so a grep error cannot read as "not a bot" by accident.
is_bot=no
bot_hits=$(printf '%s\n' "$BOTS" | tr ' ' '\n' | grep -cxF "$AUTHOR") || bot_hits=0
if [ "${bot_hits:-0}" -gt 0 ]; then is_bot=yes; fi
manifests_only=no
if [ "$n_not_manifest" -eq 0 ]; then manifests_only=yes; fi
docs_only=no
if [ "$n_not_docs" -eq 0 ]; then docs_only=yes; fi
if [ "$is_bot" = yes ] && [ "$manifests_only" = yes ]; then
exempt=yes
reason="authored by the '$AUTHOR' bot account, touches no protected path, and changes only dependency manifests"
elif [ "$docs_only" = yes ]; then
exempt=yes
reason="docs-only change (no code, no protected path)"
elif [ "$is_bot" = yes ]; then
reason="authored by the '$AUTHOR' bot account, but changes files outside the dependency-manifest set — a bot ACCOUNT does not attribute the CODE at this head (the account is the PR's immutable creator; the head is not), so this needs a real verdict"
else
reason="awaiting an H10 review verdict for head ${SHA:0:7}"
fi
fi
if [ "$exempt" = yes ]; then
state=success
desc="Exempt: $reason"
else
state=pending
desc="Awaiting review verdict for ${SHA:0:7}"
fi
echo "Decision: state=${state} — ${reason}"
# LAST-MOMENT RE-READ (ersatztv#706). Classification takes several API round-trips, and a
# reviewer can post a verdict during them — most dangerously a `failure`, which this job would
# then overwrite with an exemption `success`, turning an explicit human rejection green. The
# first read cannot see that; this one can. It NARROWS the window, it does not close it: there
# is no compare-and-set on Gitea's status API, so a verdict landing between this read and the
# POST below is still lost. Said plainly here rather than left as an implied guarantee.
read_existing_verdict
if [ "$ex_human" = yes ]; then
echo "::notice::A human verdict ('${ex_state}' by '${ex_creator}') landed on ${SHA:0:7} while this job was classifying — leaving it alone and posting nothing."
exit 0
fi
payload=$(jq -n --arg s "$state" --arg c "$CONTEXT" --arg d "$desc" --arg u "$PR_URL" \
'{state:$s, context:$c, description:$d, target_url:$u}')
gh -X POST -H 'Content-Type: application/json' -d "$payload" \
"$BASE_URL/repos/$REPO/statuses/$SHA" >/dev/null
echo "Posted ${CONTEXT}=${state} on ${SHA:0:7}."
if [ "$state" = "pending" ]; then
echo "::notice::This PR needs an H10 review verdict for head ${SHA:0:7} before it can merge. After reviewing, run: scripts/post-review-verdict.sh ${PR} MERGEABLE"
fi
+1 -23
View File
@@ -46,19 +46,7 @@ msbuild.wrn
.vs/
*.sqlite3*
# Core dumps. MUST stay anchored/qualified (ersatztv#485): a bare `core` matches any path
# component named `core`, and on a case-insensitive filesystem (macOS default) that includes
# every `*/Core/` source directory — silently excluding NEW files under e.g.
# ErsatzTV.Scanner/Core/ from `git add -A`. Tracked files are unaffected, so the symptom is a
# clean local build and a CI checkout that fails to compile.
#
# Both patterns are anchored to the repo root ON PURPOSE — an unanchored `core.[0-9]*` would
# re-introduce exactly the silent-exclusion class this fixes. Tradeoff, accepted: a dump written
# into a SUBdirectory is no longer ignored (the old bare `core` did catch those). In practice the
# processes that could drop one, run from the repo root or from `bin/` — and `[Bb]in/` already covers
# the latter. An un-ignored dump is visible noise; a wrongly-ignored source file is not.
/core
/core.[0-9]*
core
scripts/generate-api-sdk/swagger.json
scripts/download-test-content.sh
@@ -73,16 +61,6 @@ web/node_modules
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
/*.png
.playwright-mcp/
# UI-E2E run artifacts: traces/screenshots Playwright writes on failure (outputDir in
# web/playwright.config.ts), plus the report dir it would use if a reporter is ever added (#445).
web/e2e/.output/
web/playwright-report/
# Per-session worktree-ownership marker (H7, ersatztv#303) — local, never committed
.claude-worktree-owner
# Codex CLI project scaffolding — a machine-local mirror of the .claude hooks, generated by
# `codex exec`. Deliberately NOT tracked even though `.claude/` is: its config.toml embeds a
# 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/
+5
View File
@@ -8,3 +8,8 @@ grep -q '^Co-Authored-By:' "$1" || {
echo 'husky - commit message missing Co-Authored-By trailer'
exit 1
}
# H9 (ersatztv#303) — docs/decisions.md is append-only. Block a commit that rewrites a settled
# entry unless the message carries [decisions-edit]. commit-msg runs after the index is final, so
# the staged diff is what's being committed; the message file ($1) supplies the override token.
./.claude/hooks/decisions-guard.sh staged "$1" || exit 1
+6 -14
View File
@@ -1,12 +1,6 @@
cd web && npx lint-staged || exit 1
cd ..
# ersatztv#521 — decision-record lifecycle structural validator (replaces the old H9 append-only
# line guard). Runs the same validator the CI `decisions lifecycle` job uses, over the working
# tree (no base/head here, so only structural checks run; the body-diff/no-vanish checks run in
# CI where a base ref exists). Fail-open shim — see .claude/hooks/decisions-guard.sh.
./.claude/hooks/decisions-guard.sh || exit 1
# H3 (ersatztv#303) — never commit a screenshot dropped at the repo root. Belt-and-suspenders with
# .gitignore (catches a forced `git add -f`). Root-level *.png only; nested paths are legit assets.
root_png=$(git diff --cached --name-only --diff-filter=ACM | grep -iE '^[^/]+\.png$' || true)
@@ -17,17 +11,15 @@ if [ -n "$root_png" ]; then
exit 1
fi
# dotnet format on staged .cs files (repo root). Uses `whitespace . --folder` — same recipe as
# the CI `format` job (ersatztv#469): folder mode checks .editorconfig whitespace + charset (BOM)
# without the MSBuild/Roslyn workspace load, so it runs in ~0.5s instead of the old ~20-40s sln
# load. Keeping this identical to CI avoids a local hook that blocks on rules CI no longer enforces.
# Skip entirely when no .cs is staged (avoids any cost for web-only commits).
# dotnet format on staged .cs files (repo root). Scoped to the staged files so we
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
# ~20-40s sln load for web-only commits).
cs_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.cs')
if [ -n "$cs_files" ]; then
echo "husky - dotnet format (whitespace verify) on staged .cs files"
echo "husky - dotnet format (verify) on staged .cs files"
# shellcheck disable=SC2086
dotnet format whitespace . --folder --verify-no-changes --include $cs_files || {
echo "husky - dotnet format found whitespace/BOM issues in staged .cs files; run 'dotnet format whitespace . --folder --include <files>' to fix"
dotnet format ErsatzTV.sln --verify-no-changes --include $cs_files || {
echo "husky - dotnet format found issues in staged .cs files; run 'dotnet format ErsatzTV.sln --include <files>' to fix"
exit 1
}
fi
+38 -35
View File
@@ -4,9 +4,25 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture
- **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root `/` and every legacy route now 302 to `/app`, either via an explicit redirect in `ErsatzTV/LegacyUiRedirects.cs` or the Startup catch-all fallback (any unmatched non-`/api`/`/artwork`/`/docs`/`/openapi` path → `/app`). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE.
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
- **Functional C#**: Language Ext (Option, Either monads throughout)
### Project Layout
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, DI setup |
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
| `ErsatzTV.Scanner/` | Media library scanning |
### Key Files
@@ -19,14 +35,20 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Deployment
- **Docker host**: **jazz (192.168.1.29)**, container `ersatztv`, port 8409. Media transcoders (Jellyfin, `ersatztv`, `ersatztv-test`) moved here from bumblebee on 2026-07-20 (server-management#633); bumblebee (192.168.1.99) still hosts the **CI runners** and the rest of the stacks. **Name-reuse trap**: `jazz` was an *earlier* name for the .99 host, so pre-2026-07-20 docs/commits saying "jazz" mean today's **bumblebee** — go by the IP, not the name.
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz`/config` in container
- **Docker host**: bumblebee (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on bumblebee`/config` in container
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** stack — named **`jazz-media`** (the compose *project* is still `media-servers`; a dead `media-servers` stack lingers on bumblebee) — follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually `DeployStack jazz-media`. There is **no** auto-update fallback (`auto_update: false`) — promotion is manual. Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** `media-servers` stack follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually deploy the stack (Global Auto Update is the daily fallback). Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
## Development
```bash
# Build
dotnet build ErsatzTV.sln
# Run locally (needs FFmpeg in PATH)
dotnet run --project ErsatzTV
# Docker build
docker build -f docker/Dockerfile -t ersatztv:dev .
```
@@ -34,7 +56,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read the `docs/README.md` **task-signal map** and only the sections it points to for your task — not the whole corpus. **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code. **Decision/convention lookups start at the active catalog**, `docs/decisions/README.md` — resolve by topic/key, never by chasing a file path named in a historical comment (the breadcrumb rule; see `docs/README.md` → "Knowledge retrieval").
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
| Change | Update in the same PR |
@@ -42,7 +64,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
| Change a SPA screen convention | `docs/spa-conventions.md` |
| Establish / reverse a convention or decision | a new `docs/decisions/records/<area>/<topic>.md` (filename = key; lifecycle: add record, `git mv` predecessor to `archive/<area>/`) + regenerate the catalog + the affected doc |
| Establish / reverse a convention or decision | `docs/decisions.md` (append-only) + the affected doc |
| Add / remove / retitle a doc | `docs/README.md` index |
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
@@ -52,56 +74,37 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Their `review-verdict/h10` required check is auto-passed **only when BOTH hold**: the PR touches none of `.claude/`/`.gitea/`/`.husky/`/`scripts/`/`docker/ci/`, **and** every changed path is a dependency manifest (`Directory.Packages.props`, `.config/dotnet-tools.json`) — ersatztv#698. A bot ACCOUNT does not attribute the CODE at a head, so identity alone is no longer sufficient; a Renovate PR touching a `.csproj` or a source file is not blocked, it just needs a real verdict. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
## Working in parallel with other sessions
**Subagents are explicitly permitted and encouraged here.** Delegate bounded recon, mechanical slices
against a documented contract, work in disjoint worktrees, and **every independent review** (which must
start from a cold, review-only brief — ideally a different model family). Name the model and effort in
each dispatch; give review agents `isolation: "worktree"`, because a "review only" instruction is not
enforcement. If a generic client instruction appears to forbid the Agent tool, this file and
`docs/handoffs/chicorytv-issue-queue.md` override it — say so once and carry on. Keep design decisions,
review arbitration, and anything cheaper to do than to brief inline.
**Claiming an issue is a check, not just a label** (`process.parallel-session-claim`). `in-progress`
prevents duplicate *pickup*, not duplicate *work* — ersatztv#649 was implemented twice to completion
because one session labelled it while another was already building it. Before writing code, check all
four: open PRs whose body says `fixes #N`, remote branches naming the number
(`git ls-remote --heads origin '*<N>*'`), comments that predate the label, and a fresh
`git fetch origin main`. Then apply the label **and** a claiming comment.
**Re-fetch `origin/main` before every push, not only at branch time.** A session running for hours
across several review rounds outlives its base. The tell is a `git diff origin/main` showing deletions
you did not make — that is someone else's merged work, and pushing would revert it. Rebase (never merge
main in) and re-run the local gate whenever the fetch shows movement.
## Task Completion Protocol
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
**Merge-consent is 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|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/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
- `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), post a PR comment with a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED> @ <head-sha>` — this proves the *latest* commit was reviewed, not a stale earlier diff (ersatztv#242).
- `.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.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
**The 7 mandatory completion steps and the `## Closing record` comment template** live in the
`closing-an-issue` skill (`.claude/skills/closing-an-issue/SKILL.md`) — invoke it (or `/done`)
when finishing a task that closes an issue.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
## Project Boundaries
**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 OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
**ersatztv does NOT own**:
- 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 (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`, …).
- Jellyfin skill → server-management (symlinked)
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
+4 -4
View File
@@ -6,7 +6,7 @@
<ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="CliWrap" Version="3.10.3" />
<PackageVersion Include="CliWrap" Version="3.10.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.79" />
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
@@ -29,7 +29,7 @@
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
<PackageVersion Include="MediatR" Version="[12.5.0]" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.129" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
@@ -93,8 +93,8 @@
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.27.0.140913" />
<!-- Direct pin to override EF Core 9's transitive SQLitePCLRaw 2.1.10 (vulnerable
bundled SQLite, GHSA-2m69-gcr7-jv3q). The 3.x line ships the patched native
(lib.e_sqlite3 3.50.3); core 3.0.4 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
(lib.e_sqlite3 3.50.3); core 3.0.3 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
@@ -9,13 +9,8 @@ namespace ErsatzTV.Application.Artworks;
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
{
private readonly IImageCache _imageCache;
private readonly IRemoteImageValidator _validator;
public UploadArtworkHandler(IImageCache imageCache, IRemoteImageValidator validator)
{
_imageCache = imageCache;
_validator = validator;
}
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
UploadArtwork request,
@@ -43,22 +38,6 @@ public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseEr
string contentType = maybeContentType.IfNone(string.Empty);
// One rule: anything entering the logo cache is decode-budget-checked. A supported format is
// not enough — a small header can declare a multi-gigabyte canvas (a decompression bomb), so
// reject it here before it lands in the cache. The synthetic upload:// Uri is only for the
// exception message text. (ersatztv#525)
using (var probe = new MemoryStream(bytes, writable: false))
{
try
{
await _validator.Validate(probe, new Uri("upload://artwork"), cancellationToken);
}
catch (Exception ex)
{
return BaseError.New($"Image cannot be used: {ex.Message}");
}
}
using var toCache = new MemoryStream(bytes, writable: false);
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
toCache,
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
@@ -43,8 +43,7 @@ public record CreateChannelFromLineupAdvancedOptions(
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
IReadOnlyList<CreateChannelFromLineupClearField> Clear = null);
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
@@ -8,7 +8,6 @@ using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -22,7 +21,6 @@ public class CreateChannelFromLineupHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
IRemoteLogoCacher remoteLogoCacher,
ILogger<CreateChannelFromLineupHandler> logger)
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
{
@@ -39,42 +37,7 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: async prepared =>
{
Either<BaseError, PreparedCreate> resolved =
await ResolveExternalLogo(request, prepared, cancellationToken);
return await resolved.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: p => PersistAndDispatch(dbContext, p, cancellationToken));
});
}
// The lineup logo artwork is built (in BuildChannel) with the raw request path. When that path is
// an external http(s) URL, download + cache it and swap the cache name onto the logo artwork before
// persisting (a cacher Left fails the whole create); a blank or already-local/cached path is left
// unchanged. (ersatztv#525)
private async Task<Either<BaseError, PreparedCreate>> ResolveExternalLogo(
CreateChannelFromLineup request,
PreparedCreate prepared,
CancellationToken cancellationToken)
{
string path = request.Logo?.Path ?? string.Empty;
if (!Artwork.IsExternalUrl(path))
{
return prepared;
}
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
return cached.Map(name =>
{
foreach (Artwork logo in prepared.Channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
{
logo.Path = name;
}
return prepared;
});
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
}
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
@@ -190,21 +153,11 @@ public class CreateChannelFromLineupHandler(
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
// "clear to none" (#135): a field named in advanced.Clear is forced to none even when the
// template sets one; both setting and clearing the same field is contradictory.
Either<BaseError, Unit> clearValidation = ValidateClear(advanced);
foreach (BaseError error in clearValidation.LeftToSeq())
{
return error;
}
ResolvedClearableOptions resolved = ResolveClearable(advanced, template);
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = resolved.FallbackFillerId;
int? preRollFillerId = resolved.PreRollFillerId;
int? midRollFillerId = resolved.MidRollFillerId;
int? postRollFillerId = resolved.PostRollFillerId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
@@ -217,8 +170,8 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
ffmpegProfileId,
resolved,
advanced,
template,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
@@ -282,7 +235,6 @@ public class CreateChannelFromLineupHandler(
request,
template,
advanced,
resolved,
name,
number,
group,
@@ -302,7 +254,6 @@ public class CreateChannelFromLineupHandler(
playbackOrder,
advanced,
template,
resolved,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
@@ -395,7 +346,6 @@ public class CreateChannelFromLineupHandler(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
ResolvedClearableOptions resolved,
string name,
string number,
string group,
@@ -434,14 +384,16 @@ public class CreateChannelFromLineupHandler(
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = resolved.WatermarkId,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
@@ -450,8 +402,7 @@ public class CreateChannelFromLineupHandler(
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg,
Origin = ChannelOrigin.AutoTuned
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
}
@@ -474,7 +425,6 @@ public class CreateChannelFromLineupHandler(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
ResolvedClearableOptions resolved,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
@@ -491,9 +441,11 @@ public class CreateChannelFromLineupHandler(
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
PreferredAudioTitle = resolved.PreferredAudioTitle,
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
@@ -537,21 +489,20 @@ public class CreateChannelFromLineupHandler(
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
int ffmpegProfileId,
ResolvedClearableOptions resolved,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
// Validate the post-clear effective ids: a cleared reference resolves to null and skips the
// existence check (there is nothing to point at).
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
resolved.WatermarkId,
resolved.FallbackFillerId,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
@@ -560,9 +511,9 @@ public class CreateChannelFromLineupHandler(
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
resolved.PreRollFillerId,
resolved.MidRollFillerId,
resolved.PostRollFillerId,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
@@ -572,80 +523,6 @@ public class CreateChannelFromLineupHandler(
return Unit.Default;
}
// A field named in advanced.Clear must not also carry a set value: that request is contradictory.
// A null/empty set value alongside a clear is fine (redundant, not conflicting). (#135)
private static Either<BaseError, Unit> ValidateClear(CreateChannelFromLineupAdvancedOptions advanced)
{
if (advanced.Clear is null || advanced.Clear.Count == 0)
{
return Unit.Default;
}
var cleared = advanced.Clear.ToHashSet();
(CreateChannelFromLineupClearField Field, bool HasSetValue)[] checks =
[
(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId.HasValue),
(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId.HasValue),
(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId.HasValue),
(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId.HasValue),
(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId.HasValue),
(CreateChannelFromLineupClearField.PreferredAudioLanguage,
!string.IsNullOrEmpty(advanced.PreferredAudioLanguageCode)),
(CreateChannelFromLineupClearField.PreferredAudioTitle,
!string.IsNullOrEmpty(advanced.PreferredAudioTitle)),
(CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
!string.IsNullOrEmpty(advanced.PreferredSubtitleLanguageCode))
];
foreach ((CreateChannelFromLineupClearField field, bool hasSetValue) in checks)
{
if (cleared.Contains(field) && hasSetValue)
{
return BaseError.New(
$"Advanced option '{field}' cannot be both set and cleared in the same request");
}
}
return Unit.Default;
}
// Compute the effective value of every clearable field once: cleared -> none, else the advanced
// override coalesced with the template value (the historical omitted=inherit contract). (#135)
private static ResolvedClearableOptions ResolveClearable(
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template)
{
System.Collections.Generic.HashSet<CreateChannelFromLineupClearField> cleared = advanced.Clear is null
? []
: advanced.Clear.ToHashSet();
int? Id(CreateChannelFromLineupClearField field, int? adv, int? tmpl) =>
cleared.Contains(field) ? null : adv ?? tmpl;
string Str(CreateChannelFromLineupClearField field, string adv, string tmpl) =>
cleared.Contains(field) ? string.Empty : adv ?? tmpl ?? string.Empty;
return new ResolvedClearableOptions(
Id(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId, template.WatermarkId),
Id(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId, template.FallbackFillerId),
Id(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId, template.PreRollFillerId),
Id(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId, template.MidRollFillerId),
Id(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId, template.PostRollFillerId),
Str(
CreateChannelFromLineupClearField.PreferredAudioLanguage,
advanced.PreferredAudioLanguageCode,
template.PreferredAudioLanguageCode),
Str(
CreateChannelFromLineupClearField.PreferredAudioTitle,
advanced.PreferredAudioTitle,
template.PreferredAudioTitle),
Str(
CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
advanced.PreferredSubtitleLanguageCode,
template.PreferredSubtitleLanguageCode));
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
@@ -889,16 +766,4 @@ public class CreateChannelFromLineupHandler(
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
// Effective values for the clearable advanced fields after applying advanced.Clear + template
// coalescing (#135). Strings coalesce to string.Empty (never null); ids stay nullable.
private sealed record ResolvedClearableOptions(
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode);
}
@@ -1,10 +1,9 @@
using System.Globalization;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -17,8 +16,7 @@ namespace ErsatzTV.Application.Channels;
public class CreateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
IRemoteLogoCacher remoteLogoCacher)
ISearchTargets searchTargets)
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
{
public async Task<Either<BaseError, CreateChannelResult>> Handle(
@@ -27,52 +25,7 @@ public class CreateChannelHandler(
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Succ: async channel =>
{
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
return await resolvedLogo.Match(
Right: async logoPath =>
{
ApplyResolvedLogo(request, channel, logoPath);
return Right<BaseError, CreateChannelResult>(await PersistChannel(dbContext, channel));
},
Left: e => Task.FromResult(Left<BaseError, CreateChannelResult>(e)));
},
Fail: errors => Task.FromResult(Left<BaseError, CreateChannelResult>(errors.Join())));
}
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
// downloaded and cached (a cacher Left fails the whole save); an empty path or an
// already-local/cached path passes through unchanged. (ersatztv#525)
private async Task<Either<BaseError, string>> ResolveLogoPath(
CreateChannel request,
CancellationToken cancellationToken)
{
string path = request.Logo?.Path ?? string.Empty;
if (!Artwork.IsExternalUrl(path))
{
return path;
}
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
return cached;
}
// When the incoming logo was an external URL, swap the downloaded cache name onto the logo
// artwork built during validation so no URL is ever persisted in Artwork.Path. (ersatztv#525)
private static void ApplyResolvedLogo(CreateChannel request, Channel channel, string resolvedLogoPath)
{
if (!Artwork.IsExternalUrl(request.Logo?.Path ?? string.Empty))
{
return;
}
foreach (Artwork logo in channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
{
logo.Path = resolvedLogoPath;
}
return await validation.Apply(c => PersistChannel(dbContext, c));
}
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
@@ -152,8 +105,7 @@ public class CreateChannelHandler(
TranscodeMode = request.TranscodeMode,
IdleBehavior = request.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg,
Origin = ChannelOrigin.UserCreated
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -32,5 +32,4 @@ public record UpdateChannel(
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg,
List<int> GraphicsElementIds) : IRequest<Either<BaseError, ChannelViewModel>>;
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -20,8 +19,7 @@ namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
IRemoteLogoCacher remoteLogoCacher)
ISearchTargets searchTargets)
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
public async Task<Either<BaseError, ChannelViewModel>> Handle(
@@ -34,7 +32,6 @@ public class UpdateChannelHandler(
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.Playouts)
.Include(c => c.ChannelGraphicsElements)
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
return await maybeChannel.Match(
@@ -42,47 +39,29 @@ public class UpdateChannelHandler(
{
Validation<BaseError, Channel> validation =
await Validate(dbContext, request, channel, cancellationToken);
return await validation.Match(
Succ: async c =>
{
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
return await resolvedLogo.Match(
Right: async logoPath => Right<BaseError, ChannelViewModel>(
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
},
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult(
Left<BaseError, ChannelViewModel>(
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
// downloaded and cached (a cacher Left fails the whole save); an empty path (logo removal) or an
// already-local/cached path passes through unchanged. (ersatztv#525)
private async Task<Either<BaseError, string>> ResolveLogoPath(
UpdateChannel request,
CancellationToken cancellationToken)
{
string path = request.Logo?.Path ?? string.Empty;
if (!Artwork.IsExternalUrl(path))
{
return path;
}
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
return cached;
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
UpdateChannel update,
string resolvedLogoPath,
CancellationToken cancellationToken)
{
// don't save mirror when playout exists
if (c.Playouts.Count > 0)
{
update = update with
{
PlayoutSource = ChannelPlayoutSource.Generated,
MirrorSourceChannelId = null
};
}
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name;
@@ -107,9 +86,9 @@ public class UpdateChannelHandler(
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(resolvedLogoPath))
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
{
string logo = resolvedLogoPath;
string logo = update.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
@@ -161,8 +140,6 @@ public class UpdateChannelHandler(
c.PlayoutMode = ChannelPlayoutMode.Continuous;
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
}
else
{
@@ -170,18 +147,12 @@ public class UpdateChannelHandler(
c.PlayoutOffset = null;
}
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
c.ChannelGraphicsElements ??= [];
var desired = update.GraphicsElementIds?.Distinct().ToList() ?? [];
c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId));
foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id)))
{
c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id });
}
await dbContext.SaveChangesAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
@@ -223,7 +194,7 @@ public class UpdateChannelHandler(
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((_, _, _, _, _) => channel);
@@ -298,7 +269,6 @@ public class UpdateChannelHandler(
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
Channel channel,
CancellationToken cancellationToken)
{
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
@@ -306,18 +276,6 @@ public class UpdateChannelHandler(
return Unit.Default;
}
// a channel with its own playout already built (Generated mode) cannot become a Mirror —
// Mirror channels relay another channel's playout and never build one of their own, so
// switching this transition on would strand the existing playout. This used to be
// silently coerced back to Generated (issue #401); reject the transition instead so the
// caller sees why the requested Mirror source was not applied. A round-trip that keeps
// PlayoutSource as Generated never reaches this check.
if (channel.Playouts.Count > 0)
{
return BaseError.New(
"Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first.");
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
+4 -196
View File
@@ -29,106 +29,6 @@ internal static class Mapper
return result;
}
internal static ChannelHealthResponseModel GetHealth(
Channel channel,
int playoutCount,
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
{
if (playoutCount == 0)
{
return new ChannelHealthResponseModel(
ChannelHealthStatus.Problems,
[ChannelFault.NoPlayout],
0,
0);
}
var faults = new System.Collections.Generic.HashSet<string>();
var brokenSourceItemCount = 0;
var sawAssessable = false;
foreach ((Playout playout, ChannelPlayoutMode ownerMode) in ContributingPlayoutsWithOwnerMode(channel))
{
bool isOnDemand = ownerMode == ChannelPlayoutMode.OnDemand;
upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u);
brokenSourceItemCount += u.BrokenUpcoming;
bool built = playout.BuildStatus is not null && playout.BuildStatus.LastBuild != default;
// Presence signals — always live.
if (built && playout.BuildStatus.Success == false)
{
faults.Add(ChannelFault.BuildFailed);
}
if (u.BrokenUpcoming > 0)
{
faults.Add(ChannelFault.BrokenSource);
}
// Absence signals — suppressed for on-demand (drains between tune-ins).
if (!isOnDemand)
{
if (!built)
{
faults.Add(ChannelFault.NeverBuilt);
}
else if (u.TotalUpcoming == 0)
{
faults.Add(ChannelFault.EmptyUpcoming);
}
else
{
sawAssessable = true;
}
}
else if (built && u.TotalUpcoming > 0)
{
sawAssessable = true;
}
}
string status = faults.Count > 0
? ChannelHealthStatus.Problems
: sawAssessable
? ChannelHealthStatus.Healthy
: ChannelHealthStatus.Unknown;
return new ChannelHealthResponseModel(
status,
faults.ToArray(),
playoutCount,
brokenSourceItemCount);
}
internal static IEnumerable<Playout> ContributingPlayouts(Channel channel) =>
ContributingPlayoutsWithOwnerMode(channel).Select(x => x.Playout);
// Mirror channels are forced Continuous (UpdateChannelHandler), but a mirror of an on-demand SOURCE relays
// playouts that legitimately drain between tune-ins. Absence-signal suppression must key off the mode of the
// channel that OWNS each playout, not the mirror's own (always-Continuous) mode — so pair each playout with
// its owner's mode here, once, rather than re-deriving it at each call site.
private static IEnumerable<(Playout Playout, ChannelPlayoutMode OwnerMode)> ContributingPlayoutsWithOwnerMode(
Channel channel)
{
if (channel.Playouts is not null)
{
foreach (Playout p in channel.Playouts)
{
yield return (p, channel.PlayoutMode);
}
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
{
foreach (Playout p in channel.MirrorSourceChannel.Playouts)
{
yield return (p, channel.MirrorSourceChannel.PlayoutMode);
}
}
}
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
new(
channel.Id,
@@ -161,10 +61,7 @@ internal static class Mapper
channel.IsEnabled,
channel.ShowInEpg);
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(
Channel channel,
int playoutCount,
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(Channel channel, int playoutCount)
{
ArtworkContentTypeModel logo = GetLogo(channel);
return new ChannelDetailResponseModel(
@@ -196,15 +93,10 @@ internal static class Mapper
channel.TranscodeMode,
channel.IdleBehavior,
channel.IsEnabled,
channel.ShowInEpg,
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? [],
GetHealth(channel, playoutCount, upcoming));
channel.ShowInEpg);
}
internal static ChannelResponseModel ProjectToResponseModel(
Channel channel,
int playoutCount,
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming) =>
internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) =>
new(
channel.Id,
channel.Number,
@@ -217,11 +109,7 @@ internal static class Mapper
GetStreamingMode(channel),
channel.IsEnabled,
channel.ShowInEpg,
playoutCount,
GetLogoUrl(channel),
GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount),
channel.Origin,
GetHealth(channel, playoutCount, upcoming));
playoutCount);
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -235,31 +123,6 @@ internal static class Mapper
channel.FFmpegProfile.VideoProfile,
channel.FFmpegProfile.AudioFormat);
// Rooted, directly-usable channel-logo URL for the SPA's <img src> on browse surfaces (guide grid +
// channels list), following the #181 artwork convention (docs/api-conventions.md §4): the SPA does no
// client-side path building. External logo URLs pass through as-is; an uploaded logo ("iptv/logos/{file}")
// is rooted with a leading slash so it resolves against the site root regardless of the current SPA route.
// Returns null when the channel has no logo, so the SPA falls back to the generated initials "bug".
#nullable enable
internal static string? GetLogoUrl(Channel channel)
{
// Browse surfaces must not crash the whole list over a missing Artwork include; GetLogo assumes
// the caller included Channel.Artwork (GetAll + the guide query do), but stay defensive here.
if (channel.Artwork is null)
{
return null;
}
ArtworkContentTypeModel logo = GetLogo(channel);
if (string.IsNullOrWhiteSpace(logo.Path))
{
return null;
}
return logo.IsExternalUrl || logo.Path.StartsWith('/') ? logo.Path : $"/{logo.Path}";
}
#nullable restore
private static ArtworkContentTypeModel GetLogo(Channel channel)
{
Option<Artwork> maybeArtwork = channel.Artwork
@@ -285,59 +148,4 @@ internal static class Mapper
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};
#nullable enable
internal static ChannelPreviewResponseModel GetPreview(
StreamingMode streamingMode,
string channelNumber,
bool isEnabled,
int playoutCount)
{
// Precedence among the two Unavailable causes (checked in this order; the first match wins):
// 1. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
// preview must not even try.
// 2. no playout — the channel could theoretically play once scheduled, but a manifest
// request against it blocks indefinitely today; catch it before that happens.
//
// IPTV JWT auth (ConditionalIptvAuthorizeFilter, active only when JWT:IssuerSigningKey is set) is no
// longer an Unavailable cause: the SPA mints a short-lived token via GET /api/v1/auth/iptv-token and
// appends it as ?access_token= to the manifest URL below (issue #552). The token is global and the
// ManifestUrl is identical with or without JWT, so this projection is JWT-agnostic.
if (!isEnabled)
{
return new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Unavailable,
null,
"Channel is disabled");
}
if (playoutCount == 0)
{
return new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Unavailable,
null,
"Channel has no playout");
}
return streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter or StreamingMode.HttpLiveStreamingDirect =>
new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Available,
$"/iptv/channel/{channelNumber}.m3u8",
null),
// A browser cannot play video/mp2t. Forcing ?mode=segmenter yields a playable stream,
// but one that does not exercise the channel's configured Transport Stream pipeline —
// the SPA labels this result accordingly.
StreamingMode.TransportStream or StreamingMode.TransportStreamHybrid =>
new ChannelPreviewResponseModel(
ChannelPreviewAvailability.ForcedHlsOnly,
$"/iptv/channel/{channelNumber}.m3u8?mode=segmenter",
null),
_ => throw new ArgumentOutOfRangeException(nameof(streamingMode))
};
}
#nullable restore
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
@@ -12,13 +12,7 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
GetAllChannelsForApi request,
CancellationToken cancellationToken)
{
List<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten().ToList();
var playoutIds = channels
.SelectMany(c => ContributingPlayouts(c).Select(p => p.Id))
.Distinct()
.ToList();
Dictionary<int, PlayoutUpcoming> upcoming =
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList();
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
}
}
@@ -1,5 +1,4 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
@@ -8,20 +7,9 @@ namespace ErsatzTV.Application.Channels;
public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByIdForApi, Option<ChannelDetailResponseModel>>
{
public async Task<Option<ChannelDetailResponseModel>> Handle(
public Task<Option<ChannelDetailResponseModel>> Handle(
GetChannelByIdForApi request,
CancellationToken cancellationToken)
{
Option<Channel> maybeChannel = await channelRepository.GetChannel(request.Id);
foreach (Channel channel in maybeChannel)
{
var playoutIds = ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList();
Dictionary<int, PlayoutUpcoming> upcoming =
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming);
}
return Option<ChannelDetailResponseModel>.None;
}
CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel)));
}
@@ -47,7 +47,6 @@ public class GetChannelGuideDataHandler(
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.Where(c => c.ShowInEpg)
.Include(c => c.Artwork)
.Include(c => c.MirrorSourceChannel)
.ToListAsync(cancellationToken);
@@ -122,7 +121,6 @@ public class GetChannelGuideDataHandler(
new ChannelGuideChannelResponseModel(
channel.Number,
channel.Name,
Mapper.GetLogoUrl(channel),
programmes.OrderBy(p => p.Start).ToList()));
}
@@ -60,11 +60,10 @@ public partial class GetChannelGuideHandler(
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(request.AccessToken))
{
// The token lands in a URL query value inside an XMLTV attribute, so it needs BOTH layers:
// percent-encode first (#421 — a token with '&' would otherwise split the query and truncate
// the token once the consumer URL-decodes the attribute; mirrors the M3U fix), then XML-escape
// the result so it can't malform the guide (#376). Both are no-ops for an opaque base64url token.
accessTokenUri += $"&amp;access_token={SecurityElement.Escape(Uri.EscapeDataString(request.AccessToken))}";
// The token value is HTTP-request-derived and interpolated raw into the pre-built XMLTV
// cache fragments, so it must be XML-escaped like {RequestBase} above — a token containing
// '&', '<', '>', or '"' would otherwise malform the whole guide. Opaque tokens are a no-op.
accessTokenUri += $"&amp;access_token={SecurityElement.Escape(request.AccessToken)}";
}
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
@@ -34,5 +34,4 @@ public record CreateFFmpegProfile(
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
@@ -1,8 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -68,11 +67,7 @@ public class CreateFFmpegProfileHandler :
HardwareAcceleration = hwAccel,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
// 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,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
@@ -110,8 +105,7 @@ public class CreateFFmpegProfileHandler :
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
NormalizeColors = request.NormalizeColors,
DeinterlaceVideo = request.DeinterlaceVideo,
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
DeinterlaceVideo = request.DeinterlaceVideo
};
});
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
@@ -35,5 +35,4 @@ public record UpdateFFmpegProfile(
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
@@ -3,7 +3,6 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Preset;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -55,11 +54,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
// 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.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.PadMode = update.PadMode;
@@ -107,7 +102,6 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.NormalizeFramerate = update.NormalizeFramerate;
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo;
p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
// don't save invalid preset
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
@@ -35,5 +35,4 @@ public record FFmpegProfileViewModel(
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder);
bool DeinterlaceVideo);
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Api.FFmpegProfiles;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles;
@@ -37,8 +37,7 @@ internal static class Mapper
profile.AudioSampleRate,
profile.NormalizeFramerate,
profile.NormalizeColors,
profile.DeinterlaceVideo == true,
profile.QsvPreferNativeDecoder != false);
profile.DeinterlaceVideo == true);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
new(
@@ -81,6 +80,5 @@ internal static class Mapper
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.NormalizeColors,
ffmpegProfile.DeinterlaceVideo == true,
ffmpegProfile.QsvPreferNativeDecoder != false);
ffmpegProfile.DeinterlaceVideo == true);
}
@@ -1,6 +1,5 @@
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Graphics.Mapper;
@@ -19,14 +18,10 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
.AsNoTracking()
.ToListAsync(cancellationToken);
return graphicsElements
.Select(e => new
{
Vm = ProjectToViewModel(e),
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
})
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
.ThenBy(x => x.Vm.Name)
.Select(x => new GraphicsElementResponseModel(x.Vm.Id, x.Vm.Name, x.BuiltIn))
.Map(ProjectToViewModel)
.OrderBy(e => e.Name == e.FileName)
.ThenBy(e => e.Name)
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
.ToList();
}
}
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Health;
namespace ErsatzTV.Application.Health;
public record GetAllHealthCheckResultsForApi(bool Refresh = false) : IRequest<List<HealthCheckResponseModel>>;
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
@@ -18,8 +18,7 @@ public class GetAllHealthCheckResultsForApiHandler
{
try
{
List<HealthCheckResult> results =
await _healthCheckService.PerformHealthChecks(request.Refresh, cancellationToken);
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
return results
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
.Map(ProjectToResponseModel)
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health;
namespace ErsatzTV.Application.Health;
@@ -15,7 +15,7 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(false, cancellationToken);
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
@@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core;
@@ -70,23 +70,9 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(MediaKindMustBeSupportedLocally)
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
/// <summary>
/// Mixed is only ever produced for remote (Jellyfin) libraries, where the media server classifies
/// each item for us. No local folder scanner handles it, so a local Mixed library would fail every
/// scan forever. The API takes a raw LibraryMediaKind, so this must be enforced here rather than
/// left to the SPA's media-kind options.
/// </summary>
private static Validation<BaseError, LocalLibrary> MediaKindMustBeSupportedLocally(
LocalLibrary localLibrary) =>
localLibrary.MediaKind is LibraryMediaKind.Mixed
? BaseError.New(
"Local libraries cannot use the Mixed media kind; it is only valid for Jellyfin libraries.")
: localLibrary;
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext,
CreateLocalLibrary request) =>
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -35,8 +35,7 @@ public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFa
CancellationToken cancellationToken) =>
PlaylistGroupMustExist(dbContext, request, cancellationToken)
.BindT(PlaylistGroupMustNotBeSystem)
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup))
.BindT(playlistGroup => NameMustBeUnique(dbContext, request, playlistGroup));
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup));
private static Task<Validation<BaseError, PlaylistGroup>> PlaylistGroupMustExist(
TvContext dbContext,
@@ -58,23 +57,4 @@ public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFa
private static Validation<BaseError, string> ValidateName(RenamePlaylistGroup request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
// Issue #458: PlaylistGroup.Name carries a global unique index, but CreatePlaylistGroupHandler
// has no explicit duplicate guard (it relies on the DB constraint). Add one on rename so a
// collision surfaces as a clean 422 rather than a raw DbUpdateException. Excludes the group
// itself so a no-op rename to its own name still succeeds.
private static async Task<Validation<BaseError, PlaylistGroup>> NameMustBeUnique(
TvContext dbContext,
RenamePlaylistGroup request,
PlaylistGroup playlistGroup)
{
Option<PlaylistGroup> maybeExisting = await dbContext.PlaylistGroups
.AsNoTracking()
.FirstOrDefaultAsync(pg => pg.Id != request.PlaylistGroupId && pg.Name == request.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"A playlist group named \"{request.Name}\" already exists")
: Success<BaseError, PlaylistGroup>(playlistGroup);
}
}
@@ -74,33 +74,7 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
CancellationToken cancellationToken) =>
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
.BindT(playlist => ValidateName(request).Map(_ => playlist))
.BindT(playlist => PlaylistNameMustBeUnique(dbContext, playlist, request));
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
// Issue #458: mirror CreatePlaylistHandler's duplicate-name guard on rename. Uniqueness is scoped
// to the loaded playlist's group (rename cannot move groups) and excludes the playlist itself, so
// a no-op rename to its own name still succeeds. Backstopped by the (PlaylistGroupId, Name) unique
// index; this pre-check turns the common collision into a clean 422 instead of a DbUpdateException.
private static async Task<Validation<BaseError, Playlist>> PlaylistNameMustBeUnique(
TvContext dbContext,
Playlist playlist,
ReplacePlaylistItems request)
{
Option<Playlist> maybeExisting = await dbContext.Playlists
.AsNoTracking()
.FirstOrDefaultAsync(p =>
p.Id != request.PlaylistId && p.PlaylistGroupId == playlist.PlaylistGroupId && p.Name == request.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"A playlist named \"{request.Name}\" already exists in that playlist group")
: Success<BaseError, Playlist>(playlist);
}
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
ReplacePlaylistItems request,
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
+26 -34
View File
@@ -37,43 +37,23 @@ internal static class Mapper
collection.Collection is not null ? ProjectToViewModel(collection.Collection) : null,
collection.MultiCollection is not null ? ProjectToViewModel(collection.MultiCollection) : null,
collection.SmartCollection is not null ? ProjectToViewModel(collection.SmartCollection) : null,
ProjectMediaItemToViewModel(collection.MediaItem),
collection.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
Movie movie => MediaItems.Mapper.ProjectToViewModel(movie),
Episode episode => MediaItems.Mapper.ProjectToViewModel(episode),
MusicVideo musicVideo => MediaItems.Mapper.ProjectToViewModel(musicVideo),
OtherVideo otherVideo => MediaItems.Mapper.ProjectToViewModel(otherVideo),
Song song => MediaItems.Mapper.ProjectToViewModel(song),
Image image => MediaItems.Mapper.ProjectToViewModel(image),
_ => null
},
collection.FirstRunPlaybackOrder,
collection.RerunPlaybackOrder,
collection.Version);
/// <summary>
/// Flattens the <see cref="MediaItem" /> half of a selection tagged union to a named view model.
/// Shared by <see cref="RerunCollection" /> and <see cref="PlaylistItem" />, which select from an
/// identical set of media types; one copy is what stops the two drifting apart again (issue #671
/// — the same rationale as <c>ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails</c>
/// on the query side).
/// A null <paramref name="mediaItem" /> is the legitimate "this selection is not a media item"
/// case (the selection is a Collection/MultiCollection/SmartCollection instead) and maps to null.
/// An unrecognized non-null subtype keeps its id and takes a deliberately conspicuous name rather
/// than falling through to null: the id is what the editor round-trips, so returning null there
/// silently clears the user's stored selection — while throwing would fail an entire paged GET
/// over one unreadable row.
/// </summary>
private static MediaItems.NamedMediaItemViewModel ProjectMediaItemToViewModel(MediaItem mediaItem) =>
mediaItem switch
{
null => null,
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
Movie movie => MediaItems.Mapper.ProjectToViewModel(movie),
Episode episode => MediaItems.Mapper.ProjectToViewModel(episode),
MusicVideo musicVideo => MediaItems.Mapper.ProjectToViewModel(musicVideo),
OtherVideo otherVideo => MediaItems.Mapper.ProjectToViewModel(otherVideo),
Song song => MediaItems.Mapper.ProjectToViewModel(song),
Image image => MediaItems.Mapper.ProjectToViewModel(image),
RemoteStream remoteStream => MediaItems.Mapper.ProjectToNamedViewModel(remoteStream),
_ => new MediaItems.NamedMediaItemViewModel(
mediaItem.Id,
$"[unsupported media type: {mediaItem.GetType().Name}]")
};
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
new(
traktList.Id,
@@ -128,7 +108,19 @@ internal static class Mapper
playlistItem.SmartCollection is not null
? ProjectToViewModel(playlistItem.SmartCollection)
: null,
ProjectMediaItemToViewModel(playlistItem.MediaItem),
playlistItem.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
Movie movie => MediaItems.Mapper.ProjectToViewModel(movie),
Episode episode => MediaItems.Mapper.ProjectToViewModel(episode),
MusicVideo musicVideo => MediaItems.Mapper.ProjectToViewModel(musicVideo),
OtherVideo otherVideo => MediaItems.Mapper.ProjectToViewModel(otherVideo),
Song song => MediaItems.Mapper.ProjectToViewModel(song),
Image image => MediaItems.Mapper.ProjectToViewModel(image),
_ => null
},
playlistItem.PlaybackOrder,
playlistItem.Count,
playlistItem.PlayAll,
@@ -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;
@@ -15,15 +15,13 @@ public class GetPagedRerunCollectionsHandler(IDbContextFactory<TvContext> dbCont
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.RerunCollections.CountAsync(cancellationToken);
IQueryable<RerunCollection> query = dbContext.RerunCollections.AsNoTracking().IncludeSelectionDetails();
IQueryable<RerunCollection> query = dbContext.RerunCollections.AsNoTracking();
if (!string.IsNullOrWhiteSpace(request.Query))
{
query = query.Where(rc => EF.Functions.Like(rc.Name, $"%{request.Query}%"));
}
// 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
.OrderBy(rc => rc.Name)
.Skip(request.PageNum * request.PageSize)
@@ -55,10 +55,6 @@ public class GetPlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFacto
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Image).ImageMetadata)
.ThenInclude(mm => mm.Artwork)
// RemoteStream is projected by the shared ProjectMediaItemToViewModel switch as of #671;
// without its metadata the name would degrade to "???" here while every sibling type resolves.
.Include(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ToListAsync(cancellationToken);
return allItems.Map(Mapper.ProjectToViewModel).ToList();
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -16,7 +16,20 @@ public class GetRerunCollectionByIdHandler(IDbContextFactory<TvContext> dbContex
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.RerunCollections
.AsNoTracking()
.IncludeSelectionDetails()
.Include(c => c.Collection)
.Include(c => c.MultiCollection)
.Include(c => c.SmartCollection)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id, cancellationToken)
.MapT(ProjectToViewModel);
}
@@ -1,57 +0,0 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
internal static class RerunCollectionQueryExtensions
{
/// <summary>
/// The single source of truth for the navigation graph a <see cref="RerunCollection" /> needs before it
/// can be projected via <see cref="Mapper.ProjectToViewModel(RerunCollection)" />. Both the paged-list
/// and by-id handlers reload through this chain so the two cannot drift apart again (see #671 — the list
/// handler had no includes at all, so every row projected a null selection, while the by-id handler
/// covered only Movie/Season/Show/Artist and so returned a null selection for Song/OtherVideo/Image and
/// a 500 for Episode/MusicVideo).
/// Because the id and the display name are both read off these navigations, an un-included type does not
/// merely lose its label — it loses the selected id too, which is what silently cleared a stored
/// selection in the editor.
/// Deliberately narrower than the analogous playlist-item chain in <c>GetPlaylistItemsHandler</c>: the
/// rerun projection reads only each selection's id and title, never its artwork, so the
/// <c>.ThenInclude(… =&gt; …Artwork)</c> legs are omitted rather than paid for on every page.
/// </summary>
public static IQueryable<RerunCollection> IncludeSelectionDetails(this IQueryable<RerunCollection> query) =>
query
.Include(c => c.Collection)
.Include(c => c.MultiCollection)
.Include(c => c.SmartCollection)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
// No (i as Season).SeasonMetadata leg on purpose: ProjectToViewModel(Season) builds its name
// from Show.ShowMetadata and the scalar SeasonNumber, and never reads SeasonMetadata.
.Include(c => c.MediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as Image).ImageMetadata)
.Include(c => c.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata);
}
+16 -49
View File
@@ -1,24 +1,18 @@
using System.Globalization;
using System.Globalization;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems;
internal static class Mapper
{
// Every metadata navigation below is read through Optional(...).Flatten() rather than a bare
// dereference: these projections are reached from several handlers whose Include chains differ,
// and an un-included navigation must degrade to the "???" placeholder instead of throwing an
// NRE that surfaces as a 500 on a GET (issue #671).
internal static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(
show.Id,
Optional(show.ShowMetadata).Flatten().HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
internal static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
new(season.Id, $"{ShowTitle(season)} - {SeasonDescription(season)}");
internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) =>
new(artist.Id, Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match(am => am.Title, () => "???"));
new(artist.Id, artist.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => "???"));
internal static NamedMediaItemViewModel ProjectToViewModel(Movie movie) =>
new(movie.Id, MovieTitle(movie));
@@ -30,37 +24,23 @@ internal static class Mapper
new(musicVideo.Id, MusicVideoTitle(musicVideo));
internal static NamedMediaItemViewModel ProjectToViewModel(OtherVideo otherVideo) =>
new(
otherVideo.Id,
Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone().Match(ov => ov.Title, () => "???"));
new(otherVideo.Id, otherVideo.OtherVideoMetadata.HeadOrNone().Match(ov => ov.Title, () => "???"));
internal static NamedMediaItemViewModel ProjectToViewModel(Song song) =>
new(song.Id, SongTitle(song));
internal static NamedMediaItemViewModel ProjectToViewModel(Image image) =>
new(image.Id, Optional(image.ImageMetadata).Flatten().HeadOrNone().Match(i => i.Title, () => "???"));
new(image.Id, image.ImageMetadata.HeadOrNone().Match(i => i.Title, () => "???"));
internal static RemoteStreamViewModel ProjectToViewModel(RemoteStream remoteStream) =>
new(remoteStream.Id, remoteStream.Url, remoteStream.Script);
/// <summary>
/// The named projection for a <see cref="RemoteStream" />. This cannot be an overload of
/// <see cref="ProjectToViewModel(RemoteStream)" /> — that one already exists and returns a
/// <see cref="RemoteStreamViewModel" />, and C# will not overload on return type alone. Its
/// absence is why every selection-flattening switch dropped <c>RemoteStream</c> through a
/// <c>_ =&gt; null</c> arm (issue #671).
/// </summary>
internal static NamedMediaItemViewModel ProjectToNamedViewModel(RemoteStream remoteStream) =>
new(
remoteStream.Id,
Optional(remoteStream.RemoteStreamMetadata).Flatten().HeadOrNone().Match(rsm => rsm.Title, () => "???"));
private static string MovieTitle(Movie movie)
{
var title = "???";
var year = "???";
foreach (MovieMetadata movieMetadata in Optional(movie.MovieMetadata).Flatten().HeadOrNone())
foreach (MovieMetadata movieMetadata in movie.MovieMetadata.HeadOrNone())
{
title = movieMetadata.Title;
foreach (int y in Optional(movieMetadata.Year))
@@ -77,10 +57,7 @@ internal static class Mapper
var title = "???";
var year = "???";
// Season.Show and Show.ShowMetadata are only populated when the caller eager-loaded them.
// An un-included navigation must degrade to the "???" placeholder these helpers already
// produce for missing metadata — never an NRE, which surfaced as a 500 (issue #671).
foreach (ShowMetadata show in Optional(season.Show?.ShowMetadata).Flatten().HeadOrNone())
foreach (ShowMetadata show in season.Show.ShowMetadata.HeadOrNone())
{
title = show.Title;
foreach (int y in Optional(show.Year))
@@ -97,10 +74,10 @@ internal static class Mapper
private static string EpisodeTitle(Episode e)
{
string showTitle = Optional(e.Season?.Show?.ShowMetadata).Flatten().HeadOrNone()
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
var episodeNumbers = Optional(e.EpisodeMetadata).Flatten().Map(em => em.EpisodeNumber).ToList();
var episodeTitles = Optional(e.EpisodeMetadata).Flatten().Map(em => em.Title).ToList();
var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList();
var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList();
if (episodeNumbers.Count == 0 || episodeTitles.Count == 0)
{
return "[unknown episode]";
@@ -109,34 +86,24 @@ internal static class Mapper
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
var titlesString = $"{string.Join('/', episodeTitles)}";
// "s00" conventionally means Specials, so an unloaded Season must not borrow it — that would
// fabricate plausible-looking real data. Render the season as explicitly unknown instead.
string seasonNumber = e.Season is null ? "??" : $"{e.Season.SeasonNumber:00}";
return $"{showTitle}s{seasonNumber}{numbersString} - {titlesString}";
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
}
private static string MusicVideoTitle(MusicVideo mv)
{
string artistName = Optional(mv.Artist?.ArtistMetadata).Flatten().HeadOrNone()
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return Optional(mv.MusicVideoMetadata).Flatten().HeadOrNone()
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
}
private static string SongTitle(Song s)
{
// Artists is a NULLABLE primitive collection, not a navigation: a song whose tags failed to read
// is persisted by FallbackMetadataProvider with Artists never assigned, and string.Join throws
// ArgumentNullException on a null sequence. Filtering the empty case too avoids prefixing an
// artist-less song with a bare " - ".
string songArtist = Optional(s.SongMetadata).Flatten().HeadOrNone()
.Map(sm => Optional(sm.Artists).Flatten().ToList())
.Filter(artists => artists.Count > 0)
.Map(artists => $"{string.Join(", ", artists)} - ")
string songArtist = s.SongMetadata.HeadOrNone()
.Map(sm => $"{string.Join(", ", sm.Artists)} - ")
.IfNone(string.Empty);
return Optional(s.SongMetadata).Flatten().HeadOrNone()
return s.SongMetadata.HeadOrNone()
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
.IfNone("[unknown song]");
}
@@ -10,16 +10,6 @@ public class GetAllMediaSourcesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
{
// A never-scanned library has a null LastScan at runtime, but historical DB rows still carry the
// 0001-01-01 MinValue sentinel written by the old Reset_* migrations. Coerce any such residual
// sentinel to null so the API/MCP surface reports "never scanned" as null (parity with the UI),
// regardless of DB history or provider. Belt-and-suspenders alongside the NullOutNeverScannedLastScan
// data migration.
private static readonly DateTime NeverScannedThreshold = new(2000, 1, 1);
private static DateTime? NormalizeLastScan(DateTime? lastScan) =>
lastScan is { } value && value < NeverScannedThreshold ? null : lastScan;
public async Task<List<MediaSourceResponseModel>> Handle(
GetAllMediaSourcesForApi request,
CancellationToken cancellationToken)
@@ -46,7 +36,7 @@ public class GetAllMediaSourcesForApiHandler(
l.Id,
l.Name,
l.MediaKind,
NormalizeLastScan(l.LastScan),
l.LastScan,
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
.ToList();
@@ -1,35 +1,16 @@
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Application.Playouts;
public class TimeShiftOnDemandPlayoutHandler(
IPlayoutTimeShifter playoutTimeShifter,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
public class TimeShiftOnDemandPlayoutHandler(IPlayoutTimeShifter playoutTimeShifter)
: IRequestHandler<TimeShiftOnDemandPlayout, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
{
try
{
List<string> staleGuideChannels = await playoutTimeShifter.TimeShift(
request.PlayoutId,
request.Now,
request.Force,
cancellationToken);
// the time shift rewrote stored PlayoutItem timestamps but not the cached XMLTV
// fragment; rebuild the guide for the shifted channel (and any mirrors of it) so a
// client tuning in doesn't see a stale timeline. this is a post-commit side effect
// (TimeShift already saved) so it runs on CancellationToken.None — a session token that
// cancels between the DB commit and this enqueue must not leave the guide stale
// (decisions.md api.postcommit-cancellation-none)
foreach (string channelNumber in staleGuideChannels)
{
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), CancellationToken.None);
}
await playoutTimeShifter.TimeShift(request.PlayoutId, request.Now, request.Force, cancellationToken);
}
catch (Exception ex)
{
@@ -58,7 +58,6 @@ public class
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
@@ -50,7 +50,6 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
@@ -53,7 +53,6 @@ public class
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
@@ -58,7 +58,6 @@ public class
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
+4 -15
View File
@@ -11,7 +11,6 @@ internal static class Mapper
playout.ScheduleKind,
playout.Channel.Name,
playout.Channel.Number,
playout.Channel.Id,
playout.Channel.PlayoutMode,
playout.ProgramScheduleId == null ? string.Empty : playout.ProgramSchedule.Name,
playout.ScheduleFile,
@@ -102,24 +101,14 @@ internal static class Mapper
: $"{s} ({chapterTitle})")
.IfNone("[unknown video]");
case Song s:
// SongMetadata.Artists is a NULLABLE primitive collection (FallbackMetadataProvider never
// assigns it for a song whose tags failed to read) and string.Join throws
// ArgumentNullException on a null sequence. SongMetadata IS eager-loaded on this path, so
// this was a LIVE 500 on the playout guide, not a latent one (issue #671).
string songArtist = Optional(s.SongMetadata).Flatten().HeadOrNone()
.Map(sm => Optional(sm.Artists).Flatten().ToList())
.Filter(artists => artists.Count > 0)
.Map(artists => $"{string.Join(", ", artists)} - ")
string songArtist = s.SongMetadata.HeadOrNone()
.Map(sm => $"{string.Join(", ", sm.Artists)} - ")
.IfNone(string.Empty);
return Optional(s.SongMetadata).Flatten().HeadOrNone()
return s.SongMetadata.HeadOrNone()
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
.Map(t => string.IsNullOrWhiteSpace(chapterTitle)
// interpolate the composed title `t`, NOT the `case Song s` entity — Song has no
// ToString() override, so `{s}` rendered a chaptered song as the literal type name
// "ErsatzTV.Core.Domain.Song (Chapter 3)". The MusicVideo/OtherVideo arms above are
// correct only because they happen to name their lambda parameter `s`.
? t
: $"{t} ({chapterTitle})")
: $"{s} ({chapterTitle})")
.IfNone("[unknown song]");
case Image i:
return i.ImageMetadata.HeadOrNone().Map(im => im.Title ?? string.Empty).IfNone("[unknown image]");
@@ -7,7 +7,6 @@ public record PlayoutNameViewModel(
PlayoutScheduleKind ScheduleKind,
string ChannelName,
string ChannelNumber,
int ChannelId,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string ScheduleFile,
@@ -24,7 +24,6 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
p.ScheduleKind,
p.Channel.Name,
p.Channel.Number,
p.Channel.Id,
p.Channel.PlayoutMode,
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
p.ScheduleFile,
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -9,5 +9,4 @@ public record CreateProgramSchedule(
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -40,8 +40,7 @@ public class CreateProgramScheduleHandler(IDbContextFactory<TvContext> dbContext
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
ShuffleScheduleItems = request.ShuffleScheduleItems,
RandomStartPoint = request.RandomStartPoint,
FixedStartTimeBehavior = request.FixedStartTimeBehavior,
PadToNearestMinute = request.PadToNearestMinute is int m && m > 0 ? m : null
FixedStartTimeBehavior = request.FixedStartTimeBehavior
};
});
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -10,5 +10,4 @@ public record UpdateProgramSchedule(
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
@@ -40,15 +40,12 @@ public class UpdateProgramScheduleHandler(
CancellationToken cancellationToken)
{
// we need to refresh playouts if the playback order or keep multi-episodes has been modified
int? normalizedPad = request.PadToNearestMinute is int upm && upm > 0 ? upm : null;
bool needToRefreshPlayout =
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
programSchedule.RandomStartPoint != request.RandomStartPoint ||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior ||
programSchedule.PadToNearestMinute != normalizedPad;
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior;
programSchedule.Name = request.Name;
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
@@ -57,7 +54,6 @@ public class UpdateProgramScheduleHandler(
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
programSchedule.RandomStartPoint = request.RandomStartPoint;
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
programSchedule.PadToNearestMinute = normalizedPad;
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253).
// Force-write past a concurrent Version bump (e.g. a parallel schedule-items replace) instead of
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -13,7 +13,6 @@ internal static class Mapper
programSchedule.ShuffleScheduleItems,
programSchedule.RandomStartPoint,
programSchedule.FixedStartTimeBehavior,
programSchedule.PadToNearestMinute,
programSchedule.Version);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -10,5 +10,4 @@ public record ProgramScheduleViewModel(
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute,
int Version);
@@ -1,4 +1,4 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -20,7 +20,6 @@ public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContex
ps.ShuffleScheduleItems,
ps.RandomStartPoint,
ps.FixedStartTimeBehavior,
ps.PadToNearestMinute,
ps.Version))
.ToListAsync(cancellationToken);
}
@@ -1,6 +0,0 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public record GetSearchFieldValues(string Name, string Query, int Limit)
: IRequest<Option<SearchFieldValuesResponseModel>>;
@@ -1,534 +0,0 @@
using System.Text;
using System.Text.Json;
using Dapper;
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSearchFieldValues, Option<SearchFieldValuesResponseModel>>
{
private const int DefaultLimit = 50;
private const int MaxLimit = 50;
/// <summary>
/// Rows read per round trip when walking the list-valued (JSON-array) columns on
/// <c>SongMetadata</c>, and the ceiling on rows read per request.
/// <para>
/// These count ACTUAL ROWS, and arriving at that took four tries — each earlier attempt bounded a
/// quantity that sounded like rows and was not. A fixed <c>LIMIT</c> budget bounded the RESULT, and
/// the pre-filter (allowed to over-match) starved it with rows that could not match. Keyset paging
/// with a <c>LIMIT</c> bounded CANDIDATES RETURNED — but a query matching nothing must evaluate
/// every eligible row before it can return an empty page, so rows inspected stayed unbounded. A
/// closed <c>Id</c> range bounded KEYSPACE WIDTH — but keyspace is not rows: delete 20,000
/// historical rows, put one song at <c>Id</c> 20001, and the walk burns its whole allowance on empty
/// ranges and inspects nothing.
/// </para>
/// <para>
/// What makes this one hold is that <b>the query has no RESIDUAL predicate</b> — nothing that can
/// discard a row the engine already produced. The only condition is the cursor
/// <c>Id &gt; @AfterId</c>, which is a seek on the <c>ORDER BY</c> key itself, not a filter. So the
/// page returns exactly <see cref="ListValuedBatchRows" /> rows whenever that many logical rows
/// remain, independent of how sparse the matches are or where the <c>Id</c> gaps fall.
/// </para>
/// <para>
/// <b>Be precise about what is bounded: LOGICAL ROWS RETURNED AND MATERIALIZED, and the number of
/// round trips. Not physical work, and not bytes.</b> Two things break the stronger reading, and an
/// earlier version of this comment asserted it anyway:
/// <list type="bullet">
/// <item>
/// MySQL purge lag. Deleted clustered-index records survive until purge runs, and a range
/// scan still traverses them, so returning 2,000 VISIBLE rows can touch far more index
/// records. Deletion history therefore still affects physical work — the very thing the
/// keyspace attempt was trying to make irrelevant.
/// </item>
/// <item>
/// Row width is unbounded. These columns are <c>TEXT</c>/<c>longtext</c>, which both SQLite
/// and InnoDB spill to overflow pages, so a row count implies neither a byte count nor a
/// page-read count.
/// </item>
/// </list>
/// The logical-row bound is still worth having — it is what makes the walk terminate and what caps
/// the number of rows and round trips — but do not restate it as bounded I/O, and do not restate it
/// as bounded MEMORY either: payload width is unrestricted and a single JSON array can hold
/// arbitrarily many strings, every one of which may enter the in-memory set.
/// </para>
/// <para>
/// The trade is real and deliberate: no server-side narrowing, so a query with few matches transfers
/// rows it will discard, up to <see cref="ListValuedMaxRowsRead" />. A query with enough matches
/// stops as soon as it has <c>limit</c> distinct ones, so the dense cases — including an empty
/// <c>q</c> — finish on the first page. See <c>api.search-field-values-sources</c> for the measured
/// cost and for why reintroducing a <c>LIKE</c> is not an option.
/// </para>
/// </summary>
internal const int ListValuedBatchRows = 2000;
/// <inheritdoc cref="ListValuedBatchRows" />
internal const int ListValuedMaxRowsRead = 20000;
public async Task<Option<SearchFieldValuesResponseModel>> Handle(
GetSearchFieldValues request,
CancellationToken cancellationToken)
{
SearchFieldResponseModel field = SearchFieldCatalog.Fields
.FirstOrDefault(f => f.Name == request.Name);
if (field is null || field.Type != "text")
{
return Option<SearchFieldValuesResponseModel>.None;
}
int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit);
string query = request.Query ?? string.Empty;
// Invariant, not current-culture: UseRequestLocalization honours Accept-Language, so a caller can select
// tr-TR and turn `q=I` into `ı` — which then matches nothing a Turkish-dotless-i-free library contains.
// This feeds the EF-translated filter, which has no StringComparison overload EF can translate.
string qLower = query.ToLowerInvariant();
// in-memory special cases (no DB query needed)
switch (request.Name)
{
case "state":
return new SearchFieldValuesResponseModel(
FilterSortTake(Enum.GetNames<MediaItemState>(), query, limit));
case "video_dynamic_range":
return new SearchFieldValuesResponseModel(
FilterSortTake(["hdr", "sdr"], query, limit));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
if (request.Name == "content_rating")
{
return new SearchFieldValuesResponseModel(
await GetContentRatingValues(dbContext, query, limit, cancellationToken));
}
IQueryable<string> source = GetSource(dbContext, request.Name);
string listColumn = GetSongListValuedColumn(request.Name);
if (source is null && listColumn is null)
{
return Option<SearchFieldValuesResponseModel>.None;
}
var values = new List<string>();
if (source is not null)
{
values.AddRange(
await source
.Where(v => v != null && v.ToLower().StartsWith(qLower))
.Distinct()
.OrderBy(v => v)
.Take(limit)
.ToListAsync(cancellationToken));
}
// ersatztv#668. The query above prefix-matches through SQL LOWER(), and SQLite's LOWER() folds ASCII
// ONLY -- lower('Édith') is 'Édith' unchanged -- so it cannot reach a stored value whose prefix
// carries an uppercase non-ASCII character, from ANY query. It UNDER-matches, and an under-match is
// unrecoverable downstream: no later stage can reintroduce a row SQL never returned. So for the only
// queries that can be affected (those containing a non-ASCII character) run a second, Unicode-correct
// pass and merge it in. This is ADDITIVE on purpose -- the SQL pass above still contributes, so a
// value already reachable today cannot stop being reachable.
//
// MySQL needs none of this: its LOWER() is Unicode-aware, so LOWER('Édith') really is 'édith' and the
// existing predicate reaches the row unaided. Measured on 8.4 -- and note the executed path does NOT
// over-match, even though the column collation (utf8mb4_0900_ai_ci) is accent-insensitive: the driver
// binds the LIKE pattern with a BINARY collation, so the comparison is accent-sensitive in practice.
// A hand-typed probe using a LITERAL pattern DOES over-match; that is a different query from the one
// this code runs, and mistaking the two is how an earlier revision of the decision record got it wrong.
if (source is not null && ContainsNonAscii(query) && IsSqlite(dbContext))
{
values.AddRange(
await GetUnicodeFoldedValues(dbContext, request.Name, query, limit, cancellationToken));
}
if (listColumn is not null)
{
values.AddRange(await GetSongListValuedValues(dbContext, listColumn, query, limit, cancellationToken));
}
// ORDERING IS BEST-EFFORT, NOT EXACT. Each source truncates using its own ordering — the EF source by the
// database collation (SQLite's NOCASE/BINARY is ASCII-only), the list source by primary key — and neither
// is the ordinal ordering applied here. So when a source actually truncates, a value it dropped may have
// outranked one that survived: with "Zulu" and "apple" and limit=1 the database keeps "apple" (its
// ordering is case-insensitive) while ordinal ranks "Zulu" first, so the merge never sees "Zulu".
// Below the truncation points (the normal typeahead case) the result is exact.
return new SearchFieldValuesResponseModel(FilterSortTake(values.Distinct(StringComparer.Ordinal), query, limit));
}
internal static IQueryable<string> GetSource(TvContext dbContext, string name) => name switch
{
"genre" or "show_genre" => dbContext.Set<Genre>().Select(g => g.Name),
"studio" => dbContext.Set<Studio>().Select(s => s.Name),
"director" => dbContext.Set<Director>().Select(d => d.Name),
"writer" => dbContext.Set<Writer>().Select(w => w.Name),
"actor" => dbContext.Actors.Select(a => a.Name),
// Mirrors what LuceneSearchIndex writes to the `artist` field: the music video's linked artist entity
// (ArtistMetadata.Title) plus its free-text credits (MusicVideoArtist rows). The third contributor —
// SongMetadata.Artists — is a JSON-array column and is handled by GetSongListValuedValues instead.
"artist" => dbContext.ArtistMetadata.Select(m => m.Title)
.Concat(dbContext.Set<MusicVideoArtist>().Select(a => a.Name)),
"tag" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId != Tag.NfoCountryTypeId && t.ExternalTypeId != Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"network" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"collection" => dbContext.Collections.Select(c => c.Name),
"video_codec" => dbContext.MediaStreams
.Where(s => s.MediaStreamKind == MediaStreamKind.Video && s.Codec != null)
.Select(s => s.Codec),
"album" => dbContext.MusicVideoMetadata
.Where(m => m.Album != null)
.Select(m => m.Album)
.Concat(dbContext.SongMetadata.Where(m => m.Album != null).Select(m => m.Album)),
_ => null
};
/// <summary>
/// SQL name of the invariant-uppercase fold registered by <c>SqliteUnicodeFunctions</c>. Duplicated
/// rather than referenced because Application must not depend on a provider assembly; a test asserts
/// the two constants are equal so they cannot drift.
/// </summary>
internal const string UpperFunction = "etv_upper";
/// <summary>
/// True when the value contains any character outside US-ASCII, which is exactly when SQLite's
/// ASCII-only <c>LOWER()</c> can under-match. Evaluated on the RAW query, never the lowercased copy:
/// the trigger must not be coupled to the fold.
/// </summary>
internal static bool ContainsNonAscii(string value)
{
foreach (char c in value)
{
if (c > 0x7F)
{
return true;
}
}
return false;
}
// Derived per-context rather than read from the TvContext.IsSqlite static on purpose. Nothing MECHANICALLY
// stops that read -- ProviderStaticsWiringTests only parses the two composition roots for ASSIGNMENTS, not
// readers -- but that test's scanner exemption for IsSqlite is justified in prose as "read only by
// DbInitializer + DatabaseMigratorService, both host-only", and reading it here would make that reason
// false while the test stayed green. Do not "simplify" this to IsSqlite.
private static bool IsSqlite(TvContext dbContext) =>
(dbContext.Database.ProviderName ?? string.Empty).Contains("Sqlite", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Escapes the LIKE metacharacters in a user-supplied prefix and appends the trailing wildcard. The
/// backslash MUST be escaped first, or the escapes added for <c>%</c>/<c>_</c> would themselves be
/// re-escaped. Paired with an explicit <c>ESCAPE '\'</c> in <see cref="UnicodeFoldSql" />, since raw
/// SQL gets none of the escaping EF does for <c>StartsWith</c>.
/// </summary>
internal static string EscapeLikePrefix(string value) =>
value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("%", "\\%", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal) + "%";
/// <summary>
/// One bounded, exact prefix query using the Unicode-correct fold. Unlike the list-valued walk this
/// KEEPS its selectivity in SQL — it is a normal indexed-or-not <c>LIMIT</c>ed query exactly like the
/// EF one it supplements, not a paged walk, so there is no row budget to blow and no reason to strip
/// the discriminator predicates out of it.
/// </summary>
internal static string UnicodeFoldSql(string table, string column, string predicate)
{
var match = $"{UpperFunction}({column}) LIKE @Pattern ESCAPE '\\'";
string where = predicate is null ? match : $"({predicate}) AND {match}";
return $"SELECT DISTINCT {column} AS Value FROM {table} WHERE {where} ORDER BY {column} LIMIT @Limit";
}
/// <summary>
/// The tables/columns behind each EF-sourced field, mirroring <see cref="GetSource" /> 1:1.
/// <para>
/// The discriminator predicates must mirror EF's NULL semantics, not C#'s reading of the source.
/// EF compiles <c>t.ExternalTypeId != Tag.NfoCountryTypeId</c> with null semantics, so a row whose
/// <c>ExternalTypeId</c> is NULL IS included; plain SQL <c>&lt;&gt;</c> against NULL yields NULL and
/// would silently drop it. Hence the explicit <c>IS NULL</c> arm.
/// </para>
/// </summary>
private static IReadOnlyList<UnicodeFoldSource> GetUnicodeFoldSources(string name) => name switch
{
"genre" or "show_genre" => [new UnicodeFoldSource("Genre", "Name")],
"studio" => [new UnicodeFoldSource("Studio", "Name")],
"director" => [new UnicodeFoldSource("Director", "Name")],
"writer" => [new UnicodeFoldSource("Writer", "Name")],
"actor" => [new UnicodeFoldSource("Actor", "Name")],
"artist" =>
[
new UnicodeFoldSource("ArtistMetadata", "Title"),
new UnicodeFoldSource("MusicVideoArtist", "Name")
],
"tag" =>
[
new UnicodeFoldSource(
"Tag",
"Name",
"ExternalTypeId IS NULL OR (ExternalTypeId <> @NfoCountryTypeId AND ExternalTypeId <> @PlexNetworkTypeId)",
new Dictionary<string, object>
{
["NfoCountryTypeId"] = Tag.NfoCountryTypeId,
["PlexNetworkTypeId"] = Tag.PlexNetworkTypeId
})
],
"network" =>
[
new UnicodeFoldSource(
"Tag",
"Name",
"ExternalTypeId = @PlexNetworkTypeId",
new Dictionary<string, object> { ["PlexNetworkTypeId"] = Tag.PlexNetworkTypeId })
],
"collection" => [new UnicodeFoldSource("Collection", "Name")],
"video_codec" =>
[
new UnicodeFoldSource(
"MediaStream",
"Codec",
"MediaStreamKind = @VideoStreamKind AND Codec IS NOT NULL",
new Dictionary<string, object> { ["VideoStreamKind"] = (int)MediaStreamKind.Video })
],
"album" =>
[
new UnicodeFoldSource("MusicVideoMetadata", "Album", "Album IS NOT NULL"),
new UnicodeFoldSource("SongMetadata", "Album", "Album IS NOT NULL")
],
_ => []
};
private static async Task<List<string>> GetUnicodeFoldedValues(
TvContext dbContext,
string name,
string query,
int limit,
CancellationToken cancellationToken)
{
IReadOnlyList<UnicodeFoldSource> sources = GetUnicodeFoldSources(name);
if (sources.Count == 0)
{
return [];
}
// CreateFunction is per-connection, so registration happens here, at the one call site that needs
// the function, rather than through an EF connection interceptor: Dapper opens a closed connection
// itself and a direct ADO open does not raise EF's interceptors, so an interceptor-based seam would
// silently miss exactly this query. Opening first makes the registration order-independent.
await dbContext.Database.OpenConnectionAsync(cancellationToken);
TvContext.RegisterUnicodeCaseFunctions(dbContext.Connection);
string pattern = EscapeLikePrefix(query.ToUpperInvariant());
var values = new List<string>();
foreach (UnicodeFoldSource source in sources)
{
var parameters = new DynamicParameters();
parameters.Add("Pattern", pattern);
parameters.Add("Limit", limit);
if (source.Parameters is not null)
{
foreach ((string key, object value) in source.Parameters)
{
parameters.Add(key, value);
}
}
IEnumerable<string> rows = await dbContext.Connection.QueryAsync<string>(
new CommandDefinition(
UnicodeFoldSql(source.Table, source.Column, source.Predicate),
parameters,
cancellationToken: cancellationToken));
values.AddRange(rows.Where(v => !string.IsNullOrEmpty(v)));
}
return values;
}
private sealed record UnicodeFoldSource(
string Table,
string Column,
string Predicate = null,
IReadOnlyDictionary<string, object> Parameters = null);
/// <summary>
/// Maps a field name onto the <c>SongMetadata</c> column that backs it as an <c>IList&lt;string&gt;</c>.
/// The returned value is a compile-time constant from this switch — never caller input — so it is safe
/// to interpolate into the SQL in <see cref="ListValuedSql" />.
/// </summary>
private static string GetSongListValuedColumn(string name) => name switch
{
"artist" => "Artists",
"album_artist" => "AlbumArtists",
_ => null
};
/// <summary>
/// Reads whole values out of a <c>SongMetadata</c> <c>IList&lt;string&gt;</c> column.
/// <para>
/// EF maps these as primitive collections: one JSON array per row in a single <c>TEXT</c>/
/// <c>longtext</c> column. Neither provider can project the elements server-side — SQLite needs
/// the SQL <c>APPLY</c> operator it doesn't have, and Pomelo MySQL doesn't implement primitive
/// collections at all — so there is no server-side <c>SELECT DISTINCT</c> over the elements.
/// </para>
/// <para>
/// So the rows are walked in primary-key order, keyset-paged by row position, and split +
/// exact-filtered in memory. All selectivity is in memory — the query's only condition is the
/// cursor, a seek on the ordering key that never discards a row, so its <c>LIMIT</c> bounds the
/// LOGICAL ROWS returned. See <see cref="ListValuedBatchRows" /> for the four revisions it took to
/// get that right, and for what that bound does and does not cover.
/// </para>
/// </summary>
private static async Task<List<string>> GetSongListValuedValues(
TvContext dbContext,
string column,
string query,
int limit,
CancellationToken cancellationToken)
{
string sql = ListValuedSql(column);
var distinct = new System.Collections.Generic.HashSet<string>(StringComparer.Ordinal);
var afterId = 0;
var read = 0;
while (read < ListValuedMaxRowsRead && distinct.Count < limit)
{
int batch = Math.Min(ListValuedBatchRows, ListValuedMaxRowsRead - read);
List<ListValuedRow> rows = (await dbContext.Connection.QueryAsync<ListValuedRow>(
new CommandDefinition(
sql,
new { AfterId = afterId, Batch = batch },
cancellationToken: cancellationToken))).AsList();
if (rows.Count == 0)
{
break;
}
read += rows.Count;
afterId = rows[^1].Id;
foreach (ListValuedRow row in rows)
{
foreach (string element in ParseElements(row.Payload))
{
if (element.StartsWith(query, StringComparison.OrdinalIgnoreCase))
{
distinct.Add(element);
}
}
}
if (rows.Count < batch)
{
// With no RESIDUAL predicate -- only the cursor, which selects a range rather than discarding
// rows from it -- a short page can only mean the table is exhausted. It can never mean "this
// stretch happened to match nothing", which is precisely why the residual predicate had to go.
// Advancing from the last returned Id is safe for the same reason: nothing was filtered out
// behind it, so no row can be skipped.
break;
}
}
return distinct.ToList();
}
private static IEnumerable<string> ParseElements(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return [];
}
try
{
return (JsonSerializer.Deserialize<string[]>(payload) ?? []).Where(e => !string.IsNullOrEmpty(e));
}
catch (JsonException)
{
return [];
}
}
/// <summary>
/// One keyset page of rows, by ROW POSITION rather than by <c>Id</c> value.
/// <para>
/// The only condition is the cursor — deliberately <b>no RESIDUAL predicate</b>: no <c>LIKE</c>, no
/// <c>LOWER</c>, not even <c>IS NOT NULL</c>. The distinction that matters is not "no predicate"
/// (the cursor is one); it is that <c>Id &gt; @AfterId</c> is a <i>seekable predicate on the
/// ordering key</i>, which positions the scan and never discards a row, whereas a residual
/// predicate throws away rows the engine already produced. <c>LIMIT</c> only truncates what
/// survives a residual predicate, so with one present it bounds the output rather than the row
/// count — which is how every earlier revision scanned past its own bound. With none, <c>LIMIT n</c>
/// yields <c>n</c> logical rows. Null payloads are dropped in memory by
/// <see cref="ParseElements" />.
/// </para>
/// <para>
/// Note this pins the SQL string only. It cannot pin an execution plan, MVCC visibility work, or
/// payload I/O — and on MySQL, using the index to satisfy <c>ORDER BY</c> is an optimizer choice,
/// not a semantic guarantee.
/// </para>
/// </summary>
internal static string ListValuedSql(string column) =>
$"SELECT Id, {column} AS Payload FROM SongMetadata WHERE Id > @AfterId ORDER BY Id LIMIT @Batch";
private sealed class ListValuedRow
{
public int Id { get; init; }
public string Payload { get; init; }
}
private static async Task<List<string>> GetContentRatingValues(
TvContext dbContext,
string query,
int limit,
CancellationToken cancellationToken)
{
List<string> raw = await dbContext.MovieMetadata
.Where(m => m.ContentRating != null)
.Select(m => m.ContentRating)
.Concat(dbContext.ShowMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.OtherVideoMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.RemoteStreamMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Distinct()
.ToListAsync(cancellationToken);
IEnumerable<string> split = raw
.SelectMany(cr => cr.Split('/'))
.Select(cr => cr.Trim())
.Where(cr => !string.IsNullOrEmpty(cr))
.Distinct();
return FilterSortTake(split, query, limit);
}
/// <summary>
/// The one in-memory filter/sort/take every field funnels through. Both the comparison and the ordering
/// are ORDINAL on purpose: <c>UseRequestLocalization</c> honours <c>Accept-Language</c>, so the current
/// culture is caller-controlled, and <c>ToLower()</c> plus the default (linguistic)
/// <c>StartsWith(string)</c> would make the result depend on it — under <c>tr-TR</c>, <c>q=I</c> lowers
/// to <c>ı</c> and stops matching <c>Istanbul</c>. Note this is the LAST stage only: a field sourced by
/// a plain EF query has already been filtered and truncated by the database collation before it gets
/// here, which ordinal semantics downstream cannot undo (ersatztv#668).
/// </summary>
private static List<string> FilterSortTake(IEnumerable<string> values, string query, int limit) =>
values
.Where(v => v.StartsWith(query, StringComparison.OrdinalIgnoreCase))
.OrderBy(v => v, StringComparer.Ordinal)
.Take(limit)
.ToList();
}
@@ -131,19 +131,9 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
long startupMs = (long)segments.ProcessStartup.TotalMilliseconds;
long fillMs = (long)segments.SegmentFill.TotalMilliseconds;
long setupMs = Math.Max(0, totalMs - startupMs - fillMs);
// #472 sub-splits the startup work (81% of total, all of the variance) into the ErsatzTV-side
// prep before FFmpeg is launched, FFmpeg's own init (input open+probe and decoder/encoder
// init), and the wait for the playlist once FFmpeg is reporting progress. splitKind says how
// much of that was actually observable for this sample. NOTE these buckets span the worker's
// Run entry rather than the startup stopwatch, so they do NOT sum to startupMs — prep overlaps
// the tail of setup. The log says "spans runEntry" so a reader can't miss it.
// See ColdStartStartupSplit for the full set of caveats.
ColdStartStartupSplit split = segments.StartupSplit;
_logger.LogInformation(
"HLS cold-start channel {Channel} mode {Mode}: total {TotalMs}ms " +
"(setup {SetupMs}ms + startup {ProcessStartupMs}ms + fill {SegmentFillMs}ms), " +
"startup split {SplitKind} spans runEntry (prep {PrepMs}ms + ffmpegInit {FFmpegInitMs}ms " +
"+ firstGop {FirstGopMs}ms), " +
"segments {SegmentsReached}/{InitialSegmentCount}, " +
"deadlineExpired {DeadlineExpired}, subtitleBurnIn {SubtitleBurnIn}, hwaccel {HwAccel}",
request.ChannelNumber,
@@ -152,10 +142,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
setupMs,
startupMs,
fillMs,
split.Kind,
(long)split.Prep.TotalMilliseconds,
(long)split.FFmpegInit.TotalMilliseconds,
(long)split.FirstGop.TotalMilliseconds,
segments.SegmentsReached,
segments.InitialSegmentCount,
segments.DeadlineExpired,
@@ -26,9 +26,7 @@ namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorker : IHlsSessionWorker
{
// process-wide, shared by every session — the work-ahead limit is a global resource budget
private static readonly WorkAheadSlots _workAheadSlots = new();
private static int _workAheadCount;
private readonly OutputFormatKind _outputFormatKind;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = [];
@@ -63,14 +61,6 @@ public class HlsSessionWorker : IHlsSessionWorker
// segments cannot exist until this process ran) — volatile for cross-thread visibility.
private volatile string _coldStartFFmpegArguments;
// Stopwatch timestamps of the cold-start milestones used to sub-split the "startup" phase (#472).
// Each is written once on the sequential Run loop and read on the handler thread from
// WaitForPlaylistSegments; long fields cannot be volatile, so access goes through Volatile/
// Interlocked. Zero means "never reached", which ColdStartStartupSplit degrades gracefully on.
private long _coldStartRunTicks;
private long _coldStartProcessLaunchedTicks;
private long _coldStartFirstProgressTicks;
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
IGraphicsEngine graphicsEngine,
@@ -197,10 +187,6 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
// anchor for the cold-start startup sub-split (#472); this runs before any later milestone,
// so every sub-phase derived from it is non-negative by construction
Volatile.Write(ref _coldStartRunTicks, Stopwatch.GetTimestamp());
try
{
_channelNumber = channelNumber;
@@ -245,12 +231,10 @@ public class HlsSessionWorker : IHlsSessionWorker
cancellationToken);
}
// claim the slot here rather than checking here and claiming inside Transcode: the check
// and the claim have to be one atomic step or every simultaneous tune-in wins (#536)
bool initialWorkAhead = _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
_state = initialWorkAhead ? HlsSessionState.SeekAndWorkAhead : HlsSessionState.SeekAndRealtime;
if (!await Transcode(initialWorkAhead, cancellationToken))
if (!await Transcode(!initialWorkAhead, cancellationToken))
{
return;
}
@@ -273,8 +257,8 @@ public class HlsSessionWorker : IHlsSessionWorker
// only use realtime encoding when we're at least 30 seconds ahead
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
bool subsequentWorkAhead =
!realtime && _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
if (!await Transcode(subsequentWorkAhead, cancellationToken))
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
if (!await Transcode(!subsequentWorkAhead, cancellationToken))
{
return;
}
@@ -330,7 +314,6 @@ public class HlsSessionWorker : IHlsSessionWorker
var sw = Stopwatch.StartNew();
var processStartup = TimeSpan.Zero;
var startupSplit = ColdStartStartupSplit.Unavailable;
var segmentCount = 0;
try
{
@@ -346,13 +329,6 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("Playlist exists");
processStartup = sw.Elapsed;
// #472: sub-split the phase that #350 measured as 81% of cold-start and all of its variance
startupSplit = ColdStartStartupSplit.FromTimestamps(
Volatile.Read(ref _coldStartRunTicks),
Volatile.Read(ref _coldStartProcessLaunchedTicks),
Volatile.Read(ref _coldStartFirstProgressTicks),
Stopwatch.GetTimestamp());
// start the segment-wait deadline only after the playlist file appears,
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
@@ -386,8 +362,7 @@ public class HlsSessionWorker : IHlsSessionWorker
segmentCount,
initialSegmentCount,
segmentCount < initialSegmentCount,
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments),
startupSplit);
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments));
}
finally
{
@@ -460,23 +435,15 @@ public class HlsSessionWorker : IHlsSessionWorker
return result;
}
/// <summary>
/// Runs one transcode. The caller is the one that races for a work-ahead slot, so ownership is
/// passed IN: <paramref name="ownsWorkAheadSlot" /> means the caller already claimed a slot from
/// <see cref="_workAheadSlots" />, and this method releases it in its <c>finally</c> — acquire
/// and release stay one-for-one (#536).
/// </summary>
private async Task<bool> Transcode(bool ownsWorkAheadSlot, CancellationToken cancellationToken)
private async Task<bool> Transcode(bool realtime, CancellationToken cancellationToken)
{
// a session works ahead exactly when it holds a slot; everything else runs realtime (throttled)
bool realtime = !ownsWorkAheadSlot;
try
{
bool wasSeekAndWorkAhead = _state is HlsSessionState.SeekAndWorkAhead;
if (!realtime)
{
Interlocked.Increment(ref _workAheadCount);
_logger.LogDebug("HLS segmenter will work ahead for channel {Channel}", _channelNumber);
HlsSessionState nextState = _state switch
@@ -609,30 +576,10 @@ public class HlsSessionWorker : IHlsSessionWorker
var progressParser = new FFmpegProgress();
// #472: the first -progress line is the only cold-start milestone FFmpeg gives us
// for free (the pipeline runs -loglevel error -nostats, so stderr stays silent on a
// healthy run). It means the input is open and probed and the decoder/encoder are
// initialized. Record-once, so only the session's first process is measured.
void ParseProgressLine(string line)
{
// the read short-circuits the timestamp call for every line after the first,
// which is every line for the life of the session
if (Volatile.Read(ref _coldStartFirstProgressTicks) == 0)
{
Interlocked.CompareExchange(ref _coldStartFirstProgressTicks, Stopwatch.GetTimestamp(), 0);
}
progressParser.ParseLine(line);
}
// everything before this point is ErsatzTV-side "prep" (playout item resolution,
// pipeline build, graphics engine spawn); FFmpeg's own clock starts here
Interlocked.CompareExchange(ref _coldStartProcessLaunchedTicks, Stopwatch.GetTimestamp(), 0);
CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(_workingDirectory)
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithStandardOutputPipe(PipeTarget.ToDelegate(ParseProgressLine))
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
@@ -726,20 +673,6 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException
&& cancellationToken.IsCancellationRequested)
{
// a cancellation anywhere in this method (including inside the mediator sends, which sit
// outside the inner ffmpeg try below) is a shutdown or a client disconnect, not a fault.
// Without this it reaches the catch-all and logs a channel-level ERROR with a stack
// trace on every graceful teardown. The token check is load-bearing: TaskCanceledException
// is also what HttpClient throws on ITS OWN timeout, and a real timeout inside ffprobe, a
// media-server call or subtitle extraction must keep its ERROR-level signal rather than
// being downgraded to a routine teardown. (ersatztv#473 review)
_logger.LogInformation("Terminating HLS session for channel {Channel}", _channelNumber);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error transcoding channel {Channel} - {Message}", _channelNumber, ex.Message);
@@ -759,15 +692,9 @@ public class HlsSessionWorker : IHlsSessionWorker
// do nothing
}
if (ownsWorkAheadSlot && !_workAheadSlots.Release())
if (!realtime)
{
// Release() reports false only when the pool was already empty, i.e. this slot was
// released more than once. Nothing reaches here in a correct program, but if a
// future second release site breaks the ownership contract this is the one in-band
// signal that the unthrottled-transcode budget is inflated (ersatztv#536/#539 §3).
_logger.LogWarning(
"Released a work-ahead slot that was not held for channel {Channel} - the unthrottled-transcode budget may be inflated",
_channelNumber);
Interlocked.Decrement(ref _workAheadCount);
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -60,8 +60,6 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
.ThenInclude(p => p.Resolution)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.ChannelGraphicsElements)
.ThenInclude(x => x.GraphicsElement)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
foreach (var channel in maybeChannel)
@@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using CliWrap;
using Dapper;
using ErsatzTV.Application.Playouts;
@@ -42,7 +42,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly IGraphicsElementSelector _graphicsElementSelector;
private readonly IDecoSelector _decoSelector;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRemoteStreamProber _remoteStreamProber;
private readonly ISongVideoGenerator _songVideoGenerator;
private readonly ITelevisionRepository _televisionRepository;
private readonly bool _isDebugNoSync;
@@ -63,11 +62,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
IWatermarkSelector watermarkSelector,
IGraphicsElementSelector graphicsElementSelector,
IDecoSelector decoSelector,
IRemoteStreamProber remoteStreamProber,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(dbContextFactory)
{
_remoteStreamProber = remoteStreamProber;
_ffmpegProcessService = ffmpegProcessService;
_fileSystem = fileSystem;
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
@@ -552,7 +549,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
case PlayoutItemDoesNotExistOnDisk:
case PlayoutItemNotAvailableFromMediaServer:
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
@@ -854,15 +850,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
pmf.Path,
pmf.Key);
var plexUrl =
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
if (!await _remoteStreamProber.IsAvailable(plexUrl, cancellationToken))
{
return new PlayoutItemNotAvailableFromMediaServer(plexUrl);
}
return new PlayoutItemWithPath(playoutItem, plexUrl);
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
}
break;
@@ -878,14 +868,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
foreach (string itemId in jellyfinItemId)
{
var jellyfinUrl = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
if (!await _remoteStreamProber.IsAvailable(jellyfinUrl, cancellationToken))
{
return new PlayoutItemNotAvailableFromMediaServer(jellyfinUrl);
}
return new PlayoutItemWithPath(playoutItem, jellyfinUrl);
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}");
}
// attempt to remotely stream emby
@@ -898,14 +883,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
foreach (string itemId in embyItemId)
{
var embyUrl = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
if (!await _remoteStreamProber.IsAvailable(embyUrl, cancellationToken))
{
return new PlayoutItemNotAvailableFromMediaServer(embyUrl);
}
return new PlayoutItemWithPath(playoutItem, embyUrl);
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}");
}
return new PlayoutItemDoesNotExistOnDisk(path);
@@ -1,102 +0,0 @@
namespace ErsatzTV.Application.Streaming;
/// <summary>
/// The process-wide pool of work-ahead slots shared by every HLS session (ersatztv#536).
/// </summary>
/// <remarks>
/// <para>
/// <c>workAheadSegmenterLimit</c> is a resource guarantee, not a tuning knob: it bounds how many
/// transcodes may run unthrottled (no <c>-readrate</c>) at once, and the QSV hardware-frame pool
/// sizing from ersatztv#529 assumes that bound holds.
/// </para>
/// <para>
/// Acquisition must therefore be atomic. The previous shape — <c>Volatile.Read(count) &lt; limit</c>
/// in the caller, <c>Interlocked.Increment</c> later inside the transcode — is a check-then-act
/// TOCTOU separated by at least one <c>await</c> (the limit is a DB-backed config read), so N
/// simultaneous tune-ins all observed <c>0 &lt; limit</c> and all ran unthrottled. Same class as
/// ersatztv#231 / #250.
/// </para>
/// </remarks>
public sealed class WorkAheadSlots
{
private int _count;
private int _unbalancedReleases;
/// <summary>
/// Gets the number of slots currently held. For diagnostics and tests only — never branch on
/// this to decide whether to work ahead; that is exactly the race <see cref="TryAcquire" /> exists to close.
/// </summary>
public int Count => Volatile.Read(ref _count);
/// <summary>
/// Atomically claims one slot if fewer than <paramref name="limit" /> are held.
/// </summary>
/// <returns><c>true</c> when a slot was claimed; the caller then owns it and MUST
/// <see cref="Release" /> it exactly once.</returns>
public bool TryAcquire(int limit)
{
while (true)
{
int current = Volatile.Read(ref _count);
if (current >= limit)
{
return false;
}
// only the thread whose compare-exchange observes the value it read wins the slot, so
// the count can never transiently exceed the limit and two racers can never both claim
if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
{
return true;
}
}
}
/// <summary>
/// Gets the number of releases that were not matched by a successful acquire. Non-zero always
/// means the ownership contract was broken somewhere (no false positives), so the value is a
/// reliable "something is wrong" signal — but it can UNDER-count and zero does not prove
/// correctness. It only increments when a release finds the pool already empty; an over-release
/// that happens while the count is positive — e.g. one cancelling out a coexisting leak —
/// decrements a real-looking slot and is never recorded, so the two bugs hide each other. This
/// is inherent to a single counter; exact accounting would need per-owner tokens (ersatztv#539 §2).
/// </summary>
public int UnbalancedReleases => Volatile.Read(ref _unbalancedReleases);
/// <summary>
/// Returns a slot claimed by <see cref="TryAcquire" />. Only ever called by the owner of that slot.
/// </summary>
/// <returns>
/// <c>true</c> when a held slot was returned; <c>false</c> when the pool was already empty, i.e.
/// the release was unbalanced (also counted in <see cref="UnbalancedReleases" />). Callers should
/// log the <c>false</c> case: it is the only in-band signal that the budget contract was broken.
/// </returns>
/// <remarks>
/// Ownership is a discipline, not a token — the same call-once contract as `EntityLocker` (#231).
/// The one failure this defends against is an unbalanced release inflating the budget: this pool
/// is process-wide and lives for the life of the app, so a leaked slot would silently and
/// permanently admit one extra unthrottled transcode, re-opening the #529 QSV pool exhaustion.
/// It clamps at zero rather than throwing — the single caller releases from a `finally`, where a
/// throw would swallow the real exception. Unlike a decrement-first-then-clamp shape, this never
/// publishes a negative count even transiently, so a concurrent <see cref="TryAcquire" /> can
/// never read the pool as having phantom room and over-admit (ersatztv#539 §1); and it records
/// the unbalanced release synchronously here, rather than blaming a later, innocent release.
/// </remarks>
public bool Release()
{
while (true)
{
int current = Volatile.Read(ref _count);
if (current <= 0)
{
Interlocked.Increment(ref _unbalancedReleases);
return false;
}
if (Interlocked.CompareExchange(ref _count, current - 1, current) == current)
{
return true;
}
}
}
}
@@ -45,8 +45,7 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
public async Task<TroubleshootingInfo> Handle(GetTroubleshootingInfo request, CancellationToken cancellationToken)
{
// Support bundle wants current state, so force a fresh run rather than serving the poll cache.
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(true, cancellationToken);
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(cancellationToken);
string version = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
@@ -119,22 +118,22 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
{ VaapiDriver.iHD, VaapiDriver.i965, VaapiDriver.RadeonSI, VaapiDriver.Nouveau };
foreach (string display in vaapiDisplays)
foreach (VaapiDriver activeDriver in allDrivers)
foreach (string vaapiDevice in vaapiDevices)
{
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
display,
Optional(GetDriverName(activeDriver)),
vaapiDevice))
{
vaapiCapabilities.AppendLine(
CultureInfo.InvariantCulture,
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}");
vaapiCapabilities.AppendLine();
vaapiCapabilities.AppendLine(output);
vaapiCapabilities.AppendLine();
}
}
foreach (VaapiDriver activeDriver in allDrivers)
foreach (string vaapiDevice in vaapiDevices)
{
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
display,
Optional(GetDriverName(activeDriver)),
vaapiDevice))
{
vaapiCapabilities.AppendLine(
CultureInfo.InvariantCulture,
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}");
vaapiCapabilities.AppendLine();
vaapiCapabilities.AppendLine(output);
vaapiCapabilities.AppendLine();
}
}
}
if (_runtimeInfo.IsOSPlatform(OSPlatform.OSX))
+2 -2
View File
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.Watermarks;
internal static class Mapper
{
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
new(watermark.Id, watermark.Name, watermark.ImageSource);
new(watermark.Id, watermark.Name);
internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) =>
new(
@@ -1,104 +0,0 @@
using System.Text.RegularExpressions;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Architecture.Tests;
/// <summary>
/// ersatztv#491: <c>TvContext</c> carries settable provider statics (<c>LastInsertedRowId</c>,
/// <c>CaseInsensitiveCollation</c>, <c>IsUniqueConstraintViolation</c>, …) that Infrastructure code
/// reads at runtime. There are TWO composition roots that execute that Infrastructure code —
/// <c>ErsatzTV/Startup.cs</c> (the host) and <c>ErsatzTV.Scanner/Program.cs</c> (a separate
/// executable launched per scan by <c>CallLibraryScannerHandler</c>) — and each wires the statics in
/// its own copy of the provider branch.
/// <para>
/// The failure mode this guards is "a static nobody assigned": #491 added
/// <c>IsUniqueConstraintViolation</c> to <c>Startup</c> only, so every production caller of
/// <c>GetOrAddFolder</c> (all of which live in the scanner) silently kept the conservative
/// <c>_ =&gt; false</c> default and the new catch was inert. Nothing about that is visible in a
/// unit test, because every test harness wires the classifier itself.
/// </para>
/// <para>
/// Source-level rather than reflective on purpose: the wiring lives inside a host-builder
/// lambda that cannot be invoked without standing up a real application, and the thing being
/// asserted is precisely that a line of code exists in both files.
/// </para>
/// </summary>
[TestFixture]
public class ProviderStaticsWiringTests
{
/// <summary>
/// Statics the host wires that the scanner deliberately does not. Add to this only with a reason:
/// the default must be provably harmless in the scanner process.
/// </summary>
private static readonly Dictionary<string, string> ScannerExemptions = new()
{
// Only read by DbInitializer / DatabaseMigratorService, which run in the host exclusively; no
// Infrastructure code on a scan path reads it. Pre-dates #491.
["IsSqlite"] = "read only by DbInitializer + DatabaseMigratorService, both host-only"
};
private static string HostSource => ReadRepoFile(Path.Combine("ErsatzTV", "Startup.cs"));
private static string ScannerSource => ReadRepoFile(Path.Combine("ErsatzTV.Scanner", "Program.cs"));
[Test]
public void Scanner_should_wire_every_TvContext_provider_static_the_host_wires()
{
HashSet<string> host = AssignedStatics(HostSource);
HashSet<string> scanner = AssignedStatics(ScannerSource);
// sanity: the parser found the wiring at all, so a rename can't turn this test into a no-op
host.ShouldContain("LastInsertedRowId");
host.ShouldContain("IsUniqueConstraintViolation");
scanner.ShouldContain("LastInsertedRowId");
List<string> missing = host
.Except(scanner)
.Except(ScannerExemptions.Keys)
.OrderBy(name => name, StringComparer.Ordinal)
.ToList();
missing.ShouldBeEmpty(
"ErsatzTV.Scanner/Program.cs does not assign TvContext static(s) that ErsatzTV/Startup.cs "
+ $"assigns: {string.Join(", ", missing)}. The scanner is a separate process, so an unassigned "
+ "static keeps its default in every library scan. Wire it in BOTH provider branches, or add "
+ "it to ScannerExemptions with a reason if the default is provably harmless there.");
}
[Test]
public void Both_hosts_should_wire_the_unique_constraint_classifier_for_both_providers()
{
// The specific #491 regression, asserted directly rather than via set arithmetic: the classifier
// must be pointed at a real provider implementation on BOTH branches of BOTH composition roots.
foreach ((string name, string source) in new[] { ("host", HostSource), ("scanner", ScannerSource) })
{
source.ShouldContain(
"TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation",
customMessage: $"{name} does not wire the Sqlite unique-constraint classifier");
source.ShouldContain(
"TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation",
customMessage: $"{name} does not wire the MySql unique-constraint classifier");
}
}
private static HashSet<string> AssignedStatics(string source) =>
Regex.Matches(source, @"\bTvContext\.(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*=[^=]")
.Select(m => m.Groups["name"].Value)
.ToHashSet(StringComparer.Ordinal);
private static string ReadRepoFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "ErsatzTV.sln")))
{
directory = directory.Parent;
}
directory.ShouldNotBeNull("could not locate the repository root (no ErsatzTV.sln above the test binary)");
string path = Path.Combine(directory!.FullName, relativePath);
File.Exists(path).ShouldBeTrue($"expected source file not found: {path}");
return File.ReadAllText(path);
}
}
@@ -1,171 +0,0 @@
using System.Diagnostics;
using ErsatzTV.Core.FFmpeg;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class ColdStartStartupSplitTests
{
// milestones are Stopwatch.GetTimestamp() values; build them from a base + millisecond offsets
private const long Base = 1_000_000_000;
private static long At(double milliseconds) =>
Base + (long)(milliseconds / 1000.0 * Stopwatch.Frequency);
[Test]
public void Should_Split_Three_Ways_When_All_Milestones_Present()
{
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(150),
At(1200),
At(1600));
split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
split.Prep.TotalMilliseconds.ShouldBe(150, 1);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1);
split.FirstGop.TotalMilliseconds.ShouldBe(400, 1);
}
[Test]
public void Sub_Phases_Should_Sum_To_Run_Entry_Through_Playlist()
{
// deliberately NOT "should sum to startup": the buckets span the worker's Run entry, which
// begins before the request thread's startup stopwatch, so prep overlaps the tail of setup
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(150),
At(1200),
At(1600));
(split.Prep + split.FFmpegInit + split.FirstGop).TotalMilliseconds.ShouldBe(1600, 1);
}
[Test]
public void Should_Be_Unavailable_When_The_Playlist_Predates_The_Process_Launch()
{
// a stale live.m3u8 survives when the handler's pre-session folder wipe fails (EmptyFolder
// swallows the failure into a warning). Every bucket would be meaningless, so report nothing
// rather than a plausible-looking sample with a prep that exceeds the whole measured phase
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(1600),
0,
At(150));
split.ShouldBe(ColdStartStartupSplit.Unavailable);
}
[Test]
public void Stale_Playlist_Guard_Should_Take_Precedence_Over_The_Progress_Branches()
{
// without the guard, this input would be classified TwoWayLateProgress; the guard must be
// evaluated first. (It can never preempt a ThreeWay: that requires processLaunched <=
// playlistExists, which is exactly the negation of the guard condition.)
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(1600),
At(1700),
At(150));
split.ShouldBe(ColdStartStartupSplit.Unavailable);
}
[Test]
public void Should_Fall_Back_To_Two_Way_Split_When_Progress_Predates_The_Process_Launch()
{
// a progress timestamp older than the launch cannot belong to this process
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(100),
At(150),
At(120),
At(1600));
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
split.FirstGop.ShouldBe(TimeSpan.Zero);
}
[Test]
public void Should_Stay_Three_Way_When_Progress_Coincides_With_A_Boundary()
{
ColdStartStartupSplit atLaunch = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(150), At(1600));
atLaunch.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
atLaunch.FFmpegInit.ShouldBe(TimeSpan.Zero);
atLaunch.FirstGop.TotalMilliseconds.ShouldBe(1450, 1);
ColdStartStartupSplit atPlaylist = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(1600), At(1600));
atPlaylist.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
atPlaylist.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
atPlaylist.FirstGop.ShouldBe(TimeSpan.Zero);
}
[Test]
public void Should_Fall_Back_To_Two_Way_Split_When_FFmpeg_Never_Reported_Progress()
{
// no -progress output before the playlist appeared: ffmpegInit must absorb the remainder
// rather than the split inventing a firstGop boundary that was never observed
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(150),
0,
At(1600));
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay);
split.Prep.TotalMilliseconds.ShouldBe(150, 1);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
split.FirstGop.ShouldBe(TimeSpan.Zero);
}
[Test]
public void Should_Report_Late_Progress_Distinctly_When_Progress_Arrived_After_The_Playlist()
{
// the playlist is observed on the request thread while progress is recorded on the worker
// thread; a progress milestone outside the phase must not produce a negative bucket
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(0),
At(150),
At(1800),
At(1600));
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWayLateProgress);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
split.FirstGop.ShouldBe(TimeSpan.Zero);
}
[TestCase(0L, 150L, 1200L, 1600L, TestName = "Run never started")]
[TestCase(100L, 0L, 0L, 1600L, TestName = "Process never launched")]
[TestCase(100L, 150L, 1200L, 0L, TestName = "Playlist never appeared")]
public void Should_Be_Unavailable_When_A_Required_Milestone_Is_Missing(
long runStarted,
long processLaunched,
long firstProgress,
long playlistExists)
{
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
runStarted == 0 ? 0 : At(runStarted),
processLaunched == 0 ? 0 : At(processLaunched),
firstProgress == 0 ? 0 : At(firstProgress),
playlistExists == 0 ? 0 : At(playlistExists));
split.ShouldBe(ColdStartStartupSplit.Unavailable);
}
[Test]
public void Should_Clamp_Rather_Than_Report_A_Negative_Prep()
{
// defensive: launch cannot precede Run entry, but telemetry must never show a negative
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
At(500),
At(150),
At(1200),
At(1600));
split.Prep.ShouldBe(TimeSpan.Zero);
split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1);
split.FirstGop.TotalMilliseconds.ShouldBe(400, 1);
}
}
@@ -1,77 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
/// <summary>
/// Pins which watermarks ffmpeg may carry natively and which must go to the graphics engine.
/// The remote-URL rule is the second half of the #502 fix: resolving the URL is useless if the
/// resolved path is then handed to ffmpeg as a bare <c>-i</c> argument.
/// </summary>
[TestFixture]
public class FFmpegNativeWatermarkRoutingTests
{
private const string LocalPath = "/cache/logos/ab/abc123.png";
private static WatermarkOptions Options(
string imagePath,
ChannelWatermarkMode mode = ChannelWatermarkMode.Permanent) =>
new(new ChannelWatermark { Id = 1, Name = "wm", Mode = mode }, imagePath, Option<int>.None);
[Test]
public void Local_Path_Single_Permanent_Watermark_Uses_FFmpeg()
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath)])
.ShouldBeTrue();
}
[TestCase("https://cdn.example.com/logos/channel.png")]
[TestCase("http://cdn.example.com/logos/channel.png")]
public void Remote_Url_Watermark_Goes_To_Graphics_Engine(string url)
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(url)])
.ShouldBeFalse();
}
/// <summary>
/// The generated-initials fallback is a localhost URL. Only the deco path still emits it, and it is
/// routed by its resolved path like any other URL — see the #502 entry in docs/decisions.md and #510.
/// </summary>
[Test]
public void Generated_Localhost_Logo_Url_Goes_To_Graphics_Engine()
{
FFmpegLibraryProcessService
.CanUseFFmpegNativeWatermark(0, [Options("http://localhost:8409/iptv/logos/gen?text=Test")])
.ShouldBeFalse();
}
[Test]
public void Graphics_Elements_Present_Goes_To_Graphics_Engine()
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(1, [Options(LocalPath)])
.ShouldBeFalse();
}
[Test]
public void Multiple_Watermarks_Go_To_Graphics_Engine()
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath), Options(LocalPath)])
.ShouldBeFalse();
}
[Test]
public void No_Watermarks_Does_Not_Use_FFmpeg()
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, []).ShouldBeFalse();
}
[TestCase(ChannelWatermarkMode.Intermittent)]
[TestCase(ChannelWatermarkMode.None)]
public void Non_Permanent_Watermark_Goes_To_Graphics_Engine(ChannelWatermarkMode mode)
{
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath, mode)])
.ShouldBeFalse();
}
}
@@ -1,129 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.FFmpeg.State;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class SongVideoGeneratorTests
{
private ITempFilePool _tempFilePool;
private IImageCache _imageCache;
private IFFmpegProcessService _ffmpegProcessService;
private ILocalFileSystem _localFileSystem;
private SongVideoGenerator _songVideoGenerator;
private string _tempSubtitleFile;
[SetUp]
public void SetUp()
{
_tempSubtitleFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.ass");
_tempFilePool = Substitute.For<ITempFilePool>();
_tempFilePool.GetNextTempFile(Arg.Any<TempFileCategory>()).Returns(_tempSubtitleFile);
_imageCache = Substitute.For<IImageCache>();
_imageCache.GetPathForImage(Arg.Any<string>(), Arg.Any<ArtworkKind>(), Arg.Any<Option<int>>())
.Returns("/fake/watermark.png");
_ffmpegProcessService = Substitute.For<IFFmpegProcessService>();
_ffmpegProcessService.GenerateSongImage(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<Option<string>>(),
Arg.Any<Channel>(),
Arg.Any<MediaVersion>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<Option<string>>(),
Arg.Any<WatermarkLocation>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(Either<BaseError, string>.Right("/fake/song-image.png"));
_localFileSystem = Substitute.For<ILocalFileSystem>();
_localFileSystem.GetCustomOrDefaultFile(Arg.Any<string>(), Arg.Any<string>())
.Returns("/fake/background.png");
_songVideoGenerator = new SongVideoGenerator(
_tempFilePool,
_imageCache,
_ffmpegProcessService,
_localFileSystem);
}
[TearDown]
public void TearDown()
{
if (_tempSubtitleFile is not null && File.Exists(_tempSubtitleFile))
{
File.Delete(_tempSubtitleFile);
}
}
private static Channel BuildChannel()
{
var resolution = new Resolution { Width = 1920, Height = 1080 };
FFmpegProfile ffmpegProfile = FFmpegProfile.New("test", resolution);
return new Channel(Guid.NewGuid())
{
Number = "1",
Name = "Test Channel",
FFmpegProfile = ffmpegProfile,
SongVideoMode = ChannelSongVideoMode.Default
};
}
private static Song BuildUntaggedSong()
{
// an untagged song: FallbackMetadataProvider.GetSongMetadata never assigns
// Artists/AlbumArtists, so they persist (and materialize) as null (ersatztv#691)
var metadata = new SongMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = "Untagged Song",
Artwork = [],
Artists = null,
AlbumArtists = null
};
return new Song
{
SongMetadata = [metadata],
MediaVersions = []
};
}
[Test]
public async Task GenerateSongVideo_should_not_throw_when_artists_and_album_artists_are_null()
{
Song song = BuildUntaggedSong();
Channel channel = BuildChannel();
// SongVideoGenerator randomly picks between two rendering styles (and dereferences
// metadata.Artists/AlbumArtists differently in each); loop enough times that both
// branches -- including the AlbumArtists.Filter(... Artists.Contains ...) branch --
// are exercised with overwhelming probability, so the null guard is proven on both.
for (var i = 0; i < 25; i++)
{
Tuple<string, MediaVersion> result = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
"/usr/bin/ffmpeg",
"/usr/bin/ffprobe",
CancellationToken.None);
result.ShouldNotBeNull();
result.Item1.ShouldBe("/fake/song-image.png");
}
}
}
@@ -1,207 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg;
/// <summary>
/// Covers <see cref="ChannelWatermarkImageSource.ChannelLogo" /> resolution at all three watermark
/// precedence levels (playout item, channel, global). The shared fixture in
/// <see cref="WatermarkSelectorTests" /> deliberately makes every watermark file exist, so it cannot
/// express the "logo is an external URL" or "logo file is gone" cases this fixture exists for (#502).
/// </summary>
[TestFixture]
public class WatermarkSelectorChannelLogoTests
{
private const string ExternalLogoUrl = "https://cdn.example.com/logos/channel.png";
private const string LocalLogoPath = "abc123.png";
private const string LocalLogoCachePath = "/cache/logos/ab/abc123.png";
private WatermarkSelector _selector;
[SetUp]
public void SetUp()
{
var mockFileSystem = new MockFileSystem();
mockFileSystem.Initialize().WithFile(LocalLogoCachePath);
var fakeImageCache = Substitute.For<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
.Returns(_ => LocalLogoCachePath);
_selector = new WatermarkSelector(
mockFileSystem,
fakeImageCache,
Substitute.For<IDecoSelector>(),
NullLogger<WatermarkSelector>.Instance);
}
private static ChannelWatermark ChannelLogoWatermark(int id, string name) =>
new()
{
Id = id,
Name = name,
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
Mode = ChannelWatermarkMode.Permanent
};
private static Channel ChannelWithLogo(string logoPath, ChannelWatermark channelWatermark = null)
{
var channel = new Channel(Guid.Empty)
{
Id = 1,
Number = "1",
Name = "Test",
StreamingMode = StreamingMode.TransportStream,
Artwork = [],
Watermark = channelWatermark,
WatermarkId = channelWatermark?.Id
};
if (logoPath is not null)
{
channel.Artwork.Add(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = logoPath });
}
return channel;
}
// ---- external URL logo: render path must degrade to no bug, never fetch (#525) --------------
//
// As of #525 an external-URL logo is downloaded and cached at save time, so a URL path can only be a
// row that failed migration. The render/watermark path must NOT fetch at compositing time: it degrades
// to None (no on-screen bug) with a warning, rather than handing the URL downstream as a renderable
// ImagePath (the #502 behavior these tests previously pinned).
[Test]
public void PlayoutItemWatermark_Should_Ignore_External_Url_Channel_Logo()
{
ChannelWatermark watermark = ChannelLogoWatermark(1, "PlayoutItem");
Channel channel = ChannelWithLogo(ExternalLogoUrl);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
watermark,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
[Test]
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo()
{
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(ExternalLogoUrl, watermark);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
// never hand the URL downstream as a renderable path
result.IfSome(o => o.ImagePath.ShouldNotBe(ExternalLogoUrl));
}
[Test]
public void GlobalWatermark_Should_Ignore_External_Url_Channel_Logo()
{
ChannelWatermark watermark = ChannelLogoWatermark(3, "Global");
Channel channel = ChannelWithLogo(ExternalLogoUrl);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
watermark);
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// Scheme comparison goes through <see cref="Uri" />, which lower-cases it. Pinned because the fix
/// turns on <c>Artwork.IsExternalUrl</c>, and a case-sensitive check would silently fall back to the
/// existence-gated branch and re-introduce the defect for an oddly-cased URL.
/// </summary>
[Test]
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo_Regardless_Of_Scheme_Case()
{
const string UpperCaseUrl = "HTTPS://cdn.example.com/logos/channel.png";
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(UpperCaseUrl, watermark);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
// ---- regressions: local-file behavior must not change ---------------------------------------
[Test]
public void ChannelWatermark_Should_Use_Cached_Path_For_Local_Channel_Logo()
{
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsSome.ShouldBeTrue();
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LocalLogoCachePath);
}
[Test]
public void ChannelWatermark_Should_Be_Ignored_When_Local_Channel_Logo_File_Is_Missing()
{
var mockFileSystem = new MockFileSystem(); // nothing on disk
var fakeImageCache = Substitute.For<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
.Returns(_ => LocalLogoCachePath);
var selector = new WatermarkSelector(
mockFileSystem,
fakeImageCache,
Substitute.For<IDecoSelector>(),
NullLogger<WatermarkSelector>.Instance);
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
Option<WatermarkOptions> result = selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// Scope guard for #502: with no logo artwork at all, the resolved path is the generated-initials
/// URL from <see cref="Images.ChannelLogoGenerator.GenerateChannelLogoUrl" />, which hardcodes
/// localhost (issue #1). That fallback stays disabled here — reviving it is deliberately deferred
/// in docs/decisions.md and is not part of this fix.
/// </summary>
[Test]
public void ChannelWatermark_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
{
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
Channel channel = ChannelWithLogo(null, watermark);
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
}
@@ -1,589 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Images;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg;
/// <summary>
/// Pins ersatztv#510: a watermark attached through a DECO resolves by exactly the same policy as the three
/// precedence levels (playout item, channel, global).
/// </summary>
/// <remarks>
/// Before #510 the deco path had its own copy of the image-source switch that resolved paths *unchecked*, so
/// one channel could disagree with itself about whether a bug rendered purely by how the watermark was
/// attached. The divergence covered all three <see cref="ChannelWatermarkImageSource" /> values, not just
/// <c>ChannelLogo</c>:
/// <list type="bullet">
/// <item>a missing local file was handed downstream as a dead path (and a dead LOCAL path can reach
/// ffmpeg as a bare <c>-i</c> argument via <c>CanUseFFmpegNativeWatermark</c>, so it is worse than a
/// skipped overlay);</item>
/// <item>an un-migrated external-URL logo was handed down as a renderable URL, which
/// <c>graphics.channel-logo-caching</c> (#525) forbids the render path from fetching;</item>
/// <item>a channel with no logo artwork got the generated-initials localhost URL, which a live-E2E on a
/// real transcoded frame confirmed DID render — the deco path only. #510 resolved that split in favour
/// of "no on-screen bug" everywhere.</item>
/// </list>
/// The <c>Deco_And_Channel_Level_Should_Resolve_Identically</c> cases are the structural guard: they assert
/// the two callers agree, so re-introducing a per-caller policy fails here rather than silently in prod.
/// </remarks>
[TestFixture]
public class WatermarkSelectorDecoResolutionTests
{
private const string ExternalLogoUrl = "https://cdn.example.com/logos/channel.png";
private const string LogoStoredPath = "abc123.png";
private const string LogoCachePath = "/cache/logos/ab/abc123.png";
private const string CustomStoredPath = "def456.png";
private const string CustomCachePath = "/cache/watermarks/de/def456.png";
private const string ResourceImage = "song-progress.png";
private static string ResourcePath => Path.Combine(FileSystemLayout.ResourcesCacheFolder, ResourceImage);
/// <summary>Builds a selector whose mock filesystem contains exactly <paramref name="existingFiles" />.</summary>
private static WatermarkSelector Selector(Deco playoutDeco, params string[] existingFiles)
{
// one Initialize() call, chained -- calling it per file would leave "does a second Initialize()
// preserve the first file?" untested, and a silently under-seeded filesystem makes a
// "resolves to nothing" assertion pass for the wrong reason
var mockFileSystem = new MockFileSystem();
if (existingFiles.Length > 0)
{
var initialized = mockFileSystem.Initialize().WithFile(existingFiles[0]);
foreach (string file in existingFiles.Skip(1))
{
initialized = initialized.WithFile(file);
}
}
var fakeImageCache = Substitute.For<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
.Returns(_ => LogoCachePath);
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Watermark), Arg.Any<Option<int>>())
.Returns(_ => CustomCachePath);
// Faithful to the real ImageCache.GetPathForImage, which does fileName[..2] and therefore THROWS on a
// blank/null name. Modelling that is what makes the blank-image guard tests mutation-sensitive: before
// #510 the channel and global arms had no guard and this threw out of stream startup.
fakeImageCache
.GetPathForImage(
Arg.Is<string>(s => string.IsNullOrWhiteSpace(s)),
Arg.Any<ArtworkKind>(),
Arg.Any<Option<int>>())
.Returns<string>(_ => throw new ArgumentOutOfRangeException(nameof(IImageCache.GetPathForImage)));
var decoSelector = Substitute.For<IDecoSelector>();
decoSelector.GetDecoEntries(Arg.Any<Playout>(), Arg.Any<DateTimeOffset>())
.Returns(new DecoEntries(Option<Deco>.None, Optional(playoutDeco)));
return new WatermarkSelector(
mockFileSystem,
fakeImageCache,
decoSelector,
NullLogger<WatermarkSelector>.Instance);
}
private static ChannelWatermark Watermark(ChannelWatermarkImageSource source, string image = "") =>
new()
{
Id = 7,
Name = "Deco Bug",
ImageSource = source,
Image = image,
Mode = ChannelWatermarkMode.Permanent
};
private static Deco DecoWith(ChannelWatermark watermark) =>
new()
{
Id = 1,
Name = "Test Deco",
WatermarkMode = DecoMode.Override,
UseWatermarkDuringFiller = true,
DecoWatermarks = [new DecoWatermark { WatermarkId = watermark.Id, Watermark = watermark }],
Watermarks = []
};
private static Channel ChannelWith(string logoPath, ChannelWatermark channelWatermark = null)
{
var channel = new Channel(Guid.Empty)
{
Id = 1,
Number = "1",
Name = "Test",
StreamingMode = StreamingMode.TransportStream,
Artwork = [],
Watermark = channelWatermark,
WatermarkId = channelWatermark?.Id
};
if (logoPath is not null)
{
channel.Artwork.Add(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = logoPath });
}
return channel;
}
private static PlayoutItem PlayoutItem() =>
new()
{
FillerKind = FillerKind.None,
DisableWatermarks = false,
Watermarks = [],
Playout = new Playout()
};
private static List<WatermarkOptions> SelectViaDeco(
ChannelWatermark watermark,
Channel channel,
params string[] existingFiles)
{
WatermarkSelector selector = Selector(DecoWith(watermark), existingFiles);
return selector.SelectWatermarks(
Option<ChannelWatermark>.None,
channel,
PlayoutItem(),
DateTimeOffset.Now);
}
// ---- positive control: the arrangement CAN produce a watermark ------------------------------
//
// Without this, every "resolves to nothing" assertion below could pass vacuously (a broken deco
// arrangement that never reaches the resolver at all looks identical to a correct refusal).
[Test]
public void Deco_ChannelLogo_Should_Use_Cached_Path_When_Local_Logo_Exists()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(LogoStoredPath);
List<WatermarkOptions> result = SelectViaDeco(watermark, channel, LogoCachePath);
result.Count.ShouldBe(1);
result[0].ImagePath.ShouldBe(LogoCachePath);
}
// ---- ChannelLogo: the three cases #510 was filed for ----------------------------------------
[Test]
public void Deco_ChannelLogo_Should_Be_Ignored_When_Logo_Is_An_External_Url()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(ExternalLogoUrl);
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
[Test]
public void Deco_ChannelLogo_Should_Be_Ignored_When_Local_Logo_File_Is_Missing()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(LogoStoredPath);
// nothing on disk
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
/// <summary>
/// The #510 policy decision: with no logo artwork the generated-initials fallback is NOT used. It
/// genuinely rendered here before (confirmed by live-E2E on a real frame), so this is a deliberate,
/// recorded behavior change — not a no-op cleanup.
/// </summary>
[Test]
public void Deco_ChannelLogo_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
Channel channel = ChannelWith(null);
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
// Folded in from a separate test that asserted only this. On its own it was vacuous — an empty list
// trivially contains no URL — so it is a second assertion here rather than a test implying independent
// coverage. It earns its place by naming the value if this ever starts returning options again (#652).
result.Select(o => o.ImagePath)
.ShouldNotContain(ChannelLogoGenerator.GenerateChannelLogoUrl(channel));
}
// ---- Custom and Resource: the two arms #510 did not mention but that diverged too -----------
[Test]
public void Deco_Custom_Should_Use_Cached_Path_When_File_Exists()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
Channel channel = ChannelWith(LogoStoredPath);
List<WatermarkOptions> result = SelectViaDeco(watermark, channel, CustomCachePath);
result.Count.ShouldBe(1);
result[0].ImagePath.ShouldBe(CustomCachePath);
}
[Test]
public void Deco_Custom_Should_Be_Ignored_When_File_Is_Missing()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
Channel channel = ChannelWith(LogoStoredPath);
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
[Test]
public void Deco_Custom_Should_Be_Ignored_When_Image_Is_Blank()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
Channel channel = ChannelWith(LogoStoredPath);
List<WatermarkOptions> result = SelectViaDeco(watermark, channel, CustomCachePath);
result.ShouldBeEmpty();
}
[Test]
public void Deco_Resource_Should_Use_Resource_Path_When_File_Exists()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
Channel channel = ChannelWith(LogoStoredPath);
List<WatermarkOptions> result = SelectViaDeco(watermark, channel, ResourcePath);
result.Count.ShouldBe(1);
result[0].ImagePath.ShouldBe(ResourcePath);
}
[Test]
public void Deco_Resource_Should_Be_Ignored_When_File_Is_Missing()
{
ChannelWatermark watermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
Channel channel = ChannelWith(LogoStoredPath);
List<WatermarkOptions> result = SelectViaDeco(watermark, channel);
result.ShouldBeEmpty();
}
// ---- non-deco consequences of the SAME unification -------------------------------------------
//
// These pin precedence-level behavior rather than deco behavior, but they exist because of the #510
// unification: one is the single piece of per-caller policy deliberately kept, the others are arms that
// used to throw. Without them a future refactor can delete the survivor, or re-introduce the crash, with
// a fully green suite.
/// <summary>
/// The one surviving per-caller policy: a playout-item `Custom` watermark with a blank image falls
/// THROUGH to the channel/global watermark rather than resolving to "no watermark". Unifying
/// resolution must not change which watermark WINS.
/// </summary>
[Test]
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Channel_Watermark()
{
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
channelWatermark.Id = 8;
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option<WatermarkOptions> result = Selector(null, CustomCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
// the CHANNEL watermark wins -- not None, and not the blank playout-item one
result.IsSome.ShouldBeTrue();
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
options.ImagePath.ShouldBe(CustomCachePath);
options.Watermark.Id.ShouldBe(8);
}
/// <summary>
/// Before #510 the channel and global arms had no blank-image guard, so they reached
/// <c>ImageCache.GetPathForImage</c> whose <c>fileName[..2]</c> threw out of stream startup. Now a
/// warning plus no watermark.
/// </summary>
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Channel_Level_Blank_Custom_Watermark_Should_Resolve_To_None_Not_Throw(string image)
{
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Custom, image);
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option<WatermarkOptions> result = Should.NotThrow(
() => Selector(null).GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None));
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// Same fall-through, but landing on the GLOBAL watermark — the channel-level variant above cannot
/// distinguish "fell through correctly" from "stopped at the channel by accident".
/// </summary>
[Test]
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Global_Watermark()
{
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, " ");
ChannelWatermark globalWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
globalWatermark.Id = 9;
// no channel-level watermark, so the only remaining candidate is the global one
Channel channel = ChannelWith(LogoStoredPath);
Option<WatermarkOptions> result = Selector(null, CustomCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, globalWatermark);
result.IsSome.ShouldBeTrue();
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
options.ImagePath.ShouldBe(CustomCachePath);
options.Watermark.Id.ShouldBe(9);
}
/// <summary>
/// The complement of the fall-through cases: a NON-blank custom image whose file is merely missing must
/// NOT fall through — it resolves to "no watermark" and the channel watermark never gets a turn.
/// Without this, widening the blank-image guard to "any unresolvable custom" would pass unnoticed.
/// </summary>
/// <remarks>
/// The channel-level fallback is deliberately an INDEPENDENTLY RESOLVABLE `ChannelLogo` watermark whose
/// cached file exists. An earlier version of this test gave the fallback the same missing custom path as
/// the playout-item watermark, which made it unfalsifiable: a wrongly-widened guard would have fallen
/// through to a fallback that also resolved to None, so the assertion held either way.
/// </remarks>
[Test]
public void Missing_But_Named_Custom_Playout_Item_Watermark_Should_Not_Fall_Through()
{
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
channelWatermark.Id = 8;
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
// the channel logo's cached file EXISTS, so a fall-through would return it and fail this test;
// the custom watermark's file does not, so the playout-item watermark is unresolvable
Option<WatermarkOptions> result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// Positive control for the test above: the same arrangement, but with the playout-item watermark BLANK
/// rather than missing, must fall through and return the resolvable channel logo. Together the pair
/// shows the guard distinguishes blank from unresolvable, rather than both landing on None.
/// </summary>
/// <remarks>
/// Parameterized over all three blank forms because the guard is <c>IsNullOrWhiteSpace</c>: testing only
/// <c>" "</c> would let a mutation to <c>image == " "</c> pass while silently breaking fall-through
/// for <c>null</c> and <c>""</c> — and <c>null</c> is the form the API actually persists.
/// </remarks>
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_A_Resolvable_Channel_Logo(string image)
{
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.Custom, image);
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
channelWatermark.Id = 8;
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option<WatermarkOptions> result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
result.IsSome.ShouldBeTrue();
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LogoCachePath);
}
/// <summary>
/// Pins the <c>ImageSource is Custom</c> half of the blank-image guard, which nothing else covers.
/// </summary>
/// <remarks>
/// A <c>ChannelLogo</c> watermark's <c>Image</c> is NORMALLY blank — the API persists `Image = null` for
/// every non-`Custom` source — so if the guard's `is Custom` discriminator were dropped, leaving only
/// `IsNullOrWhiteSpace(Image)`, every playout-item `ChannelLogo` watermark would fall through to
/// channel/global instead of resolving the channel's own logo. This test fails on that mutation: the
/// playout-item watermark carries a distinguishing Id, so falling through is observable even though both
/// levels would resolve to the same cached path.
/// </remarks>
[Test]
public void Blank_Image_ChannelLogo_Playout_Item_Watermark_Should_Win_And_Not_Fall_Through()
{
// Image is left blank, exactly as the API stores a ChannelLogo watermark
ChannelWatermark playoutItemWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
playoutItemWatermark.Id = 42;
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.ChannelLogo);
channelWatermark.Id = 8;
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option<WatermarkOptions> result = Selector(null, LogoCachePath)
.GetWatermarkOptions(channel, playoutItemWatermark, Option<ChannelWatermark>.None);
result.IsSome.ShouldBeTrue();
WatermarkOptions options = result.IfNone(() => throw new InvalidOperationException());
options.ImagePath.ShouldBe(LogoCachePath);
// the PLAYOUT-ITEM watermark won; a fall-through would have returned the channel's (Id 8)
options.Watermark.Id.ShouldBe(42);
}
/// <summary>
/// `CreateWatermarkHandler`/`UpdateWatermarkHandler` write `Image = null` for every non-`Custom`
/// watermark, so an API-created `Resource` watermark hits `Path.Combine(folder, null)` — an
/// `ArgumentNullException` out of stream startup. Uses the persisted shape (null), not a hand-made
/// filename, which is what the rest of the fixture would otherwise assume.
/// </summary>
[TestCase(null)]
[TestCase("")]
public void Resource_Watermark_With_No_Image_Name_Should_Resolve_To_None_Not_Throw(string image)
{
ChannelWatermark channelWatermark = Watermark(ChannelWatermarkImageSource.Resource, image);
Channel channel = ChannelWith(LogoStoredPath, channelWatermark);
Option<WatermarkOptions> result = Should.NotThrow(
() => Selector(null).GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
Option<ChannelWatermark>.None));
result.IsNone.ShouldBeTrue();
}
/// <summary>
/// Dropping an unresolvable watermark shortens the list handed to
/// <c>CanUseFFmpegNativeWatermark</c>, whose predicate includes `Count == 1`. So this is also the pin on
/// the observable routing change: two attached permanent watermarks, one missing, now yield ONE option
/// (ffmpeg-native) where they previously yielded two (graphics engine).
/// </summary>
[Test]
public void Deco_With_One_Valid_And_One_Missing_Watermark_Should_Return_Only_The_Valid_One()
{
ChannelWatermark valid = Watermark(ChannelWatermarkImageSource.ChannelLogo);
ChannelWatermark missing = Watermark(ChannelWatermarkImageSource.Custom, CustomStoredPath);
missing.Id = 8;
var deco = new Deco
{
Id = 1,
Name = "Test Deco",
WatermarkMode = DecoMode.Override,
UseWatermarkDuringFiller = true,
DecoWatermarks =
[
new DecoWatermark { WatermarkId = valid.Id, Watermark = valid },
new DecoWatermark { WatermarkId = missing.Id, Watermark = missing }
],
Watermarks = []
};
// only the channel logo's cached file exists; the custom watermark's does not
List<WatermarkOptions> result = Selector(deco, LogoCachePath).SelectWatermarks(
Option<ChannelWatermark>.None,
ChannelWith(LogoStoredPath),
PlayoutItem(),
DateTimeOffset.Now);
result.Count.ShouldBe(1);
result[0].ImagePath.ShouldBe(LogoCachePath);
// The routing claim itself, not just the filtering: call the real predicate. Asserting Count == 1 alone
// would leave the decision record's "now routes ffmpeg-native" statement unpinned, since the decision
// lives in FFmpegLibraryProcessService rather than in the selector.
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, result).ShouldBeTrue();
}
/// <summary>
/// Before #510 the global arm had no <c>Resource</c> case and hit <c>default: throw</c>.
/// </summary>
[Test]
public void Global_Level_Resource_Watermark_Should_Resolve_Instead_Of_Throwing()
{
ChannelWatermark globalWatermark = Watermark(ChannelWatermarkImageSource.Resource, ResourceImage);
Channel channel = ChannelWith(LogoStoredPath);
Option<WatermarkOptions> result = Should.NotThrow(
() => Selector(null, ResourcePath).GetWatermarkOptions(
channel,
Option<ChannelWatermark>.None,
globalWatermark));
result.IsSome.ShouldBeTrue();
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(ResourcePath);
}
// ---- the structural guard: deco and channel-level must agree, case for case ------------------
private static IEnumerable<TestCaseData> ParityCases()
{
// (image source, watermark.Image, channel logo path, files that exist)
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", LogoStoredPath, new[] { LogoCachePath })
.SetName("ChannelLogo, local file present");
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", LogoStoredPath, Array.Empty<string>())
.SetName("ChannelLogo, local file missing");
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", ExternalLogoUrl, Array.Empty<string>())
.SetName("ChannelLogo, external URL");
yield return new TestCaseData(
ChannelWatermarkImageSource.ChannelLogo, "", null, Array.Empty<string>())
.SetName("ChannelLogo, no logo artwork");
yield return new TestCaseData(
ChannelWatermarkImageSource.Custom, CustomStoredPath, LogoStoredPath, new[] { CustomCachePath })
.SetName("Custom, file present");
yield return new TestCaseData(
ChannelWatermarkImageSource.Custom, CustomStoredPath, LogoStoredPath, Array.Empty<string>())
.SetName("Custom, file missing");
// Both sides agree here by construction (each returns nothing), which is the point: it documents that
// the blank-image fall-through asymmetry lives ONLY at the playout-item level -- covered by
// Blank_Custom_Playout_Item_Watermark_Should_Fall_Through_To_Channel_Watermark -- rather than leaving
// the omission looking like an evasion.
yield return new TestCaseData(
ChannelWatermarkImageSource.Custom, " ", LogoStoredPath, Array.Empty<string>())
.SetName("Custom, blank image");
yield return new TestCaseData(
ChannelWatermarkImageSource.Resource, ResourceImage, LogoStoredPath, new[] { ResourcePath })
.SetName("Resource, file present");
yield return new TestCaseData(
ChannelWatermarkImageSource.Resource, ResourceImage, LogoStoredPath, Array.Empty<string>())
.SetName("Resource, file missing");
}
[TestCaseSource(nameof(ParityCases))]
public void Deco_And_Channel_Level_Should_Resolve_Identically(
ChannelWatermarkImageSource source,
string image,
string logoPath,
string[] existingFiles)
{
// deco path
ChannelWatermark decoWatermark = Watermark(source, image);
List<WatermarkOptions> viaDeco = SelectViaDeco(decoWatermark, ChannelWith(logoPath), existingFiles);
// channel precedence level, same watermark definition and same channel
ChannelWatermark channelWatermark = Watermark(source, image);
Channel channel = ChannelWith(logoPath, channelWatermark);
Option<WatermarkOptions> viaChannel = Selector(null, existingFiles)
.GetWatermarkOptions(channel, Option<ChannelWatermark>.None, Option<ChannelWatermark>.None);
List<string> decoPaths = viaDeco.Select(o => o.ImagePath).ToList();
// built explicitly rather than via Option.ToList(), which yields a LanguageExt Lst<string>
var channelPaths = new List<string>();
viaChannel.IfSome(o => channelPaths.Add(o.ImagePath));
decoPaths.ShouldBe(channelPaths);
}
}
@@ -1,52 +0,0 @@
using ErsatzTV.Core.Images;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Images;
[TestFixture]
public class RemoteImageDecodeBudgetTests
{
private static readonly Uri Uri = new("https://example.com/logo.png");
// the product is the real bound: 2500x2500 x600 is affordable on each axis alone but not together
[Test]
public void Should_Reject_Dimensions_And_Frames_Affordable_Alone_But_Not_Together()
{
((long)2500 * 2500).ShouldBeLessThanOrEqualTo(RemoteImageDecodeBudget.MaxRemoteDecodedPixels);
600.ShouldBeLessThanOrEqualTo(RemoteImageDecodeBudget.MaxRemoteFrames);
InvalidOperationException ex = Should.Throw<InvalidOperationException>(
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(2500, 2500, 600, Uri));
ex.Message.ShouldContain("pixel limit");
}
[Test]
public void Should_Reject_Too_Many_Frames_Even_When_Each_Is_Tiny() =>
Should.Throw<InvalidOperationException>(
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(8, 8, RemoteImageDecodeBudget.MaxRemoteFrames + 1, Uri))
.Message.ShouldContain("frame limit");
[Test]
public void Should_Reject_A_Single_Oversized_Frame() =>
Should.Throw<InvalidOperationException>(
() => RemoteImageDecodeBudget.EnsureDimensionsAffordable(30000, 30000, Uri))
.Message.ShouldContain("pixel limit");
[Test]
public void Should_Allow_A_Single_Large_Still_Within_Budget() =>
Should.NotThrow(() => RemoteImageDecodeBudget.EnsureDecodeAffordable(7680, 4320, 1, Uri));
[Test]
public void Should_Charge_At_Least_One_Frame_When_Header_Reports_None() =>
Should.Throw<InvalidOperationException>(
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(30000, 30000, 0, Uri));
[Test]
public void Should_Afford_Fewer_Frames_As_Frames_Get_Larger()
{
RemoteImageDecodeBudget.AffordableFrames(8, 8).ShouldBe(RemoteImageDecodeBudget.MaxRemoteFrames);
RemoteImageDecodeBudget.AffordableFrames(1000, 1000).ShouldBe(50);
RemoteImageDecodeBudget.AffordableFrames(7000, 7000).ShouldBe(1);
}
}
@@ -187,14 +187,11 @@ public class ChannelGuideGoldenTests
xml.ShouldNotContain("a&b");
}
// The access-token value is HTTP-request-derived (?access_token=) and interpolated into the
// {AccessTokenUri} placeholder, which sits in a URL query value inside an XML attribute. It is
// percent-encoded FIRST (#421 — URL-correct: a token '&' becomes %26 so it can't split the query and
// truncate the token once a consumer URL-decodes the attribute) and XML-escaped SECOND (#376 — so the
// guide stays well-formed). For this token every char percent-encodes to an XML-safe %XX, so the emitted
// value is the percent-encoded form with no '&amp;'/'&lt;' introduced by the token.
// The access-token value is HTTP-request-derived (?access_token=) and interpolated raw into the
// {AccessTokenUri} placeholder, so a token containing XML-special chars must be escaped too —
// otherwise it malforms the whole guide, exactly like the {RequestBase} case above. (Finding #376.)
[Test]
public async Task Guide_encodes_and_xml_escapes_access_token()
public async Task Guide_xml_escapes_access_token()
{
MockFileSystem fileSystem = BuildCacheFileSystem();
var localFileSystem = Substitute.For<ILocalFileSystem>();
@@ -226,12 +223,9 @@ public class ChannelGuideGoldenTests
Right: guide => guide.ToXml(),
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
// Percent-encoded, therefore already XML-safe: '&'->%26, '<'->%3C, '>'->%3E, '"'->%22.
xml.ShouldContain("access_token=tok%26%3C%3E%22");
// The token must not introduce a bare '&' NOR an '&amp;' — either would truncate the query on decode.
xml.ShouldNotContain("access_token=tok&");
// And the raw special chars must never reach the output.
xml.ShouldNotContain("tok&<>\"");
// Every XML-special char in the token must be escaped; the raw token must never reach the output.
xml.ShouldContain("access_token=tok&amp;&lt;&gt;&quot;");
xml.ShouldNotContain("access_token=tok&<");
}
// --- harness ---
@@ -1,90 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Iptv;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Iptv;
// #421: a token containing M3U-structural characters (a double-quote or ampersand) must not be able to
// break out of the quoted url-tvg="..."/tvg-logo="..." attributes or the query string it is placed into.
[TestFixture]
public class ChannelPlaylistAccessTokenTests
{
[Test]
public void Access_token_with_structural_chars_is_percent_encoded()
{
const string nastyToken = "aa\"bb&cc dd";
var playlist = new ChannelPlaylist(
"https",
"tv.example.com",
baseUrl: string.Empty,
[
new Channel(new Guid("00000000-0000-0000-0000-000000000001"))
{
Number = "1",
Name = "News",
Group = "ErsatzTV",
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
Artwork = [],
FFmpegProfile = new FFmpegProfile
{
VideoFormat = FFmpegProfileVideoFormat.H264,
AudioFormat = FFmpegProfileAudioFormat.Aac
}
}
],
userAgent: "VLC/3.0",
accessToken: nastyToken);
string m3u = playlist.ToM3U();
// The raw token characters must never appear in the token value...
m3u.ShouldNotContain("access_token=aa\"");
m3u.ShouldNotContain("access_token=aa\"bb&cc");
// ...they are percent-encoded instead (" -> %22, & -> %26, space -> %20).
m3u.ShouldContain("access_token=aa%22bb%26cc%20dd");
// No line's url-tvg attribute value contains a bare double-quote that could terminate it early.
foreach (string line in m3u.Split('\n'))
{
if (line.StartsWith("#EXTM3U", StringComparison.Ordinal))
{
// url-tvg="<url>" x-tvg-url="<url>" — exactly the attribute-delimiting quotes, no stray ones.
line.Count(c => c == '"').ShouldBe(4);
}
}
}
[Test]
public void Normal_jwt_token_is_unchanged()
{
// A base64url JWT is entirely RFC 3986 unreserved, so encoding is a no-op (goldens stay stable).
const string jwt = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEyM30.abc-DEF_123";
var playlist = new ChannelPlaylist(
"https",
"tv.example.com",
baseUrl: string.Empty,
[
new Channel(new Guid("00000000-0000-0000-0000-000000000001"))
{
Number = "1",
Name = "News",
Group = "ErsatzTV",
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
Artwork = [],
FFmpegProfile = new FFmpegProfile
{
VideoFormat = FFmpegProfileVideoFormat.H264,
AudioFormat = FFmpegProfileAudioFormat.Aac
}
}
],
userAgent: "VLC/3.0",
accessToken: jwt);
playlist.ToM3U().ShouldContain($"access_token={jwt}");
}
}
@@ -1,119 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
// Direct unit coverage for the enumerator-construction helper extracted from the Scripted
// (SchedulingEngine.EnumeratorForContent) and Sequential/YAML (EnumeratorCache.GetEnumeratorForContent)
// engines (#395). These pin the two behaviors the issue flags as traps:
// - Shuffle must build the *block* enumerator, NOT Classic's ShuffledMediaCollectionEnumerator (a
// different algorithm keyed on the same PlaybackOrder), and
// - every order the two engines don't support returns None, so each caller logs its own #70 warning
// instead of silently scheduling nothing.
// The Scripted engine has no golden (its external-process/HTTP transport is integration-only, #563), so
// this direct helper test is the in-process regression net for the shared construction it drives.
[TestFixture]
public class ContentEnumeratorBuilderTests
{
[Test]
public void Chronological_Builds_Chronological_Enumerator()
{
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1), FakeMovie(2)],
new CollectionEnumeratorState(),
PlaybackOrder.Chronological,
multiPart: false);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
enumerator.ShouldBeOfType<ChronologicalMediaCollectionEnumerator>();
}
}
[Test]
public void Shuffle_Builds_Block_Shuffle_Enumerator_Not_Classic()
{
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1), FakeMovie(2)],
new CollectionEnumeratorState(),
PlaybackOrder.Shuffle,
multiPart: false);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
// The documented trap: Shuffle here is the block algorithm, never Classic's shuffle.
enumerator.ShouldBeOfType<BlockPlayoutShuffledMediaCollectionEnumerator>();
enumerator.ShouldNotBeOfType<ShuffledMediaCollectionEnumerator>();
}
}
[Test]
public void Shuffle_MultiPart_Also_Builds_Block_Shuffle_Enumerator()
{
// "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together; multiPart routes the
// items through it before the block enumerator. Grouping mechanics are covered by
// MultiPartEpisodeGrouper's / ShuffleSourceBuilder's own tests; here we pin that the flag path still
// produces the block enumerator (and doesn't throw on the grouped list).
List<MediaItem> parts =
[
NamedEpisode("Episode 1 (1)", 1),
NamedEpisode("Episode 2 (2)", 2)
];
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
parts,
new CollectionEnumeratorState(),
PlaybackOrder.Shuffle,
multiPart: true);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
enumerator.ShouldBeOfType<BlockPlayoutShuffledMediaCollectionEnumerator>();
}
}
[TestCase(PlaybackOrder.None)]
[TestCase(PlaybackOrder.Random)]
[TestCase(PlaybackOrder.ShuffleInOrder)]
[TestCase(PlaybackOrder.MultiEpisodeShuffle)]
[TestCase(PlaybackOrder.SeasonEpisode)]
[TestCase(PlaybackOrder.RandomRotation)]
[TestCase(PlaybackOrder.Marathon)]
[TestCase(PlaybackOrder.WeightedShuffle)]
public void Unsupported_Order_Returns_None(PlaybackOrder order)
{
// #70: these two engines support only Chronological + Shuffle; every other order returns None so the
// caller logs a "not supported" warning instead of silently scheduling nothing.
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1)],
new CollectionEnumeratorState(),
order,
multiPart: false);
result.IsNone.ShouldBeTrue();
}
private static Episode NamedEpisode(string title, int id) => new()
{
Id = id,
EpisodeMetadata = [new EpisodeMetadata { Title = title, EpisodeNumber = id }],
Season = new Season { SeasonNumber = 1, Show = new Show { Id = 1 }, ShowId = 1 }
};
private static Movie FakeMovie(int id) => new()
{
Id = id,
MediaVersions = [],
MovieMetadata =
[
new MovieMetadata { ReleaseDate = new DateTime(2020, 1, id) }
]
};
}
@@ -1,18 +0,0 @@
# Deterministic Sequential (YAML) schedule fixture for
# PlayoutBuildGoldenTests.Sequential_yaml (ersatztv#381).
#
# Two `count` instructions over ONE chronological collection. The content enumerator is cached by key,
# so it continues across the two instructions: items 1-2 come from the first `count`, items 3-4 from the
# second. `order: chronological` + literal integer counts keep the build free of shuffle-seed, RNG, and
# wall-clock/local-time dependence, so the snapshot of raw UTC Start/Finish is machine-timezone-independent
# (no Assume guard needed, unlike the Block golden). Do not introduce `wait_until` / `pad_to_next` /
# `pad_until` (local-time-of-day) or a `shuffle` order without revisiting that determinism claim.
content:
- collection: Sequential Test Collection
key: movies
order: chronological
playout:
- count: 2
content: movies
- count: 2
content: movies
@@ -1,144 +0,0 @@
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Schedule Padded Movie Fallback 01
001 | 2026-01-15 00:22:00 - 2026-01-15 00:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
002 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Schedule Padded Movie Fallback 02
003 | 2026-01-15 01:07:00 - 2026-01-15 01:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
004 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Schedule Padded Movie Fallback 03
005 | 2026-01-15 02:07:00 - 2026-01-15 02:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
006 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Schedule Padded Movie Fallback 01
007 | 2026-01-15 02:37:00 - 2026-01-15 02:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
008 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Schedule Padded Movie Fallback 02
009 | 2026-01-15 03:22:00 - 2026-01-15 03:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
010 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Schedule Padded Movie Fallback 03
011 | 2026-01-15 04:22:00 - 2026-01-15 04:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
012 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Schedule Padded Movie Fallback 01
013 | 2026-01-15 04:52:00 - 2026-01-15 05:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
014 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Schedule Padded Movie Fallback 02
015 | 2026-01-15 05:37:00 - 2026-01-15 05:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
016 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Schedule Padded Movie Fallback 03
017 | 2026-01-15 06:37:00 - 2026-01-15 06:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
018 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Schedule Padded Movie Fallback 01
019 | 2026-01-15 07:07:00 - 2026-01-15 07:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
020 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Schedule Padded Movie Fallback 02
021 | 2026-01-15 07:52:00 - 2026-01-15 08:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
022 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Schedule Padded Movie Fallback 03
023 | 2026-01-15 08:52:00 - 2026-01-15 09:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
024 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Schedule Padded Movie Fallback 01
025 | 2026-01-15 09:22:00 - 2026-01-15 09:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
026 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Schedule Padded Movie Fallback 02
027 | 2026-01-15 10:07:00 - 2026-01-15 10:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
028 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Schedule Padded Movie Fallback 03
029 | 2026-01-15 11:07:00 - 2026-01-15 11:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
030 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Schedule Padded Movie Fallback 01
031 | 2026-01-15 11:37:00 - 2026-01-15 11:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
032 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Schedule Padded Movie Fallback 02
033 | 2026-01-15 12:22:00 - 2026-01-15 12:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
034 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Schedule Padded Movie Fallback 03
035 | 2026-01-15 13:22:00 - 2026-01-15 13:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
036 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Schedule Padded Movie Fallback 01
037 | 2026-01-15 13:52:00 - 2026-01-15 14:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
038 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Schedule Padded Movie Fallback 02
039 | 2026-01-15 14:37:00 - 2026-01-15 14:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
040 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Schedule Padded Movie Fallback 03
041 | 2026-01-15 15:37:00 - 2026-01-15 15:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
042 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Schedule Padded Movie Fallback 01
043 | 2026-01-15 16:07:00 - 2026-01-15 16:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
044 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Schedule Padded Movie Fallback 02
045 | 2026-01-15 16:52:00 - 2026-01-15 17:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
046 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Schedule Padded Movie Fallback 03
047 | 2026-01-15 17:52:00 - 2026-01-15 18:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
048 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Schedule Padded Movie Fallback 01
049 | 2026-01-15 18:22:00 - 2026-01-15 18:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
050 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Schedule Padded Movie Fallback 02
051 | 2026-01-15 19:07:00 - 2026-01-15 19:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
052 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Schedule Padded Movie Fallback 03
053 | 2026-01-15 20:07:00 - 2026-01-15 20:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
054 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Schedule Padded Movie Fallback 01
055 | 2026-01-15 20:37:00 - 2026-01-15 20:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
056 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Schedule Padded Movie Fallback 02
057 | 2026-01-15 21:22:00 - 2026-01-15 21:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
058 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Schedule Padded Movie Fallback 03
059 | 2026-01-15 22:22:00 - 2026-01-15 22:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
060 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Schedule Padded Movie Fallback 01
061 | 2026-01-15 22:52:00 - 2026-01-15 23:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
062 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Schedule Padded Movie Fallback 02
063 | 2026-01-15 23:37:00 - 2026-01-15 23:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
064 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Schedule Padded Movie Fallback 03
065 | 2026-01-16 00:37:00 - 2026-01-16 00:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
066 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Schedule Padded Movie Fallback 01
067 | 2026-01-16 01:07:00 - 2026-01-16 01:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
068 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Schedule Padded Movie Fallback 02
069 | 2026-01-16 01:52:00 - 2026-01-16 02:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
070 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Schedule Padded Movie Fallback 03
071 | 2026-01-16 02:52:00 - 2026-01-16 03:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
072 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Schedule Padded Movie Fallback 01
073 | 2026-01-16 03:22:00 - 2026-01-16 03:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
074 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Schedule Padded Movie Fallback 02
075 | 2026-01-16 04:07:00 - 2026-01-16 04:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
076 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Schedule Padded Movie Fallback 03
077 | 2026-01-16 05:07:00 - 2026-01-16 05:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
078 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Schedule Padded Movie Fallback 01
079 | 2026-01-16 05:37:00 - 2026-01-16 05:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
080 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Schedule Padded Movie Fallback 02
081 | 2026-01-16 06:22:00 - 2026-01-16 06:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
082 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Schedule Padded Movie Fallback 03
083 | 2026-01-16 07:22:00 - 2026-01-16 07:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
084 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Schedule Padded Movie Fallback 01
085 | 2026-01-16 07:52:00 - 2026-01-16 08:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
086 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Schedule Padded Movie Fallback 02
087 | 2026-01-16 08:37:00 - 2026-01-16 08:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
088 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Schedule Padded Movie Fallback 03
089 | 2026-01-16 09:37:00 - 2026-01-16 09:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
090 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Schedule Padded Movie Fallback 01
091 | 2026-01-16 10:07:00 - 2026-01-16 10:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
092 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Schedule Padded Movie Fallback 02
093 | 2026-01-16 10:52:00 - 2026-01-16 11:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
094 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Schedule Padded Movie Fallback 03
095 | 2026-01-16 11:52:00 - 2026-01-16 12:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
096 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Schedule Padded Movie Fallback 01
097 | 2026-01-16 12:22:00 - 2026-01-16 12:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
098 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Schedule Padded Movie Fallback 02
099 | 2026-01-16 13:07:00 - 2026-01-16 13:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
100 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Schedule Padded Movie Fallback 03
101 | 2026-01-16 14:07:00 - 2026-01-16 14:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
102 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Schedule Padded Movie Fallback 01
103 | 2026-01-16 14:37:00 - 2026-01-16 14:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
104 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Schedule Padded Movie Fallback 02
105 | 2026-01-16 15:22:00 - 2026-01-16 15:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
106 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Schedule Padded Movie Fallback 03
107 | 2026-01-16 16:22:00 - 2026-01-16 16:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
108 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Schedule Padded Movie Fallback 01
109 | 2026-01-16 16:52:00 - 2026-01-16 17:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
110 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Schedule Padded Movie Fallback 02
111 | 2026-01-16 17:37:00 - 2026-01-16 17:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
112 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Schedule Padded Movie Fallback 03
113 | 2026-01-16 18:37:00 - 2026-01-16 18:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
114 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Schedule Padded Movie Fallback 01
115 | 2026-01-16 19:07:00 - 2026-01-16 19:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
116 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Schedule Padded Movie Fallback 02
117 | 2026-01-16 19:52:00 - 2026-01-16 20:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
118 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Schedule Padded Movie Fallback 03
119 | 2026-01-16 20:52:00 - 2026-01-16 21:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
120 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Schedule Padded Movie Fallback 01
121 | 2026-01-16 21:22:00 - 2026-01-16 21:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
122 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Schedule Padded Movie Fallback 02
123 | 2026-01-16 22:07:00 - 2026-01-16 22:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
124 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Schedule Padded Movie Fallback 03
125 | 2026-01-16 23:07:00 - 2026-01-16 23:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
126 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Schedule Padded Movie Fallback 01
127 | 2026-01-16 23:37:00 - 2026-01-16 23:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
128 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Schedule Padded Movie Fallback 02
129 | 2026-01-17 00:22:00 - 2026-01-17 00:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
130 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Schedule Padded Movie Fallback 03
131 | 2026-01-17 01:22:00 - 2026-01-17 01:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
132 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Schedule Padded Movie Fallback 01
133 | 2026-01-17 01:52:00 - 2026-01-17 02:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
134 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Schedule Padded Movie Fallback 02
135 | 2026-01-17 02:37:00 - 2026-01-17 02:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
136 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Schedule Padded Movie Fallback 03
137 | 2026-01-17 03:37:00 - 2026-01-17 03:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
138 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Schedule Padded Movie Fallback 01
139 | 2026-01-17 04:07:00 - 2026-01-17 04:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
140 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Schedule Padded Movie Fallback 02
141 | 2026-01-17 04:52:00 - 2026-01-17 05:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
142 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Schedule Padded Movie Fallback 03
143 | 2026-01-17 05:52:00 - 2026-01-17 06:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
@@ -1,72 +0,0 @@
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Schedule Padded Movie Offline 01
001 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Schedule Padded Movie Offline 02
002 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Schedule Padded Movie Offline 03
003 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Schedule Padded Movie Offline 01
004 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Schedule Padded Movie Offline 02
005 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Schedule Padded Movie Offline 03
006 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Schedule Padded Movie Offline 01
007 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Schedule Padded Movie Offline 02
008 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Schedule Padded Movie Offline 03
009 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Schedule Padded Movie Offline 01
010 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Schedule Padded Movie Offline 02
011 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Schedule Padded Movie Offline 03
012 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Schedule Padded Movie Offline 01
013 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Schedule Padded Movie Offline 02
014 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Schedule Padded Movie Offline 03
015 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Schedule Padded Movie Offline 01
016 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Schedule Padded Movie Offline 02
017 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Schedule Padded Movie Offline 03
018 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Schedule Padded Movie Offline 01
019 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Schedule Padded Movie Offline 02
020 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Schedule Padded Movie Offline 03
021 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Schedule Padded Movie Offline 01
022 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Schedule Padded Movie Offline 02
023 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Schedule Padded Movie Offline 03
024 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Schedule Padded Movie Offline 01
025 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Schedule Padded Movie Offline 02
026 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Schedule Padded Movie Offline 03
027 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Schedule Padded Movie Offline 01
028 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Schedule Padded Movie Offline 02
029 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Schedule Padded Movie Offline 03
030 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Schedule Padded Movie Offline 01
031 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Schedule Padded Movie Offline 02
032 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Schedule Padded Movie Offline 03
033 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Schedule Padded Movie Offline 01
034 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Schedule Padded Movie Offline 02
035 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Schedule Padded Movie Offline 03
036 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Schedule Padded Movie Offline 01
037 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Schedule Padded Movie Offline 02
038 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Schedule Padded Movie Offline 03
039 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Schedule Padded Movie Offline 01
040 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Schedule Padded Movie Offline 02
041 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Schedule Padded Movie Offline 03
042 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Schedule Padded Movie Offline 01
043 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Schedule Padded Movie Offline 02
044 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Schedule Padded Movie Offline 03
045 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Schedule Padded Movie Offline 01
046 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Schedule Padded Movie Offline 02
047 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Schedule Padded Movie Offline 03
048 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Schedule Padded Movie Offline 01
049 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Schedule Padded Movie Offline 02
050 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Schedule Padded Movie Offline 03
051 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Schedule Padded Movie Offline 01
052 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Schedule Padded Movie Offline 02
053 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Schedule Padded Movie Offline 03
054 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Schedule Padded Movie Offline 01
055 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Schedule Padded Movie Offline 02
056 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Schedule Padded Movie Offline 03
057 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Schedule Padded Movie Offline 01
058 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Schedule Padded Movie Offline 02
059 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Schedule Padded Movie Offline 03
060 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Schedule Padded Movie Offline 01
061 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Schedule Padded Movie Offline 02
062 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Schedule Padded Movie Offline 03
063 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Schedule Padded Movie Offline 01
064 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Schedule Padded Movie Offline 02
065 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Schedule Padded Movie Offline 03
066 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Schedule Padded Movie Offline 01
067 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Schedule Padded Movie Offline 02
068 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Schedule Padded Movie Offline 03
069 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Schedule Padded Movie Offline 01
070 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Schedule Padded Movie Offline 02
071 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Schedule Padded Movie Offline 03
@@ -1,4 +0,0 @@
000 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Sequential Movie 01
001 | 2026-01-15 06:30:00 - 2026-01-15 07:15:00 | None | Sequential Movie 02
002 | 2026-01-15 07:15:00 - 2026-01-15 08:15:00 | None | Sequential Movie 03
003 | 2026-01-15 08:15:00 - 2026-01-15 08:45:00 | None | Sequential Movie 04
@@ -6,12 +6,10 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
@@ -119,138 +117,6 @@ public class PlayoutBuildGoldenTests
await CompareGolden("classic-clock-padded.txt", items, titles);
}
// Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to
// the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances
// the build clock to the boundary even when nothing fills the gap.
[Test]
public async Task Classic_schedule_clock_padded_offline()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: false);
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
content.Count.ShouldBeGreaterThan(2);
// No filler of any kind is emitted (offline gaps only).
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
await CompareGolden("classic-schedule-clock-padded-offline.txt", items, titles);
}
// Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to
// the :15 boundary (no offline gap).
[Test]
public async Task Classic_schedule_clock_padded_fallback()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: true);
items.ShouldContain(i => i.FillerKind == FillerKind.Fallback);
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
await CompareGolden("classic-schedule-clock-padded-fallback.txt", items, titles);
}
// #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad).
[Test]
public async Task Classic_item_pad_wins_over_schedule_pad()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout(schedulePadMinutes: 30);
await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden
}
// #392: the schedule-level pad + OFFLINE advance is shared machinery — AddFiller records the offline
// target and every scheduler honors it. The offline goldens only exercise PlayoutModeSchedulerOne, so
// these invariant tests cover Flood / Duration / Multiple across a 2-day window (two midnight crossings)
// to prove the offline advance AND its day-seam anchor survival are not One-specific. No golden files:
// the invariants (boundary alignment of every content item, zero filler emitted, and resumption on a
// boundary on both later days) fully pin the behavior and are the exact thing the day-seam clamp fix
// must preserve. If the day-boundary anchor clamp wrongly discarded an offline advance, the first
// content item after a midnight would land mid-interval and fail here.
[TestCase("Flood")]
[TestCase("Duration")]
[TestCase("Multiple")]
public async Task Schedule_clock_padded_offline_multimode(string mode)
{
List<PlayoutItem> items = await BuildSchedulePaddedModePlayout(mode);
List<PlayoutItem> content = items
.Where(i => i.FillerKind == FillerKind.None)
.OrderBy(i => i.Start)
.ToList();
// Sanity: a 2-day window over sub-hour content must yield many items spanning >1 day.
content.Count.ShouldBeGreaterThan(10);
content.Select(i => i.Start.Date).Distinct().Count().ShouldBeGreaterThan(2);
// Offline variant: NO filler of any kind is emitted (gaps up to the boundary are left offline).
items.ShouldNotContain(i => i.FillerKind != FillerKind.None, $"[{mode}] offline pad must emit no filler");
// Every content item begins on a :15 boundary. The very first item is the raw anchor at the pinned
// Start (06:00, itself a :15 boundary); every later item — including the first of day 2 and day 3 —
// is on a boundary only because the preceding item's offline pad advanced the clock to it AND the
// day-boundary anchor clamp preserved that advance across each midnight seam.
foreach (PlayoutItem item in content)
{
(item.Start.Minute % 15).ShouldBe(
0,
$"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0, $"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not second-aligned");
}
// Explicit day-seam assertion: the first content item on each day after the first still lands on a
// boundary (this is precisely what regressed before the clamp fix, once per simulated midnight).
List<PlayoutItem> firstOfEachDay = content
.GroupBy(i => i.Start.Date)
.OrderBy(g => g.Key)
.Select(g => g.OrderBy(i => i.Start).First())
.ToList();
foreach (PlayoutItem dayStart in firstOfEachDay.Skip(1))
{
(dayStart.Start.Minute % 15).ShouldBe(
0,
$"[{mode}] first content item of {dayStart.Start:yyyy-MM-dd} at {dayStart.Start:HH:mm:ss} resumed mid-interval");
}
}
// Regression for the whole-branch-review defect: Fill-With-Group schedule items (FillWithGroupMode
// .FillWithOrderedGroups / FillWithShuffledGroups) are scheduled via a FAKE ProgramScheduleItem that
// PlayoutBuilder synthesizes with DeepCopy() (Newtonsoft serialization). ProgramScheduleItem
// .ProgramSchedule is [JsonIgnore]'d there, so without reassigning it on the copy, the schedule-level
// PadToNearestMinute silently no-ops for fill-with-group items only — the normal (non-group) path
// reads the schedule nav that Build() populates centrally and was never broken. No golden: this is an
// invariant-only regression test (boundary alignment + zero filler), the same assertions
// Classic_schedule_clock_padded_offline uses for the non-group path.
[Test]
public async Task Schedule_clock_padded_fill_with_group_offline()
{
List<PlayoutItem> items = await BuildSchedulePaddedFillWithGroupPlayout();
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
content.Count.ShouldBeGreaterThan(2);
// No filler of any kind is emitted (offline gaps only).
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(
0,
$"fill-with-group content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
}
// Classic + PlaybackOrder.Shuffle: exercises PlayoutBuilder's call into the shuffle-source helper
// (GetGroupedMediaItemsForShuffle) that #380 moves to ShuffleSourceBuilder, plus the wiring into
// ShuffledMediaCollectionEnumerator. Unlike the chronological fixture, shuffle output depends on the
@@ -268,36 +134,6 @@ public class PlayoutBuildGoldenTests
[Test]
public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);
// Sequential (YAML) builder (#381, follow-up to #163). SequentialPlayoutBuilder reads a YAML schedule
// file (Playout.ScheduleFile) instead of a ProgramSchedule/Block calendar. The committed fixture
// Goldens/Fixtures/sequential-schedule.yml schedules two `count: 2` instructions over one chronological
// collection, so the builder lays exactly four items back-to-back from the pinned start (the enumerator
// is cached by content key and continues across the two instructions). Besides the golden we assert the
// contiguity invariant: it is what "sequential" means here, and a deliberate change to a count or a
// duration flips both the assertion and the golden (#12 non-vacuity). The count/all/duration handlers do
// pure UTC arithmetic off the caller-supplied start (no TimeZoneInfo.Local / ToLocalTime), so this case
// is TZ-independent and needs no Assume guard — unlike Block, and unlike the wait_until/pad_* handlers
// the fixture deliberately avoids.
[Test]
public async Task Sequential_yaml()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSequentialPlayout();
List<PlayoutItem> ordered = items.OrderBy(i => i.Start).ToList();
ordered.Count.ShouldBe(4);
ordered.ShouldAllBe(i => i.FillerKind == FillerKind.None);
ordered[0].Start.ShouldBe(Start.UtcDateTime);
for (var i = 1; i < ordered.Count; i++)
{
ordered[i].Start.ShouldBe(
ordered[i - 1].Finish,
$"sequential item {i} at {ordered[i].Start:HH:mm:ss} is not contiguous with the previous finish");
}
await CompareGolden("sequential-yaml.txt", items, titles);
}
[Test]
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
public async Task Regenerate_goldens()
@@ -306,11 +142,7 @@ public class PlayoutBuildGoldenTests
try
{
foreach (Func<Task> regen in new Func<Task>[]
{
Classic_chronological, Block_playout, Classic_clock_padded,
Classic_schedule_clock_padded_offline, Classic_schedule_clock_padded_fallback,
Classic_shuffle, Classic_weighted, Sequential_yaml
})
{ Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle })
{
try
{
@@ -857,12 +689,11 @@ public class PlayoutBuildGoldenTests
// --- Clock-boundary pad builder (issue #77) ---
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout(
int? schedulePadMinutes = null)
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedPaddedData(cancellationToken, schedulePadMinutes);
var (playoutId, titles) = await SeedPaddedData(cancellationToken);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
@@ -902,17 +733,11 @@ public class PlayoutBuildGoldenTests
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedPaddedData(
CancellationToken cancellationToken,
int? schedulePadMinutes = null)
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
// Suffix distinguishes this call from the base (schedulePadMinutes: null) call so unique-name/guid
// constraints don't collide when both are seeded into the same shared in-memory database. It never
// touches a Movie/MovieMetadata title, so the golden snapshot (which only records those) is unaffected.
string suffix = schedulePadMinutes.HasValue ? $" SchedulePad{schedulePadMinutes}" : string.Empty;
var path = new LibraryPath { Path = $"Padded LibraryPath{suffix}" };
var path = new LibraryPath { Path = "Padded LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
@@ -961,12 +786,12 @@ public class PlayoutBuildGoldenTests
var contentCollection = new Collection
{
Name = $"Padded Content Collection{suffix}",
Name = "Padded Content Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
var fillerCollection = new Collection
{
Name = $"Padded Filler Collection{suffix}",
Name = "Padded Filler Collection",
MediaItems = new List<MediaItem> { fillerClip }
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
@@ -981,7 +806,7 @@ public class PlayoutBuildGoldenTests
// increment (e.g. 10) — it would become machine-TZ dependent and need the Block-style Assume guard.
var padFiller = new FillerPreset
{
Name = $"Pad To Quarter Hour{suffix}",
Name = "Pad To Quarter Hour",
FillerKind = FillerKind.PostRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 15,
@@ -1005,29 +830,21 @@ public class PlayoutBuildGoldenTests
}
};
var ffmpegProfile = new FFmpegProfile { Name = $"Padded FFmpeg Profile{suffix}" };
var ffmpegProfile = new FFmpegProfile { Name = "Padded FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(
schedulePadMinutes.HasValue
? Guid.Parse("00000000-0000-0000-0000-000000000007")
: Guid.Parse("00000000-0000-0000-0000-000000000003"))
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000003"))
{
Name = $"Padded Test Channel{suffix}",
Number = schedulePadMinutes.HasValue ? "7" : "3",
Name = "Padded Test Channel",
Number = "3",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = $"Padded Test Schedule{suffix}", Items = scheduleItems };
if (schedulePadMinutes.HasValue)
{
schedule.PadToNearestMinute = schedulePadMinutes.Value;
}
var schedule = new ProgramSchedule { Name = "Padded Test Schedule", Items = scheduleItems };
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
@@ -1074,529 +891,6 @@ public class PlayoutBuildGoldenTests
TimeSpan.Zero);
}
// #392: schedule-level PadToNearestMinute (no item-level Pad filler). Mirrors BuildPaddedPlayout/
// SeedPaddedData/GetPaddedReferenceData above, but the item has no PostRollFiller and the schedule
// itself carries the pad divisor.
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSchedulePaddedPlayout(
bool withFallback)
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedSchedulePaddedData(cancellationToken, withFallback);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
NullLogger<PlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedSchedulePaddedData(
CancellationToken cancellationToken,
bool withFallback)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
// Suffix distinguishes the offline/fallback variants so unique-name/guid constraints don't
// collide when both fixtures are seeded into the same shared in-memory database.
string suffix = withFallback ? "Fallback" : "Offline";
var path = new LibraryPath { Path = $"Schedule Padded LibraryPath {suffix}" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Content: three movies with OFF-boundary durations (22/37/52 min) so padding to :15 is visible.
int[] durationsMinutes = [22, 37, 52];
var movies = new List<Movie>();
for (var i = 1; i <= 3; i++)
{
movies.Add(new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Schedule Padded Movie {suffix} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
// Filler: a SINGLE 1-minute clip, only used for the fallback variant.
var fillerClip = new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Schedule Fallback Filler Clip {suffix}", ReleaseDate = new DateTime(2019, 1, 1) }
},
LibraryPath = path,
LibraryPathId = path.Id
};
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.Movies.AddAsync(fillerClip, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
titles[fillerClip.Id] = fillerClip.MovieMetadata[0].Title;
var contentCollection = new Collection
{
Name = $"Schedule Padded Content Collection {suffix}",
MediaItems = movies.Cast<MediaItem>().ToList()
};
var fillerCollection = new Collection
{
Name = $"Schedule Padded Fallback Collection {suffix}",
MediaItems = new List<MediaItem> { fillerClip }
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
await context.Collections.AddAsync(fillerCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
FillerPreset fallbackFiller = null;
if (withFallback)
{
fallbackFiller = new FillerPreset
{
Name = $"Schedule Pad Fallback {suffix}",
FillerKind = FillerKind.Fallback,
FillerMode = FillerMode.None,
CollectionType = CollectionType.Collection,
Collection = fillerCollection,
CollectionId = fillerCollection.Id
};
await context.FillerPresets.AddAsync(fallbackFiller, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
var scheduleItem = new ProgramScheduleItemOne
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
PlaybackOrder = PlaybackOrder.Chronological
};
if (withFallback)
{
scheduleItem.FallbackFiller = fallbackFiller;
scheduleItem.FallbackFillerId = fallbackFiller.Id;
}
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded FFmpeg Profile {suffix}" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse(withFallback ? "00000000-0000-0000-0000-000000000009" : "00000000-0000-0000-0000-000000000008"))
{
Name = $"Schedule Padded Test Channel {suffix}",
Number = withFallback ? "9" : "8",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule
{
Name = $"Schedule Padded Test Schedule {suffix}",
Items = scheduleItems,
PadToNearestMinute = 15
};
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id,
ScheduleKind = PlayoutScheduleKind.Classic
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetSchedulePaddedReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
.AsNoTracking()
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
.Include(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.FallbackFiller)
.FirstOrDefaultAsync();
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
programSchedule,
[],
[],
TimeSpan.Zero);
}
// #392: schedule-level pad + offline advance for a NON-One scheduler (Flood / Duration / Multiple).
// Same shape as SeedSchedulePaddedData (no item-level Pad filler, no FallbackFiller → offline), only the
// ProgramScheduleItem subtype differs. Reuses GetSchedulePaddedReferenceData for the build query.
private async Task<List<PlayoutItem>> BuildSchedulePaddedModePlayout(string mode)
{
var cancellationToken = CancellationToken.None;
int playoutId = await SeedSchedulePaddedModeData(cancellationToken, mode);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
NullLogger<PlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return buildResult.AddedItems;
}
private async Task<int> SeedSchedulePaddedModeData(CancellationToken cancellationToken, string mode)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
// Per-mode suffix + GUID keep unique constraints from colliding across fixtures in the shared DB.
string guid = mode switch
{
"Flood" => "00000000-0000-0000-0000-00000000000a",
"Duration" => "00000000-0000-0000-0000-00000000000b",
"Multiple" => "00000000-0000-0000-0000-00000000000c",
_ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
};
var path = new LibraryPath { Path = $"Schedule Padded Mode LibraryPath {mode}" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Sub-15-min durations (7/11/13) so each padded item occupies exactly one :15 slot. This makes a
// whole :15-multiple block (Duration's 3h) tile exactly with no leftover — Duration therefore never
// packs a final item unpadded to fill the block (its fill-the-duration contract legitimately
// overrides per-item clock-pad when content straddles a :15, which would obscure the pad/seam signal
// this test is pinning). Every mode then holds the same strict boundary invariant.
int[] durationsMinutes = [7, 11, 13];
var movies = new List<Movie>();
for (var i = 1; i <= 3; i++)
{
movies.Add(new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Schedule Padded Mode Movie {mode} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var contentCollection = new Collection
{
Name = $"Schedule Padded Mode Content Collection {mode}",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
ProgramScheduleItem scheduleItem = mode switch
{
"Flood" => new ProgramScheduleItemFlood
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
PlaybackOrder = PlaybackOrder.Chronological
},
"Duration" => new ProgramScheduleItemDuration
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
PlayoutDuration = TimeSpan.FromHours(3),
TailMode = TailMode.Offline,
PlaybackOrder = PlaybackOrder.Chronological
},
"Multiple" => new ProgramScheduleItemMultiple
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
MultipleMode = MultipleMode.Count,
Count = "3",
PlaybackOrder = PlaybackOrder.Chronological
},
_ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
};
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded Mode FFmpeg Profile {mode}" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse(guid))
{
Name = $"Schedule Padded Mode Channel {mode}",
Number = mode switch { "Flood" => "10", "Duration" => "11", _ => "12" },
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule
{
Name = $"Schedule Padded Mode Schedule {mode}",
Items = scheduleItems,
PadToNearestMinute = 15
};
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id,
ScheduleKind = PlayoutScheduleKind.Classic
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return playout.Id;
}
// Regression harness for the Fill-With-Group DeepCopy defect: same shape as
// SeedSchedulePaddedModeData (off-boundary durations, no item-level Pad filler, no FallbackFiller
// -> offline), except the single ProgramScheduleItemMultiple sets FillWithGroupMode so PlayoutBuilder
// schedules it via a synthesized (DeepCopy'd) fake schedule item instead of the original.
private async Task<int> SeedSchedulePaddedFillWithGroupData(CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Schedule Padded FillWithGroup LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Off-boundary durations (22/37/52 min), same as SeedSchedulePaddedData, so padding to :15 is visible.
int[] durationsMinutes = [22, 37, 52];
var movies = new List<Movie>();
for (var i = 1; i <= 3; i++)
{
movies.Add(new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Schedule Padded FillWithGroup Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var contentCollection = new Collection
{
Name = "Schedule Padded FillWithGroup Content Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItem = new ProgramScheduleItemMultiple
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
MultipleMode = MultipleMode.Count,
Count = "3",
PlaybackOrder = PlaybackOrder.Chronological,
FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups
};
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
var ffmpegProfile = new FFmpegProfile { Name = "Schedule Padded FillWithGroup FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-00000000000d"))
{
Name = "Schedule Padded FillWithGroup Channel",
Number = "13",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule
{
Name = "Schedule Padded FillWithGroup Schedule",
Items = scheduleItems,
PadToNearestMinute = 15
};
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id,
ScheduleKind = PlayoutScheduleKind.Classic
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return playout.Id;
}
private async Task<List<PlayoutItem>> BuildSchedulePaddedFillWithGroupPlayout()
{
var cancellationToken = CancellationToken.None;
int playoutId = await SeedSchedulePaddedFillWithGroupData(cancellationToken);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
NullLogger<PlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return buildResult.AddedItems;
}
// --- Block builder ---
//
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
@@ -1847,166 +1141,6 @@ public class PlayoutBuildGoldenTests
TimeSpan.Zero);
}
// --- Sequential (YAML) builder (issue #381) ---
//
// SequentialPlayoutBuilder reads its schedule from a YAML file at Playout.ScheduleFile. It checks the
// file's existence through the injected IFileSystem but reads the bytes with the static System.IO.File,
// so the test writes a REAL committed fixture on disk (Goldens/Fixtures/sequential-schedule.yml) and
// only stubs IFileSystem.File.Exists -> true. The schema validator is stubbed (the real one loads a JSON
// schema from a runtime cache folder that a unit test has no reason to populate); this golden locks the
// BUILDER's PlayoutItem output, not the validator, which is a separate surface.
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSequentialPlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedSequentialData(cancellationToken);
var fileSystem = Substitute.For<System.IO.Abstractions.IFileSystem>();
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
var validator = Substitute.For<ISequentialScheduleValidator>();
validator.ValidateSchedule(Arg.Any<string>(), Arg.Any<bool>()).Returns(Task.FromResult(true));
var builder = new SequentialPlayoutBuilder(
fileSystem,
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
Substitute.For<IChannelRepository>(),
Substitute.For<IGraphicsElementRepository>(),
validator,
NullLogger<SequentialPlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetSequentialReferenceData(context, playoutId);
// Reset over the pinned window: with no prior Anchor, Reset avoids the YamlPlayoutContext.Reset path
// (its ToLocalTime() only runs on a saved-anchor Continue), keeping the build TZ-independent.
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
Start,
playout,
referenceData,
PlayoutBuildMode.Reset,
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedSequentialData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Sequential LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Six movies, distinct release dates (so chronological order is unambiguous) and varied durations so
// item boundaries are visible. Only the first four are scheduled (2 + 2 counts).
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Sequential Movie {i:D2}",
ReleaseDate = new DateTime(2005, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
// Name must match the fixture YAML's `collection:` value — EnumeratorCache resolves content by
// Collection.Name.
var collection = new Collection
{
Name = "Sequential Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var ffmpegProfile = new FFmpegProfile { Name = "Sequential FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000006"))
{
Name = "Sequential Test Channel",
Number = "6",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Sequential playout: no ProgramSchedule; content comes from the YAML ScheduleFile.
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ScheduleKind = PlayoutScheduleKind.Sequential,
ScheduleFile = Path.Combine(FixtureDir(), "sequential-schedule.yml")
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetSequentialReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
// Sequential reads content from the YAML file, not a ProgramSchedule; empty history + a fresh build.
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
null,
[],
[],
TimeSpan.Zero);
}
// One line per PlayoutItem, ordered by Start then MediaItemId (stable tiebreak). Raw UTC Start/Finish
// serialized invariant — NOT the *Offset properties (those localize). Title resolved from the seed map.
private static string Snapshot(List<PlayoutItem> items, Dictionary<int, string> titles)
@@ -2042,12 +1176,6 @@ public class PlayoutBuildGoldenTests
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
// Committed YAML input fixtures (not golden outputs) for builders that read a schedule file. Note
// GoldenDir nests as Goldens/Goldens (this test file already lives under Goldens/), so fixtures sit in a
// sibling Goldens/Fixtures to keep inputs and snapshot outputs visually separate.
private static string FixtureDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() =>
@@ -1,93 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class PlaybackOrderSupportTests
{
private static readonly PlaybackOrder[] AllOrders = Enum.GetValues<PlaybackOrder>();
// The tripwire (#403): every engine must classify every PlaybackOrder value as either supported or
// explicitly unsupported. Adding a new order without classifying it here fails this test, which forces the
// author to wire it into (or deliberately reject it from) each dispatch site instead of letting it degrade
// silently.
[Test]
public void EveryOrder_IsClassified_ForEveryEngine()
{
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
foreach (PlaybackOrder order in AllOrders)
{
PlaybackOrderSupport.IsClassified(engine, order).ShouldBeTrue(
$"PlaybackOrder.{order} is not classified for {engine}. Add it to PlaybackOrderSupport " +
"(supported or unsupported) AND wire it into that engine's dispatch switch (#403).");
}
}
}
// Every SchedulingEngineKind must have a matrix entry, or Matrix[engine] throws KeyNotFoundException at
// runtime instead of failing here. This is the engine-axis counterpart to the order tripwire.
[Test]
public void EveryEngineKind_HasAMatrixEntry()
{
var classified = PlaybackOrderSupport.Engines.ToHashSet();
foreach (SchedulingEngineKind engine in Enum.GetValues<SchedulingEngineKind>())
{
classified.ShouldContain(engine,
$"SchedulingEngineKind.{engine} has no PlaybackOrderSupport matrix entry (#403).");
}
}
// The two sets must partition the enum: no order both supported and unsupported, and together they cover
// exactly the enum (no stale entry for a removed value, no missing value).
[Test]
public void SupportedAndUnsupported_ArePartition_ForEveryEngine()
{
var all = AllOrders.ToHashSet();
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
IReadOnlySet<PlaybackOrder> supported = PlaybackOrderSupport.SupportedBy(engine);
IReadOnlySet<PlaybackOrder> unsupported = PlaybackOrderSupport.UnsupportedBy(engine);
supported.Intersect(unsupported).ShouldBeEmpty(
$"{engine}: an order is listed as both supported and unsupported");
var union = supported.Concat(unsupported).ToHashSet();
union.ShouldBe(all, ignoreOrder: true,
$"{engine}: supported unsupported does not equal the PlaybackOrder enum");
}
}
// Guards the specific fragility called out in #403: Random is in Block's allow-list, so it must be
// supported by Block (it previously worked only via the switch's coincidental Random fallback).
[Test]
public void Block_Supports_Random()
{
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, PlaybackOrder.Random).ShouldBeTrue();
}
// WeightedShuffle (#70) is Classic-only; the other engines must classify it as unsupported so the
// write-path guards and this matrix agree.
[Test]
public void WeightedShuffle_IsClassicOnly()
{
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, PlaybackOrder.WeightedShuffle)
.ShouldBeTrue();
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
if (engine == SchedulingEngineKind.Classic)
{
continue;
}
PlaybackOrderSupport.IsSupported(engine, PlaybackOrder.WeightedShuffle).ShouldBeFalse(
$"{engine} must not support WeightedShuffle (#70 is Classic-only)");
}
}
}
@@ -1,7 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -434,67 +433,6 @@ public class PlaylistEnumeratorTests
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
}
// #403: an order the playlist engine doesn't handle must be dropped LOUDLY (a warning), not silently.
[Test]
public async Task Test_UnsupportedOrder_Drops_Item_And_Logs_Warning()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var logger = new RecordingLogger();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10), FakeMovie(11)]
},
{
// WeightedShuffle (#70) is Classic-only; the playlist switch has no arm for it.
new PlaylistItem
{
Id = 2,
PlaybackOrder = PlaybackOrder.WeightedShuffle,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 2
},
[FakeMovie(20), FakeMovie(21)]
}
};
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
new CollectionEnumeratorState(),
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None,
logger);
// the unsupported item (20, 21) is dropped; only the chronological item (10, 11) cycles
var items = new List<int>();
for (var i = 0; i < 4; i++)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
items.ShouldContain(10);
items.ShouldContain(11);
items.ShouldNotContain(20);
items.ShouldNotContain(21);
// and it said so, rather than dropping silently
logger.Entries.ShouldContain(
e => e.Level == LogLevel.Warning && e.Message.Contains("not supported by playlist"));
}
private static Movie FakeMovie(int id) => new()
{
Id = id,
@@ -507,27 +445,4 @@ public class PlaylistEnumeratorTests
}
]
};
private sealed class RecordingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception exception,
Func<TState, Exception, string> formatter) =>
Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -66,7 +66,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
},
new List<MediaChapter>(),
new PlayoutBuildWarnings(),
_cancellationToken).Items;
_cancellationToken);
playoutItems.Count.ShouldBe(1);
}
@@ -125,7 +125,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
},
new List<MediaChapter> { new() },
new PlayoutBuildWarnings(),
_cancellationToken).Items;
_cancellationToken);
playoutItems.Count.ShouldBe(1);
}
@@ -192,7 +192,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
},
new PlayoutBuildWarnings(),
_cancellationToken).Items;
_cancellationToken);
playoutItems.Count.ShouldBe(3);
playoutItems[0].MediaItemId.ShouldBe(1);
@@ -284,7 +284,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
},
new PlayoutBuildWarnings(),
_cancellationToken).Items;
_cancellationToken);
playoutItems.Count.ShouldBe(5);
@@ -392,7 +392,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
],
new PlayoutBuildWarnings(),
_cancellationToken).Items;
_cancellationToken);
playoutItems.Count.ShouldBe(5);
@@ -501,7 +501,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
],
new PlayoutBuildWarnings(),
_cancellationToken).Items;
_cancellationToken);
playoutItems.Count.ShouldBe(6);
@@ -611,7 +611,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
},
new PlayoutBuildWarnings(),
_cancellationToken).Items;
_cancellationToken);
playoutItems.Count.ShouldBe(5);
@@ -719,7 +719,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
],
new PlayoutBuildWarnings(),
_cancellationToken).Items;
_cancellationToken);
playoutItems.Count.ShouldBe(5);
@@ -1,263 +0,0 @@
using ErsatzTV.Application.Streaming;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Streaming;
// Verifies the atomic-claim contract of the work-ahead slot pool (ersatztv#536): at most
// `limit` sessions may hold a slot at once, no matter how many race for one simultaneously.
//
// NEGATIVE CONTROL (per the ersatztv#231/#250 lesson — a one-shot Barrier + Task.WhenAll does NOT
// catch this class on this hardware): to prove these tests are non-vacuous, temporarily replace the
// compare-exchange in WorkAheadSlots.TryAcquire with the check-then-act shape this issue fixed —
//
// int current = Volatile.Read(ref _count);
// if (current >= limit) return false;
// Interlocked.Increment(ref _count);
// return true;
//
// — and TryAcquire_ParallelCallers_NeverExceedsLimit must FAIL (badRounds > 0). Do NOT "break" it by
// stubbing `if (true)`: that leaves values assigned-but-never-read, and CS0219 under
// warnings-as-errors fails the build silently, so `dotnet test --no-build` then runs the STALE
// (fixed) dll and the control falsely passes. Always grep the build output for `error CS` first.
[TestFixture]
public class WorkAheadSlotsTests
{
[Test]
public void TryAcquire_BelowLimit_Succeeds()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(2).ShouldBeTrue();
slots.TryAcquire(2).ShouldBeTrue();
slots.TryAcquire(2).ShouldBeFalse();
slots.Count.ShouldBe(2);
}
[Test]
public void TryAcquire_DoesNotConsumeASlotWhenItFails()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(1).ShouldBeTrue();
slots.TryAcquire(1).ShouldBeFalse();
slots.Count.ShouldBe(1);
// the failed attempt must not have leaked a slot: releasing the one real holder frees the pool
slots.Release();
slots.Count.ShouldBe(0);
slots.TryAcquire(1).ShouldBeTrue();
}
[Test]
public void TryAcquire_WithNonPositiveLimit_NeverSucceeds()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(0).ShouldBeFalse();
slots.TryAcquire(-1).ShouldBeFalse();
slots.Count.ShouldBe(0);
}
// The pool is process-wide and never recreated, so a negative count would not self-heal: it would
// permanently admit more than `limit` unthrottled transcodes, with nothing in the logs to find it by.
[Test]
public void Release_WithoutAcquire_ClampsAtZeroAndIsRecorded()
{
var slots = new WorkAheadSlots();
slots.Release();
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(1);
// the budget is intact: a limit of 1 still admits exactly one holder, not two
slots.TryAcquire(1).ShouldBeTrue();
slots.TryAcquire(1).ShouldBeFalse();
}
[Test]
public void UnbalancedReleases_IsZeroUnderCorrectUse()
{
var slots = new WorkAheadSlots();
slots.TryAcquire(1).ShouldBeTrue();
slots.Release();
slots.UnbalancedReleases.ShouldBe(0);
}
// Hammers the acquire race over many rounds rather than a single simultaneous burst: the
// read->increment window is far too narrow to collide reliably when threads release once.
[Test]
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void TryAcquire_ParallelCallers_NeverExceedsLimit(int limit)
{
const int threads = 8;
const int rounds = 20_000;
var slots = new WorkAheadSlots();
var winners = 0;
var badRounds = 0;
using var startRound = new Barrier(threads);
using var endRound = new Barrier(
threads,
_ =>
{
int held = Volatile.Read(ref winners);
if (held > limit || held != slots.Count)
{
Interlocked.Increment(ref badRounds);
}
// reset for the next round: release every slot claimed this round
for (var i = 0; i < held; i++)
{
slots.Release();
}
Volatile.Write(ref winners, 0);
});
var workers = new Thread[threads];
for (var t = 0; t < threads; t++)
{
workers[t] = new Thread(() =>
{
for (var r = 0; r < rounds; r++)
{
startRound.SignalAndWait();
if (slots.TryAcquire(limit))
{
Interlocked.Increment(ref winners);
}
endRound.SignalAndWait();
}
});
workers[t].Start();
}
foreach (Thread worker in workers)
{
worker.Join();
}
badRounds.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(0);
}
// §1 (ersatztv#539): an unbalanced Release() must never publish a NEGATIVE count, even
// transiently. The pre-#539 shape decremented FIRST (0 -> -1) and clamped afterwards, so a
// concurrent TryAcquire(limit) could read the -1, see "-1 < limit" as phantom room, and admit a
// holder the budget doesn't have (the second acquirer then reads 0 and admits another). Because
// TryAcquire is the sole, CAS-guarded increment path, a count that is provably never negative is
// exactly what forecloses that over-admit. Here one thread hammers unbalanced releases on an
// empty pool while readers sample the count; none may ever observe a value below zero.
//
// NEGATIVE CONTROL (per the class-level ersatztv#231/#250 lesson) — revert Release() to its
// pre-#539 decrement-first body to prove this test is non-vacuous:
//
// if (Interlocked.Decrement(ref _count) >= 0) return true;
// Interlocked.Increment(ref _unbalancedReleases);
// while (true) { int c = Volatile.Read(ref _count);
// if (c >= 0 || Interlocked.CompareExchange(ref _count, 0, c) == c) return false; }
//
// — and this test must FAIL (sawNegative > 0): the readers catch the transient -1. Do NOT stub
// `if (true)`: that leaves values assigned-but-never-read, and CS0219 under warnings-as-errors
// fails the build silently so `dotnet test --no-build` runs the STALE dll and the control falsely
// passes. Always grep the build output for `error CS` first.
[Test]
public void Release_Unbalanced_NeverPublishesNegativeCount()
{
const int releases = 2_000_000;
const int readers = 4;
var slots = new WorkAheadSlots();
var sawNegative = 0;
var done = false;
var readerThreads = new Thread[readers];
for (var i = 0; i < readers; i++)
{
readerThreads[i] = new Thread(() =>
{
while (!Volatile.Read(ref done))
{
if (slots.Count < 0)
{
Interlocked.Increment(ref sawNegative);
}
}
});
readerThreads[i].Start();
}
// every release finds the pool empty, so every one is unbalanced
for (var r = 0; r < releases; r++)
{
slots.Release();
}
Volatile.Write(ref done, true);
foreach (Thread reader in readerThreads)
{
reader.Join();
}
sawNegative.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(releases);
}
// The release path is the fiddly half: a slot freed by its owner must become available again,
// and a burst of acquire/release cycles must not drift the count in either direction.
[Test]
public void AcquireAndRelease_UnderContention_LeavesNoLeakedOrDoubleFreedSlots()
{
const int threads = 8;
const int rounds = 20_000;
const int limit = 3;
var slots = new WorkAheadSlots();
var overLimit = 0;
var live = 0;
var workers = new Thread[threads];
for (var t = 0; t < threads; t++)
{
workers[t] = new Thread(() =>
{
for (var r = 0; r < rounds; r++)
{
if (!slots.TryAcquire(limit))
{
continue;
}
if (Interlocked.Increment(ref live) > limit)
{
Interlocked.Increment(ref overLimit);
}
Interlocked.Decrement(ref live);
slots.Release();
}
});
workers[t].Start();
}
foreach (Thread worker in workers)
{
worker.Join();
}
overLimit.ShouldBe(0);
slots.Count.ShouldBe(0);
slots.UnbalancedReleases.ShouldBe(0);
}
}
@@ -38,10 +38,7 @@ public record ChannelDetailResponseModel(
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg,
int[] GraphicsElementIds,
// Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel.
ChannelHealthResponseModel Health);
bool ShowInEpg);
// Wire-compatible mirror of the Application-layer ArtworkContentTypeModel (which lives in
// ErsatzTV.Application and therefore can't be referenced from Core). Same serialized shape the
@@ -16,9 +16,6 @@ public record ChannelGuideProgrammeResponseModel(
public record ChannelGuideChannelResponseModel(
string Number,
string Name,
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
string? Logo,
List<ChannelGuideProgrammeResponseModel> Programmes);
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
@@ -1,36 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
// Server-derived per-channel health. The SPA and MCP both read Status/Faults rather than deriving a
// verdict themselves (the api.channel-health-object decision, superseding #72's raw-fact-only stance).
// Faults are plain strings (see ChannelFault) not a C# enum, to keep the OpenAPI schema simple — the
// SPA hand-maintains its own union, matching the ChannelPreviewAvailability pattern.
public record ChannelHealthResponseModel(
// One of ChannelHealthStatus's values.
string Status,
// The specific fault classes that fired (each a ChannelFault value); empty when Healthy/Unknown.
string[] Faults,
// Retained #72 fact: total playouts (mirror-aware).
int PlayoutCount,
// Count of upcoming built items pointing at a FileNotFound/Unavailable MediaItem; 0 when none.
int BrokenSourceItemCount);
public static class ChannelHealthStatus
{
public const string Healthy = "Healthy";
public const string Problems = "Problems";
public const string Unknown = "Unknown";
}
public static class ChannelFault
{
public const string NoPlayout = "NoPlayout";
public const string NeverBuilt = "NeverBuilt";
public const string BuildFailed = "BuildFailed";
public const string EmptyUpcoming = "EmptyUpcoming";
public const string BrokenSource = "BrokenSource";
}
// Per-playout upcoming-item aggregate (Task 2's repository query fills this). Lives in Core so both
// the repository interface (Core) and Mapper (Application) can reference it.
public readonly record struct PlayoutUpcoming(int TotalUpcoming, int BrokenUpcoming);
@@ -1,32 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
/// <summary>Server-declared browser-preview capability for a channel.</summary>
/// <remarks>
/// The SPA renders and acts on this; it never derives preview eligibility itself (see
/// docs/decisions.md, api.healthcheck-remediation-dto for the same pattern). Deriving it in the
/// SPA would mean keying behavior off the human-readable StreamingMode label.
/// </remarks>
public record ChannelPreviewResponseModel(
// One of ChannelPreviewAvailability's values. A plain string (not a C# enum), which keeps the
// OpenAPI schema simple, but the tradeoff is that the generated TypeScript types this as a bare
// `string`, not a literal union — the SPA hand-maintains its own `ChannelPreviewAvailability`
// union (web/src/api/channels.ts) and narrows against it, rather than getting one for free.
string Availability,
// Rooted, directly-usable HLS manifest URL; null when Availability is Unavailable.
string? ManifestUrl,
// Human-readable reason; non-null only when Availability is Unavailable.
string? UnavailableReason);
public static class ChannelPreviewAvailability
{
/// <summary>The channel's configured mode is browser-playable; preview exercises the real pipeline.</summary>
public const string Available = "Available";
/// <summary>Configured for Transport Stream; preview must force an HLS session and is content-only.</summary>
public const string ForcedHlsOnly = "ForcedHlsOnly";
/// <summary>Preview cannot run at all (IPTV JWT auth is enabled and the SPA cannot mint a token).</summary>
public const string Unavailable = "Unavailable";
}
@@ -1,5 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
using Newtonsoft.Json;
namespace ErsatzTV.Core.Api.Channels;
@@ -17,14 +16,4 @@ public record ChannelResponseModel(
string StreamingMode,
bool IsEnabled,
bool ShowInEpg,
int PlayoutCount,
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
string? Logo,
// Server-declared browser-preview capability; see ChannelPreviewResponseModel.
ChannelPreviewResponseModel Preview,
// Immutable creation-provenance (auto-tuned vs user-created). Raw fact; the SPA decides how to render it.
// Unknown for rows created before the origin column existed (never back-filled).
ChannelOrigin Origin,
// Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel.
ChannelHealthResponseModel Health);
int PlayoutCount);
@@ -1,20 +0,0 @@
namespace ErsatzTV.Core.Api.Channels;
// The "clear to none" signal for POST /api/v1/channels/from-lineup (#135). For these
// template-inheritable advanced fields a null/omitted override means INHERIT the template value;
// naming the field here forces it to NONE on the new channel even when the template sets one.
// Omitting the field entirely keeps the historical omitted=inherit behavior stable for existing
// clients. Sending both a set value and a clear for the same field is a validation error (see
// CreateChannelFromLineupHandler). Lives in Core so the OpenAPI string-enum scan (Startup
// UseStringEnumSchemas) renders it as a string enum, matching every sibling advanced-options enum.
public enum CreateChannelFromLineupClearField
{
Watermark,
FallbackFiller,
PreRollFiller,
MidRollFiller,
PostRollFiller,
PreferredAudioLanguage,
PreferredAudioTitle,
PreferredSubtitleLanguage
}

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