Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48f7ceff62 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 += $"&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 += $"&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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+10
-30
@@ -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) < 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 < 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))
|
||||
|
||||
@@ -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>_ => 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 '&'/'<' 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 '&' — 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&<>"");
|
||||
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
|
||||
-144
@@ -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
|
||||
-72
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user