Compare commits

..
Author SHA1 Message Date
timothyandClaude Opus 4.8 48f7ceff62 fix(327): validate playlist name on rename (reject empty/whitespace/too-long)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m43s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m11s
fixes #327

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:46:26 +02:00
677 changed files with 5238 additions and 155601 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 -150
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,41 +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 or the exemption is unsafe. Gitea caps this
# endpoint at 50 rows per page and silently ignores a larger `limit` (verified: PR #619 has 194
# changed files and `?limit=100` returns exactly 50), so the previous single-page read could see 50
# docs files, miss the code in positions 51+, and exempt a PR that is not remotely docs-only.
# Page until a short page proves the end; anything else leaves `files_complete=no`, which withholds
# the exemption and falls through to the full gate (ersatztv#622).
files=""; files_complete=no; page=1
while [ "$page" -le 40 ]; do
raw=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=50&page=$page")
# A transport/parse failure must not look like a legitimate short final page: `gq` returns empty
# on any error, which counts as zero rows and would set files_complete=yes over a PARTIAL list —
# failing OPEN into the exemption.
#
# Checking only the top-level type leaves the same hole one level down: `[{}]` is a valid array
# whose rows carry no `filename`, so it yields no paths, looks like a short page, and completes
# the enumeration from a partial list. Require every row to carry a non-empty string `filename`
# (an empty array is still valid — that is a genuine end-of-pagination). This also rejects arrays
# of scalars, which would otherwise make the `.filename` extraction below fail under `set -e`.
if ! printf '%s' "$raw" \
| jq -e 'type == "array" and all(.[]; (.filename | type == "string" and length > 0) and (if .status == "renamed" then (.previous_filename | type == "string" and length > 0) else true end))' \
>/dev/null 2>&1; then
files_complete=no; break
fi
# BOTH sides of a rename: Gitea reports a `git mv` as ONE row whose `filename` is the DESTINATION,
# with the source in `previous_filename`. Reading only `filename` would let a PR move code into
# docs/ and claim the docs-only exemption. Page size is measured in ROWS, not paths — one renamed
# row is one row but two paths.
n=$(printf '%s' "$raw" | jq -r 'length')
chunk=$(printf '%s' "$raw" | jq -r '.[] | (.filename // empty), (.previous_filename // empty)')
[ -n "$chunk" ] && files=$(printf '%s\n%s' "$files" "$chunk")
if [ "$n" -lt 50 ]; then files_complete=yes; break; fi
page=$((page + 1))
done
files=$(printf '%s\n' "$files" | grep -v '^$' || true)
if [ "$files_complete" = yes ] && [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; 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
@@ -154,71 +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")
if ! 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
@@ -233,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">
```
+11 -59
View File
@@ -5,55 +5,19 @@ description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Je
# ErsatzTV Channel Management
Host: **jazz (192.168.1.29)**. Prod container `ersatztv` port **8409**; test `ersatztv-test` port
**8410** (tracks `:latest` via Komodo auto-update, daily 03:00 — a same-day validation needs the
manual pull below).
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` (owned by root — use `sudo sqlite3`)
Image: **our fork**, `192.168.1.95:3000/timothy/ersatztv` (`:prod` / `:latest`). Upstream
`ghcr.io/ersatztv/ersatztv` was archived at v26.3.0 and is NOT what runs here.
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
**This section described upstream v26.3.0 and was wrong for the fork — corrected 2026-07-21.**
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
- The **Blazor UI is gone** (#91 phase b). The only UI is the ChicoryTV React SPA at `/app`; legacy
routes 302 there.
- There **is** a full versioned REST API under **`/api/v1`**, write paths included — channels,
collections, schedules, playouts and media sources have CRUD. **Do not hand-edit SQLite for
something the API can do.** The DB-scripting recipes below survive only for gaps with no endpoint.
- Controllers stay thin and delegate to MediatR handlers; the SPA talks to `/api/v1` only.
- Authoritative endpoint list: `docs/endpoint-index.md` (generated) + `docs/api-conventions.md`.
Prefer those over any list in this file — a hand-maintained copy drifts.
## REST API access (auth-gated — read before curling)
Calls need **`X-Api-Key`** (machine clients) or a browser session. An unauthenticated call returns a
401 JSON body that is easy to mistake for real data — see the silent-401 trap in Gotchas.
The key file is **root-owned `0600`**, so `cat` as `timothy` fails *silently* and yields an empty
header. Read it with `sudo`, inline, so the value is never printed:
```bash
# prod (8409); test is identical with .../ersatztv-test/api.key and port 8410
ssh timothy@192.168.1.29 'K=$(sudo -n cat /home/timothy/downloadswarm/ersatztv/api.key); \
curl -s -H "X-Api-Key: $K" http://localhost:8409/api/v1/channels'
```
Settings live under `/api/v1/settings/*``settings/ffmpeg` (`workAheadSegmenterLimit`,
`qsvExtraHardwareFrames`) and `settings/logging` (`streamingMinimumLogLevel`). Note the order: it is
`settings/ffmpeg`, **not** `ffmpeg/settings`.
Refresh test to the newest `:latest` without waiting for 03:00 — scope it to the service, since a
bare `up -d` would recreate everything else in the compose project:
```bash
D=/etc/komodo/stacks/ersatztv/docker/jazz/stacks/ersatztv
docker compose -f $D/compose.yaml pull ersatztv-test
docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
```
The unversioned `/api/*` endpoints below predate the `/api/v1` surface — verify one against
`docs/endpoint-index.md` before relying on it.
## REST API
```bash
# Via docker exec
@@ -184,25 +148,13 @@ After creating: `POST /api/channels/{number}/playout/reset`
## Gotchas
- DB owned by root — always use `sudo sqlite3`
- **The api.key file is root-owned too, and an unsudo'd read fails SILENTLY.** `cat` returns nothing,
the header goes out empty, and the 401 body parses as a dict — so a naive script reports "0
channels" rather than an auth error. If a query returns a suspiciously empty result, check auth
before believing it. (Cost a wrong reading on 2026-07-21.)
- WAL mode: reads OK while running, stop container for writes
- ~~No REST API for channel/collection/schedule CRUD~~**false since the fork's `/api/v1`**; use the
API, not DB scripting, wherever an endpoint exists
- **A container's OCI labels lie about what is running** — they are inherited from the linuxserver
base image (they claimed `2026-06-27` on an image built minutes earlier). To prove which build is
live, compare `docker inspect <c> --format '{{.Image}}'` to the registry's `Docker-Content-Digest`
for that tag
- **Container log lines carry a LOCAL-time bracket (`[18:48:13 DBG]`) while `docker logs -t` emits
UTC**, so `--since` windows silently mis-slice. For before/after measurements capture by line
offset instead (`wc -l` before, `tail -n +N` after)
- 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.) — MUST insert into subtype table
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
- `/api/health` predates the Blazor removal; verify the API with an authenticated `/api/v1/channels` instead
- `/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
+1 -16
View File
@@ -97,22 +97,7 @@ Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/Virt
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
- **Music videos are typed `MusicVideo`, NOT `Movie`** (corrected 2026-07-21, ersatztv#177). The old
"typed as Movie" note described a deliberate DB reclassification workaround that existed only because
ErsatzTV could not consume `MusicVideo` items — ersatztv#42 shipped that sync, so the workaround's
premise is gone. Verified live: the `Music Videos` library (`/data/music`, collection type
`musicvideos`) holds 1437 items typed `MusicVideo` and **zero** typed `Movie`. Query with
`includeItemTypes=MusicVideo`. (Reclassification to `Movie` may still apply to concert/standup content
in the `movies`/`mixed` libraries — that is a different set; see the server-management jellyfin skill.)
- **`Album` is not an `ItemFields` value.** It is a plain `BaseItemDto` property serialized whenever set,
so it comes back regardless of the `fields=` query param — do NOT add it to `fields` (verified: 111 of
1437 music videos returned `Album` with `fields=Path` alone). Contrast `Genres`/`People`/`Chapters`,
which ARE `ItemFields` and must be requested. Check the enum before extending `fields`.
- **`IndexNumber` is the track number; `ParentIndexNumber` is the disc/season axis.** Frequency misleads
here — on the live music video library `ParentIndexNumber` is populated on 66 items vs 4 for
`IndexNumber`, but where both exist `ParentIndexNumber` is `1` while `IndexNumber` holds the real
ordinal, and where only `ParentIndexNumber` exists it is a collection grouping tracking the album
(`Glastonbury: 2022` -> 230). `AlbumId` is always null on these items.
- Music 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
+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."
-189
View File
@@ -1,189 +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 three checks are pure `checkout + git diff` gates: they carry no
# `container:`, run on the `small` lane (git-only, 1 GiB; server-management#639), and are PR-only.
# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags —
# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because
# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the
# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase
# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened
# the tag's overall commit status even though the release built, scanned, and deployed fine
# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical
# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens
# in the dispatch window before any step or `if:`-skip runs.
#
# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow
# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole
# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders
# (they also encode "these steps need a PR base_ref"; harmless given the trigger).
#
# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of
# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/
# api-docs/format) that remain there. None of these three are required checks — branch protection
# requires only `Build & test (.NET)` and `EF migration integrity` — so relocating them (which
# changes their status-context prefix from "Build ErsatzTV Image / …" to "PR Gates / …") does not
# affect merges. See docs/ci-cd.md -> "PR gates workflow".
on:
pull_request:
# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but
# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash.
defaults:
run:
shell: bash
# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel.
concurrency:
group: ersatztv-pr-gates-${{ github.ref }}
cancel-in-progress: true
jobs:
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -eq 0 ]; then
echo "::error::No ersatztv-ci pin found in docker-build.yml at all. Every container: job must pin ersatztv-ci:<7-char-sha>; if the grep pattern stopped matching, fix it here too (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
# LENGTH is a separate invariant from CORRECTNESS, and only this check covers it
# (ersatztv#594). The resolve + staleness checks below compare RESOLVED shas, so a
# 8/9/10-char abbreviation of the right commit sails through them green — while
# matching NO tag in the registry, because ci-image.yml tags with
# `git rev-parse --short HEAD` under `fetch-depth: 1`, which always yields exactly 7.
# The failure would otherwise surface far downstream as all five `container:` jobs
# dying at image-pull with `manifest unknown`, which reads like a registry outage.
# This is an easy mistake to make: the natural local command prints 8 chars.
#
# Deliberately a literal 7, not a derived `git rev-parse --short=7`: in this full
# clone git may widen an ambiguous abbreviation past 7, which would demand a pin
# ci-image.yml can never publish — the exact clone-depth asymmetry noted above.
# `${expected:0:7}` is plain string truncation, so it is safe to suggest.
#
# ESCAPE HATCH, if you are ever stuck: this makes 7 mandatory, so if `${expected:0:7}` ever
# became an AMBIGUOUS prefix (two objects sharing it), the resolve check below would fail
# and a longer pin — previously the workaround — is now rejected here first. There is no
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
# empirical property of today's shallow clone, not an enforced invariant. Making the
# publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of
# scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs
# the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') —
# ci-image.yml's push trigger has no branches: filter, so a feature branch does publish.
if [ "${#pins[0]}" -ne 7 ]; then
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
# supersedes/superseded-by links, no rationale-prose rewrite without 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
-193
View File
@@ -1,193 +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)") 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.
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
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:
- name: Classify the PR and post the review-verdict status
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
BASE_URL: ${{ github.server_url }}/api/v1
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
SHA: ${{ github.event.pull_request.head.sha }}
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"
# 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" "$@"; }
# --- Already decided for THIS sha? Never overwrite a real verdict. -------------------
# A human/agent verdict for this exact head may already exist (the reviewer ran the
# script before this workflow finished, or a rerun). Re-posting `pending` over it would
# un-approve a reviewed head and stall the PR.
#
# Read the COMBINED endpoint, not `/statuses/{sha}`: the latter returns one row per
# status 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. Missing it here is NOT harmless — we would post
# `pending` (or worse, an exemption `success`) over a real human verdict. The combined
# endpoint returns latest-per-context, which is both what we mean and ~11 rows.
#
# An unreadable/unparseable response must NOT be read as "no verdict exists": fail the
# job WITHOUT posting anything, so a transient API error can never overwrite a verdict.
statusjson=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || statusjson=""
if ! printf '%s' "$statusjson" | 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
existing=$(printf '%s' "$statusjson" \
| jq -r --arg c "$CONTEXT" '[.statuses[] | select(.context == $c)] | first | .status // ""')
if [ "$existing" = "success" ] || [ "$existing" = "failure" ]; then
echo "${CONTEXT} is already '${existing}' on ${SHA:0:7} — leaving the existing verdict alone."
exit 0
fi
# --- Changed files: PAGE to exhaustion, and fail CLOSED if we cannot. ----------------
# Gitea caps this endpoint at 50 rows per page and SILENTLY IGNORES a larger `limit`
# (verified: PR #619 has 194 changed files and `?limit=100` returns exactly 50). A
# single-page read is therefore a silent false negative: a protected path sitting at
# position 51+ would simply not be seen, and a bot-authored PR that edits the gate could
# exempt itself from the gate. Page until a short page proves the end.
PAGE_SIZE=50
MAX_PAGES=40 # 2000 files; beyond this we refuse rather than guess
files=""
page=1
complete=no
while [ "$page" -le "$MAX_PAGES" ]; do
raw=$(gh "$BASE_URL/repos/$REPO/pulls/$PR/files?limit=${PAGE_SIZE}&page=${page}") || raw=""
# A transport/parse failure must NOT masquerade as a legitimate short final page.
# Empty output counts as zero rows, which would otherwise read as "end of list" and set
# complete=yes over a PARTIAL enumeration — failing OPEN at the exact point this guard
# exists to fail closed.
#
# Validating only the TOP-LEVEL type is not enough: a page like `[{}]` is a well-formed
# array whose rows carry no `filename`, so it contributes no paths, counts as a short
# page, and completes the enumeration from a partial list — the same failure one level
# down. Require every row to carry a non-empty string `filename`; an empty array stays
# valid, since that is what a genuine end-of-pagination looks like.
if ! printf '%s' "$raw" \
| jq -e 'type == "array" and all(.[]; (.filename | type == "string" and length > 0) and (if .status == "renamed" then (.previous_filename | type == "string" and length > 0) else true end))' \
>/dev/null 2>&1; then
complete=no; break
fi
# Page-size termination is measured in ROWS; the path set collects BOTH sides of a
# rename. Gitea reports a `git mv` as ONE row whose `filename` is the DESTINATION, with
# the source only in `previous_filename` — so reading `filename` alone lets a PR move a
# protected file INTO docs/ and pass as docs-only (verified live:
# `.gitea/workflows/renovate.yml` -> `docs/innocuous-note.md` showed no protected path).
# One renamed row thus contributes ONE to `n` and TWO to the path set, which is why
# these two counts are deliberately computed differently.
n=$(printf '%s' "$raw" | jq -r 'length')
chunk=$(printf '%s' "$raw" | jq -r '.[] | (.filename // empty), (.previous_filename // empty)')
[ -n "$chunk" ] && files=$(printf '%s\n%s' "$files" "$chunk")
if [ "$n" -lt "$PAGE_SIZE" ]; then complete=yes; break; fi
page=$((page + 1))
done
files=$(printf '%s\n' "$files" | grep -v '^$' || true)
count=$(printf '%s\n' "$files" | grep -c . || true)
echo "Changed files (${count}, complete=${complete}, pages=${page}):"
printf '%s\n' "$files" | sed 's/^/ /'
exempt=no
reason=""
if [ "$complete" != yes ]; then
reason="could not enumerate the changed files exhaustively (stopped at ${count}) — no exemption"
elif [ "${count:-0}" -eq 0 ]; then
reason="no changed files could be read from the API — no exemption"
elif printf '%s\n' "$files" | grep -qE "$PROTECTED"; then
reason="touches a protected path (gate/CI/hooks/scripts/ci-image) — exemptions do not apply"
elif printf '%s\n' "$BOTS" | tr ' ' '\n' | grep -qxF "$AUTHOR"; then
exempt=yes
reason="authored by the '$AUTHOR' bot account and touches no protected path"
elif ! printf '%s\n' "$files" | grep -qvE "$DOCS_ONLY"; then
exempt=yes
reason="docs-only change (no code, no protected path)"
else
reason="awaiting an H10 review verdict for head ${SHA:0:7}"
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}"
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 -17
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,10 +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
+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
+36 -11
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,7 +74,7 @@ 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 (their `review-verdict/h10` required check is auto-passed as a bot PR — unless they touch `.claude/`/`.gitea/`/`.husky/`/`scripts/`/`docker/ci/`, which need a real verdict), the rest are manual. 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)
@@ -61,15 +83,18 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
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
@@ -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);
}
}
@@ -75,33 +75,12 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
.BindT(playlist => ValidateName(request).Map(_ => playlist))
.BindT(playlist => PlaylistNameMustBeUnique(dbContext, playlist, request));
.BindT(playlist => ValidateName(request).Map(_ => playlist));
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
// Issue #458: mirror CreatePlaylistHandler's duplicate-name guard on rename. Uniqueness is scoped
// to the loaded playlist's group (rename cannot move groups) and excludes the playlist itself, so
// a no-op rename to its own name still succeeds. Backstopped by the (PlaylistGroupId, Name) unique
// index; this pre-check turns the common collision into a clean 422 instead of a DbUpdateException.
private static async Task<Validation<BaseError, Playlist>> PlaylistNameMustBeUnique(
TvContext dbContext,
Playlist playlist,
ReplacePlaylistItems request)
{
Option<Playlist> maybeExisting = await dbContext.Playlists
.AsNoTracking()
.FirstOrDefaultAsync(p =>
p.Id != request.PlaylistId && p.PlaylistGroupId == playlist.PlaylistGroupId && p.Name == request.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"A playlist named \"{request.Name}\" already exists in that playlist group")
: Success<BaseError, Playlist>(playlist);
}
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
ReplacePlaylistItems request,
Playlist playlist) =>
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -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,
-1
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,
@@ -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,120 +0,0 @@
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSearchFieldValues, Option<SearchFieldValuesResponseModel>>
{
private const int DefaultLimit = 50;
private const int MaxLimit = 50;
public async Task<Option<SearchFieldValuesResponseModel>> Handle(
GetSearchFieldValues request,
CancellationToken cancellationToken)
{
SearchFieldResponseModel field = SearchFieldCatalog.Fields
.FirstOrDefault(f => f.Name == request.Name);
if (field is null || field.Type != "text")
{
return Option<SearchFieldValuesResponseModel>.None;
}
int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit);
string qLower = (request.Query ?? string.Empty).ToLower();
// in-memory special cases (no DB query needed)
switch (request.Name)
{
case "state":
return new SearchFieldValuesResponseModel(
FilterSortTake(Enum.GetNames<MediaItemState>(), qLower, limit));
case "video_dynamic_range":
return new SearchFieldValuesResponseModel(
FilterSortTake(["hdr", "sdr"], qLower, limit));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
if (request.Name == "content_rating")
{
return new SearchFieldValuesResponseModel(
await GetContentRatingValues(dbContext, qLower, limit, cancellationToken));
}
IQueryable<string> source = GetSource(dbContext, request.Name);
if (source is null)
{
return Option<SearchFieldValuesResponseModel>.None;
}
List<string> values = await source
.Where(v => v != null && v.ToLower().StartsWith(qLower))
.Distinct()
.OrderBy(v => v)
.Take(limit)
.ToListAsync(cancellationToken);
return new SearchFieldValuesResponseModel(values);
}
private static IQueryable<string> GetSource(TvContext dbContext, string name) => name switch
{
"genre" or "show_genre" => dbContext.Set<Genre>().Select(g => g.Name),
"studio" => dbContext.Set<Studio>().Select(s => s.Name),
"director" => dbContext.Set<Director>().Select(d => d.Name),
"writer" => dbContext.Set<Writer>().Select(w => w.Name),
"actor" => dbContext.Actors.Select(a => a.Name),
// entity artists only; free-text music-video/song artist credits are not included (known limitation)
"artist" => dbContext.ArtistMetadata.Select(m => m.Title),
"tag" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId != Tag.NfoCountryTypeId && t.ExternalTypeId != Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"network" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"collection" => dbContext.Collections.Select(c => c.Name),
"video_codec" => dbContext.MediaStreams
.Where(s => s.MediaStreamKind == MediaStreamKind.Video && s.Codec != null)
.Select(s => s.Codec),
"album" => dbContext.MusicVideoMetadata
.Where(m => m.Album != null)
.Select(m => m.Album)
.Concat(dbContext.SongMetadata.Where(m => m.Album != null).Select(m => m.Album)),
_ => null
};
private static async Task<List<string>> GetContentRatingValues(
TvContext dbContext,
string qLower,
int limit,
CancellationToken cancellationToken)
{
List<string> raw = await dbContext.MovieMetadata
.Where(m => m.ContentRating != null)
.Select(m => m.ContentRating)
.Concat(dbContext.ShowMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.OtherVideoMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.RemoteStreamMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Distinct()
.ToListAsync(cancellationToken);
IEnumerable<string> split = raw
.SelectMany(cr => cr.Split('/'))
.Select(cr => cr.Trim())
.Where(cr => !string.IsNullOrEmpty(cr))
.Distinct();
return FilterSortTake(split, qLower, limit);
}
private static List<string> FilterSortTake(IEnumerable<string> values, string qLower, int limit) =>
values
.Where(v => v.ToLower().StartsWith(qLower))
.OrderBy(v => v)
.Take(limit)
.ToList();
}
@@ -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,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,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
}
@@ -36,5 +36,4 @@ public record FFmpegFullProfileResponseModel(
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo,
bool QsvPreferNativeDecoder);
bool DeinterlaceVideo);
@@ -1,4 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Graphics;
public record GraphicsElementResponseModel(int Id, string Name, bool BuiltIn);
public record GraphicsElementResponseModel(int Id, string Name);
@@ -7,7 +7,6 @@ public record PlayoutListItemResponseModel(
int Id,
string ChannelNumber,
string ChannelName,
int ChannelId,
PlayoutScheduleKind ScheduleKind,
string ScheduleName,
TimeSpan? DailyRebuildTime,
@@ -8,7 +8,6 @@ public record PlayoutResponseModel(
PlayoutScheduleKind ScheduleKind,
string ChannelName,
string ChannelNumber,
int ChannelId,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string? ScheduleFile,
@@ -24,7 +23,6 @@ public record PlayoutResponseModel(
PlayoutScheduleKind scheduleKind,
string channelName,
string channelNumber,
int channelId,
ChannelPlayoutMode playoutMode,
string scheduleName,
string? scheduleFile,
@@ -39,7 +37,6 @@ public record PlayoutResponseModel(
scheduleKind,
channelName,
channelNumber,
channelId,
playoutMode,
scheduleName,
scheduleFile,
@@ -13,5 +13,4 @@ public record ProgramScheduleResponseModel(
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute);
FixedStartTimeBehavior FixedStartTimeBehavior);
@@ -1,8 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
/// <summary>
/// Distinct term values for a single text field in the search index, used to power the visual rule
/// builder's facet-value typeahead.
/// </summary>
public record SearchFieldValuesResponseModel(List<string> Values);
@@ -1,8 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Watermarks;
// ImageSource lets a client identify logo-driven presets (the seeded "Channel Bug") without
// matching a user-editable name. Additive under the frozen /api/v1 contract (#286).
public record WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource);
public record WatermarkResponseModel(int Id, string Name);
-5
View File
@@ -25,8 +25,6 @@ public class Channel
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
public List<GraphicsElement> GraphicsElements { get; set; }
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; }
@@ -45,8 +43,5 @@ public class Channel
public ChannelIdleBehavior IdleBehavior { get; set; }
public bool IsEnabled { get; set; }
public bool ShowInEpg { get; set; }
// Immutable creation-provenance (auto-tuned vs user-created); stamped once at insert, never on edit.
public ChannelOrigin Origin { get; set; }
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain;
public class ChannelGraphicsElement
{
public int ChannelId { get; set; }
public Channel Channel { get; set; }
public int GraphicsElementId { get; set; }
public GraphicsElement GraphicsElement { get; set; }
}
-12
View File
@@ -1,12 +0,0 @@
namespace ErsatzTV.Core.Domain;
// How a Channel row came to exist. This is immutable creation-provenance: it records how the channel
// was born and a later user edit never changes it. Unknown is the honest default for rows that predate
// this column — provenance was never recorded for them and is deliberately not back-filled (inferring it
// from the "Channel Lineups" system playlist group is the mislabeling heuristic #414 rejected).
public enum ChannelOrigin
{
Unknown = 0,
UserCreated = 1,
AutoTuned = 2
}

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