Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97725ac92d |
@@ -1,12 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# design-sync-reminder — single hook, both directions (#388). Keeps the Claude Design project
|
||||
# (`ChicoryTV Design System`, eb3b6122 / local mirror `design-system/`) in step with the shipped
|
||||
# SPA. Trigger is PURELY MECHANICAL: "touching the UI" == a file matching UI_RE below. No prompt
|
||||
# keyword guessing. Wired to two boundaries:
|
||||
#
|
||||
# start (PreToolUse / Write|Edit) — the FIRST time this session edits a UI file, remind to PULL
|
||||
# the current design from Claude Design first.
|
||||
# finish (Stop) — if the working tree actually changed a UI file, remind to
|
||||
# MIRROR/PUSH the change back before wrapping up.
|
||||
#
|
||||
# UI_RE is the one place the "what counts as UI" fileset is defined: SPA .tsx/.css under web/src
|
||||
# (test files excluded). Widen it here if the design surface grows.
|
||||
#
|
||||
# Fail-open: any parse trouble / non-match → emit nothing, exit 0. Throttled once per session per
|
||||
# phase so it informs without nagging. DesignSync runs only from the main session (docs/design-sync.md).
|
||||
# This is a reminder, never a hard gate — `start` only injects context; `finish` is a one-shot Stop nudge.
|
||||
set -euo pipefail
|
||||
|
||||
UI_RE='(^|/)web/src/.*\.(tsx|css)$'
|
||||
TEST_RE='\.test\.(tsx|ts)$'
|
||||
|
||||
phase="${1:-}"
|
||||
input=$(cat)
|
||||
me=$(printf '%s' "$input" | jq -r '.session_id // "nosess"' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
[ -z "$cwd" ] && cwd="$PWD"
|
||||
marker="${TMPDIR:-/tmp}/ctv-designsync-${phase}-${me}"
|
||||
|
||||
case "$phase" in
|
||||
start)
|
||||
fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null || true)
|
||||
[ -z "$fp" ] && exit 0
|
||||
printf '%s' "$fp" | grep -qE "$TEST_RE" && exit 0 # skip test files
|
||||
printf '%s' "$fp" | grep -qE "$UI_RE" || exit 0 # not a UI file → nothing
|
||||
[ -f "$marker" ] && exit 0
|
||||
: > "$marker" 2>/dev/null || true
|
||||
read -r -d '' MSG <<'EOF' || true
|
||||
[design-sync #388] About to edit a ChicoryTV SPA UI file. The `design-system/` prototypes mirror the Claude Design project (eb3b6122). If you're changing how a screen LOOKS, first PULL its current prototype from Claude Design so you start from the live design (docs/design-sync.md, pull = DesignSync list_files/get_file → design-system/, incremental). You'll be reminded to MIRROR the change back when the task finishes. DesignSync runs only from the main session.
|
||||
EOF
|
||||
jq -n --arg m "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$m}}'
|
||||
exit 0
|
||||
;;
|
||||
finish)
|
||||
# Did this turn actually change a UI file? (tracked diff vs HEAD + untracked, minus tests)
|
||||
changed=$( { git -C "$cwd" diff --name-only HEAD 2>/dev/null; git -C "$cwd" ls-files --others --exclude-standard 2>/dev/null; } | grep -vE "$TEST_RE" | grep -E "$UI_RE" || true )
|
||||
[ -z "$changed" ] && exit 0
|
||||
[ -f "$marker" ] && exit 0
|
||||
: > "$marker" 2>/dev/null || true
|
||||
n=$(printf '%s\n' "$changed" | sed '/^$/d' | wc -l | tr -d ' ')
|
||||
reason="[design-sync #388] This task changed ${n} SPA UI file(s) under web/src. Before wrapping up, MIRROR the visual change into the matching design-system/templates/chicorytv-admin/*.jsx prototype and push it to Claude Design (eb3b6122) in this same session, per docs/design-sync.md — so the design system does not drift from prod. If you already synced, or are deliberately deferring the mirror (say why), just note it and stop. DesignSync runs only from the main session. This one-shot reminder won't fire again this session."
|
||||
jq -n --arg r "$reason" '{decision:"block",reason:$r}'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse / Bash — after a successful `git worktree add`, stamp the new worktree with
|
||||
# this session's id (.claude-worktree-owner) so pretooluse-worktree-guard.sh (H7) can tell
|
||||
# a sibling worktree another session created apart from this session's own.
|
||||
# Fail-safe: any parse trouble → do nothing (the guard stays fail-open without a marker).
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
me=$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)
|
||||
|
||||
printf '%s' "$cmd" | grep -qE 'git[[:space:]]+worktree[[:space:]]+add\b' || exit 0
|
||||
[ -z "$me" ] && exit 0
|
||||
[ -z "$cwd" ] && cwd="$PWD"
|
||||
|
||||
# Extract the <path> arg of `git worktree add [flags] <path> [<commit-ish>]`.
|
||||
# Skip flags; skip the values of the value-taking flags (-b/-B/--reason). Worktree paths
|
||||
# in this repo have no spaces, so whitespace tokenization is safe.
|
||||
add_args=$(printf '%s' "$cmd" | sed -E 's/.*git[[:space:]]+worktree[[:space:]]+add[[:space:]]+//')
|
||||
path=""
|
||||
skip=0
|
||||
for tok in $add_args; do
|
||||
if [ "$skip" = 1 ]; then skip=0; continue; fi
|
||||
case "$tok" in
|
||||
-b|-B|--reason) skip=1; continue ;;
|
||||
--) continue ;;
|
||||
-*) continue ;;
|
||||
*) path=$(printf '%s' "$tok" | tr -d '"'"'"''); break ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$path" ] && exit 0
|
||||
case "$path" in /*) abs="$path" ;; *) abs="$cwd/$path" ;; esac
|
||||
[ -d "$abs" ] || exit 0
|
||||
# Don't clobber a marker a different session already planted.
|
||||
[ -f "$abs/.claude-worktree-owner" ] && exit 0
|
||||
printf '%s\n' "$me" > "$abs/.claude-worktree-owner" 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# H13 (ersatztv#416 session) — refuse to push when a file in the pushed diff still has UNCOMMITTED
|
||||
# changes in the working tree or index. That is the "I left part of my intended change behind"
|
||||
# failure: a fix edited into the working file but never committed (e.g. after a `git reset --soft`
|
||||
# that re-staged a stale index) gets pushed WITHOUT the fix — while local tests and a working-tree
|
||||
# review both see the fix that never shipped. This bit the #416 session: a `--no-renames` review fix
|
||||
# lived only in the working tree, so the pushed commit, CI, and the first re-review each saw a
|
||||
# different tree, and a PR went out still carrying the bug the review had "confirmed" fixed.
|
||||
#
|
||||
# Scope is deliberately PRECISE to keep false positives near zero: it blocks only when a dirty
|
||||
# tracked file is ALSO part of this branch's diff vs origin/main. Unrelated uncommitted scratch in a
|
||||
# file the push doesn't touch is fine; untracked files are ignored.
|
||||
#
|
||||
# Fail-OPEN on anything we can't decide (a git pre-push hook has no "ask"): not a git repo, offline /
|
||||
# no origin/main, HEAD unresolved -> allow. Deliberate escape: ETV_ALLOW_DIRTY_PUSH=1.
|
||||
set -uo pipefail
|
||||
|
||||
[ "${ETV_ALLOW_DIRTY_PUSH:-}" = "1" ] && exit 0
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Files with uncommitted changes vs HEAD — unstaged AND staged-but-uncommitted, tracked only.
|
||||
dirty="$( { git diff --name-only; git diff --cached --name-only; } 2>/dev/null | sort -u )"
|
||||
[ -z "$dirty" ] && exit 0 # clean tree -> nothing to guard
|
||||
|
||||
# The set of files this branch introduces vs origin/main (the "pushed diff"). Best-effort fetch;
|
||||
# if origin/main is unavailable we cannot scope precisely -> fail open rather than over-block.
|
||||
git fetch origin main --quiet 2>/dev/null || exit 0
|
||||
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
|
||||
pushed="$( git diff --name-only "origin/main...HEAD" 2>/dev/null | sort -u )"
|
||||
[ -z "$pushed" ] && exit 0
|
||||
|
||||
# Intersection: dirty files that are part of the pushed diff.
|
||||
both="$( comm -12 <(printf '%s\n' "$dirty") <(printf '%s\n' "$pushed") )"
|
||||
[ -z "$both" ] && exit 0
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
|
||||
echo "husky - push blocked (H13): '$branch' has UNCOMMITTED changes to file(s) that are part of"
|
||||
echo " what you're pushing — the pushed commit does NOT match your working tree, so a local fix"
|
||||
echo " or review may be shipping without its change (the #416 index/worktree trap):"
|
||||
printf '%s\n' "$both" | sed 's/^/ /'
|
||||
echo " Commit them (or 'git checkout --' to discard), then push. If the difference is intentional"
|
||||
echo " and unrelated, bypass with: ETV_ALLOW_DIRTY_PUSH=1 git push"
|
||||
exit 1
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Husky pre-push backstop for ersatztv#303 H6 — the fast-forward-to-main path the Claude merge
|
||||
# hook (pretooluse-merge-consent.sh) can't see. Reads git's pre-push ref lines on stdin; for a push
|
||||
# to main it scans the pushed commits for a Gitea close-keyword (`fixes #N`), and if the linked
|
||||
# issue's "## Done-when" checklist still has unticked boxes it BLOCKS the push.
|
||||
#
|
||||
# A git hook has no interactive "ask", so this is deliberately fail-OPEN: it only blocks when it can
|
||||
# positively prove an unticked box (creds present, issue fetched, non-docs change). No creds, Gitea
|
||||
# unreachable, docs-only diff, or no linked issue -> allow (a loud warning at most). The authoritative
|
||||
# gate is the merge hook; this just catches a direct `git push origin main`.
|
||||
#
|
||||
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
|
||||
set -euo pipefail
|
||||
|
||||
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
|
||||
refs=$(cat || true)
|
||||
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
|
||||
|
||||
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
|
||||
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
|
||||
exit 0 # can't verify -> fail-open (the merge hook is the real gate)
|
||||
fi
|
||||
gq() {
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$1" 2>/dev/null || true
|
||||
else
|
||||
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$1" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
zero=0000000000000000000000000000000000000000
|
||||
blocked=""
|
||||
while read -r localref localsha remoteref remotesha; do
|
||||
[ "$remoteref" = "refs/heads/main" ] || continue
|
||||
[ "$localsha" = "$zero" ] && continue # branch deletion
|
||||
# Commit range being pushed. New branch (remotesha all-zero) -> just the tip, don't rescan history.
|
||||
if [ "$remotesha" = "$zero" ]; then range="$localsha -1"; else range="$remotesha..$localsha"; fi
|
||||
msgs=$(git log --format='%B' $range 2>/dev/null || true)
|
||||
issues=$(printf '%s' "$msgs" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
||||
[ -n "$issues" ] || continue
|
||||
|
||||
# Docs-only exemption over the pushed range.
|
||||
changed=$(git diff --name-only $range 2>/dev/null || true)
|
||||
if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
|
||||
continue
|
||||
fi
|
||||
|
||||
for n in $issues; do
|
||||
ibody=$(gq "repos/timothy/ersatztv/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
|
||||
[ -n "$ibody" ] || continue # can't fetch -> fail-open
|
||||
unchecked=$(printf '%s\n' "$ibody" | awk '
|
||||
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
|
||||
grab && /^##[[:space:]]/ {grab=0}
|
||||
grab {print}' | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
|
||||
if [ "${unchecked:-0}" -gt 0 ]; then
|
||||
blocked="${blocked} - issue #$n has $unchecked unticked ## Done-when box(es)\n"
|
||||
fi
|
||||
done
|
||||
done <<EOF
|
||||
$refs
|
||||
EOF
|
||||
|
||||
if [ -n "$blocked" ]; then
|
||||
printf 'husky - H6 merge-consent (ersatztv#303): push to main BLOCKED\n' >&2
|
||||
printf '%b' "$blocked" >&2
|
||||
printf 'Finish/tick every Done-when criterion (incl. adversarial review) first, or push a docs-only change.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# H11 (ersatztv#311) — refuse to push a branch that is BEHIND origin/main: rebase first, do NOT
|
||||
# merge main in. A merge commit drags in files you never touched (e.g. the ~2500 legacy-BOM .cs),
|
||||
# which then trips the pre-commit `dotnet format` hook on code that isn't yours (the #309 session).
|
||||
# Rebasing keeps your diff to exactly what you changed.
|
||||
#
|
||||
# Fail-OPEN on anything we can't decide (a git pre-push hook has no "ask"): not a git repo,
|
||||
# offline / fetch fails, no origin/main, HEAD unresolved -> allow the push. The only hard block is
|
||||
# a positively-proven "behind origin/main". Deliberate exception: ETV_SKIP_REBASE_CHECK=1.
|
||||
set -uo pipefail
|
||||
|
||||
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Best-effort fetch of the latest main; offline / no network -> don't block.
|
||||
git fetch origin main --quiet 2>/dev/null || exit 0
|
||||
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Pushing main itself, or a branch already rebased on top of it, means origin/main is an ANCESTOR
|
||||
# of HEAD -> nothing to rebase, allow.
|
||||
if git merge-base --is-ancestor origin/main HEAD 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
behind=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo '?')
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
|
||||
echo "husky - push blocked (H11): '$branch' is behind origin/main by $behind commit(s)."
|
||||
echo " Rebase before pushing — do NOT merge main in (a merge drags in files you didn't touch,"
|
||||
echo " e.g. legacy-BOM .cs, and trips the format hook on code that isn't yours):"
|
||||
echo " git fetch origin main && git rebase origin/main"
|
||||
echo " Deliberate exception: ETV_SKIP_REBASE_CHECK=1 git push"
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Agent (subagent spawn) — RAM-gate the fan-out.
|
||||
# The historic 8-9-way crash was RAM starvation, not CPU load; gate on FREE RAM.
|
||||
# Fail-open: if memory_pressure is unavailable/unparsable → allow.
|
||||
set -euo pipefail
|
||||
free=$(memory_pressure -Q 2>/dev/null | grep -oE 'free percentage: [0-9]+' | grep -oE '[0-9]+' || true)
|
||||
[ -z "${free:-}" ] && exit 0
|
||||
|
||||
if [ "$free" -lt 10 ]; then
|
||||
jq -n --arg f "$free" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("Free RAM \($f)% (<10%): do NOT spawn more agents — the historic crash was RAM starvation from an 8-9-way fan-out. Wait for memory_pressure -Q to recover, then retry.")}}'
|
||||
elif [ "$free" -lt 20 ]; then
|
||||
jq -n --arg f "$free" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("Free RAM \($f)% (<20%): near the fan-out ceiling. Confirm before adding another build/implementer agent (read-only recon agents are cheap).")}}'
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Bash — deny commands that violate a HARD RULE.
|
||||
# Fail-open: any parse trouble → allow (exit 0 with no output).
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
|
||||
# Match an actual env ASSIGNMENT in COMMAND POSITION — line start or right after a shell
|
||||
# separator (; && || | ( ), optionally `export`. This deliberately does NOT match the name
|
||||
# when it sits inside a quoted string (echo, git commit -m, jq test payloads), where the
|
||||
# preceding char is a quote/word, not a separator — so mentions of the rule never false-trip.
|
||||
if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(export[[:space:]]+)?ETV_UPDATE_GOLDENS='; then
|
||||
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Blocked: ETV_UPDATE_GOLDENS regenerates golden-test baselines — HARD RULE (docs/handoffs lore); never set it in a session. Update a golden deliberately and reviewed, not via a guarded run."}}'
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Bash — deny `git commit` / `git push` when a .cs file this branch touches carries a
|
||||
# UTF-8 BOM. `.editorconfig` sets charset=utf-8 (no BOM), and the #311 fix-as-you-touch gate
|
||||
# ("Formatting (changed .cs conform to .editorconfig)") FAILS THE PR for any touched file that has one.
|
||||
#
|
||||
# Why a hook and not a note: the ~2500 legacy .cs files carry a BOM, so it becomes *your* problem the
|
||||
# moment you touch one — and the usual ways of touching them re-add it silently. Python
|
||||
# `io.open(..., encoding='utf-8-sig')` WRITES a BOM back; perl/sed round-trips preserve it. On
|
||||
# 2026-07-17 this cost two separate sessions a red CI job on the same day (PR #405 x6 files;
|
||||
# #70/PR #402 x19), and a memory describing the trap did not prevent either — the second session
|
||||
# re-added a BOM an hour after writing that memory down. A check that runs is worth more than one you
|
||||
# have to remember.
|
||||
#
|
||||
# Generated files are excluded: dotnet format skips *.Designer.cs and TvContextModelSnapshot.cs as
|
||||
# generated code, and so does the CI verify, so `dotnet ef` may leave its BOM there.
|
||||
#
|
||||
# Fail-open by design: any parse/lookup trouble → allow (exit 0, no output). This gate must never be
|
||||
# the reason a commit can't happen; CI is still the backstop.
|
||||
set -uo pipefail
|
||||
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
[ -n "$cmd" ] || exit 0
|
||||
|
||||
# Only gate real `git commit` / `git push` invocations (allowing global flags like `git -c x=y commit`).
|
||||
# Matched in command position so the words inside a commit message or an echo never false-trip.
|
||||
printf '%s' "$cmd" \
|
||||
| grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+(commit|push)([[:space:]]|$)' \
|
||||
|| exit 0
|
||||
|
||||
# Which tree does this act on? Commits here are typically `cd <worktree>` followed by git, and the
|
||||
# harness resets the shell cwd between calls, so an in-command `cd` is the most reliable signal.
|
||||
# Fall back to the payload cwd, then the project dir.
|
||||
dir=$(printf '%s' "$cmd" \
|
||||
| grep -oE '(^|[;&|(]|&&|\|\|)[[:space:]]*cd[[:space:]]+[^;&|)]+' \
|
||||
| tail -1 | sed -E 's/.*cd[[:space:]]+//; s/[[:space:]]+$//' | tr -d "\"'" || true)
|
||||
if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then
|
||||
dir=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then
|
||||
dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
fi
|
||||
|
||||
root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
|
||||
# Scoped to this repo — the .editorconfig rule it enforces is ours.
|
||||
case "$root" in
|
||||
*ersatztv*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# The touched set: what this branch changes vs origin/main, plus anything staged or dirty right now
|
||||
# (a commit can introduce a BOM that isn't in the pushed diff yet).
|
||||
base=$(git -C "$root" rev-parse --verify --quiet origin/main 2>/dev/null || true)
|
||||
{
|
||||
[ -n "$base" ] && git -C "$root" diff --name-only --diff-filter=ACM "$base"...HEAD -- '*.cs' 2>/dev/null
|
||||
git -C "$root" diff --name-only --diff-filter=ACM --cached -- '*.cs' 2>/dev/null
|
||||
git -C "$root" diff --name-only --diff-filter=ACM -- '*.cs' 2>/dev/null
|
||||
} | sort -u > /tmp/.bom-guard-files.$$ 2>/dev/null || { rm -f /tmp/.bom-guard-files.$$; exit 0; }
|
||||
|
||||
bad=""
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
case "$f" in
|
||||
*.Designer.cs|*TvContextModelSnapshot.cs) continue ;;
|
||||
esac
|
||||
p="$root/$f"
|
||||
[ -f "$p" ] || continue
|
||||
if [ "$(head -c3 "$p" 2>/dev/null | xxd -p 2>/dev/null)" = "efbbbf" ]; then
|
||||
bad="${bad} ${f}"$'\n'
|
||||
fi
|
||||
done < /tmp/.bom-guard-files.$$
|
||||
rm -f /tmp/.bom-guard-files.$$
|
||||
|
||||
[ -n "$bad" ] || exit 0
|
||||
|
||||
reason="Blocked: these .cs files carry a UTF-8 BOM, which .editorconfig forbids (charset=utf-8). The #311 Formatting CI job fails the PR for any file this branch touches that has one:
|
||||
|
||||
${bad}
|
||||
Strip it, then re-run this command:
|
||||
|
||||
python3 - <<'EOF'
|
||||
import subprocess
|
||||
def g(*a): return subprocess.run(['git','diff','--name-only',*a,'--','*.cs'],
|
||||
capture_output=True, text=True).stdout.split()
|
||||
# same detection set as the guard: branch diff + staged + dirty (a brand-new staged
|
||||
# file is exactly what fires the deny and is absent from origin/main...HEAD)
|
||||
fs = set(g('origin/main...HEAD')) | set(g('--cached')) | set(g())
|
||||
for f in sorted(fs):
|
||||
try: b = open(f,'rb').read()
|
||||
except OSError: continue
|
||||
if b[:3] == b'\xef\xbb\xbf':
|
||||
open(f,'wb').write(b[3:]); print('stripped', f)
|
||||
EOF
|
||||
|
||||
Usual cause: an edit that rewrote a legacy file preserved its BOM — Python io.open(..., encoding='utf-8-sig') WRITES one back; sed/perl round-trips keep it. Touching a legacy file makes its inherited BOM yours to remove (docs/contributing.md; ersatztv#311). Generated *.Designer.cs / TvContextModelSnapshot.cs are exempt and not listed here."
|
||||
|
||||
jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
|
||||
exit 0
|
||||
@@ -1,376 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / mcp__gitea__pull_request_write — derive merge consent from STATE instead of
|
||||
# trusting the agent's judgment (ersatztv#303 H6 + H10). A PR merge is the one irreversible op; allow it
|
||||
# only when ALL are true:
|
||||
# (a) the PR's CI combined status is green, AND
|
||||
# (b) every checkbox in the linked issue's "## Done-when" section is ticked, AND
|
||||
# (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
|
||||
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
|
||||
#
|
||||
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
|
||||
# - state derivable and satisfied -> grant (auto-approve: permissionDecision "allow",
|
||||
# so NO redundant permission prompt fires —
|
||||
# the derived state IS the consent, ersatztv#314)
|
||||
# - state derivable and NOT satisfied -> deny (actionable reason)
|
||||
# - state NOT derivable (no creds, Gitea down,
|
||||
# no linked issue, no Done-when section) -> ask (surface to a human/session judgment)
|
||||
# Only a real merge is gated; every other pull_request_write method is passed through UNTOUCHED
|
||||
# (bare exit 0 → normal permissioning still applies), NOT auto-granted.
|
||||
#
|
||||
# WHY "grant" (not a bare exit 0) on the satisfied path (ersatztv#314 root cause): a PreToolUse hook
|
||||
# that exits 0 with no JSON does NOT auto-approve — it only declines to block, so control falls through
|
||||
# to the normal permission system and the raw MCP prompt still fires. The gate therefore only ever
|
||||
# ADDED a deny/ask net; it never REMOVED the baseline prompt on the happy path, so a satisfied merge
|
||||
# was confirmed twice (conversationally + a redundant mechanical prompt). Emitting permissionDecision
|
||||
# "allow" is what actually suppresses the prompt — "derive consent from state" made real.
|
||||
#
|
||||
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
|
||||
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
|
||||
decide() { # $1=grant|allow|deny|ask $2=reason
|
||||
case "$1" in
|
||||
# grant = the gate is SATISFIED → auto-approve so no redundant permission prompt fires.
|
||||
grant) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}'; exit 0 ;;
|
||||
# allow = not our concern (non-merge method) → pass through untouched; normal permissioning applies.
|
||||
allow) exit 0 ;;
|
||||
deny) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'; exit 0 ;;
|
||||
ask) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
method=$(printf '%s' "$input" | jq -r '.tool_input.method // ""' 2>/dev/null || true)
|
||||
[ "$method" = "merge" ] || decide allow ""
|
||||
|
||||
owner=$(printf '%s' "$input" | jq -r '.tool_input.owner // ""' 2>/dev/null || true)
|
||||
repo=$(printf '%s' "$input" | jq -r '.tool_input.repo // ""' 2>/dev/null || true)
|
||||
pr=$(printf '%s' "$input" | jq -r '.tool_input.pull_number // ""' 2>/dev/null || true)
|
||||
mwcs=$(printf '%s' "$input" | jq -r '.tool_input.merge_when_checks_succeed // false' 2>/dev/null || true)
|
||||
[ -n "$owner" ] && [ -n "$repo" ] && [ -n "$pr" ] || decide ask "H6 merge gate: could not read owner/repo/pull_number from the merge call; confirm manually that CI is green and the issue's Done-when boxes are ticked."
|
||||
|
||||
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
|
||||
# curl wrapper carrying whichever auth is configured; empty output on any failure.
|
||||
gq() {
|
||||
local path="$1"
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
|
||||
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
|
||||
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
|
||||
decide ask "H6 merge gate: no Gitea credentials in env (ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH), so CI/Done-when state can't be verified. Confirm manually that CI is green and the linked issue's Done-when boxes are all ticked, then approve."
|
||||
fi
|
||||
|
||||
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
|
||||
[ -n "$prjson" ] || decide ask "H6 merge gate: could not fetch PR #$pr from Gitea (unreachable or auth rejected). Verify CI-green + Done-when manually before merging."
|
||||
|
||||
sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
|
||||
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
|
||||
|
||||
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
|
||||
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and bound to ONE head, or the
|
||||
# exemption is unsafe. ALL of that now lives in scripts/pr-changed-files.sh — the single shared
|
||||
# implementation, also called by .gitea/workflows/review-verdict.yml (ersatztv#649).
|
||||
#
|
||||
# Why it moved: this logic was written twice. This copy is ADVISORY (a failure produces a human
|
||||
# prompt); the workflow's copy is ENFORCED (it writes the branch-protection-required
|
||||
# `review-verdict/h10` status). Four rounds of ersatztv#643 hardening landed here and never reached
|
||||
# there, leaving the copy with real authority strictly weaker than the copy without — and its safe
|
||||
# behaviour resting on a bash arithmetic error rather than an intentional guard. Two copies of a
|
||||
# security predicate drift; one cannot.
|
||||
#
|
||||
# What is NOT shared, deliberately: the docs-only allow-list below. This one also lets .claude/,
|
||||
# .gitea/ and .husky/ through, which is safe HERE only because a match falls through to a human
|
||||
# prompt rather than auto-granting. The workflow's list is narrower for exactly that reason. Sharing
|
||||
# the enumeration fixes the drift; sharing the classification would erase an intended difference.
|
||||
#
|
||||
# A non-zero exit means "could not tell" and MUST withhold the exemption — never read stdout without
|
||||
# checking the status. An empty `$sha` (unparseable PR JSON) reaches the script as an empty argument
|
||||
# and is rejected there, so that path also fails closed.
|
||||
#
|
||||
# The 5th argument binds the enumeration to a base branch (ersatztv#698 route 1), because
|
||||
# `/pulls/{n}/files` diffs against the PR's LIVE base and retargeting moves that without moving the
|
||||
# head. Be precise about what it buys HERE, which is less than what it buys in the workflow: the
|
||||
# workflow passes the base from a `pull_request_target` event payload, fixed at event time and beyond
|
||||
# a retarget's reach, so it detects a retarget outright. This hook has no such trusted snapshot — it
|
||||
# passes the base it just read from the live PR, so what it asserts is that the base did not move
|
||||
# between that read and the enumeration. Narrower, and still worth having: without it the hook cannot
|
||||
# tell a mid-flight retarget from an honest read at all. An empty/unparseable `.base.ref` reaches the
|
||||
# script as an empty argument and is rejected there, so that path fails closed too.
|
||||
base_ref=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
files=""; files_complete=no
|
||||
if files=$("$repo_root/scripts/pr-changed-files.sh" "$owner" "$repo" "$pr" "$sha" "$base_ref" 2>/dev/null); then
|
||||
files_complete=yes
|
||||
fi
|
||||
|
||||
# HOW THIS PREDICATE IS EVALUATED, matching the enforced gate (ersatztv#698,
|
||||
# `ci.grep-q-pipefail-inversion`). `printf … | grep -q` INVERTS under `set -o pipefail`: grep -q exits
|
||||
# at its first match, printf then takes SIGPIPE (141), and a MATCH is reported as a failed pipeline —
|
||||
# so this negated test would grant a spurious docs-only exemption for any PR whose path list exceeds
|
||||
# the pipe buffer. A here-string fixes that but is materialised via temporary storage for large inputs,
|
||||
# so it can fail when temp space is full or unwritable and flip the predicate the same way. Counting
|
||||
# with `grep -c` drains stdin (no SIGPIPE) over an ordinary pipe (no temp file); `grep -c` exits 1 for
|
||||
# a zero count, which is a legitimate answer, so only a status >1 is a real error and is treated as
|
||||
# "cannot tell" -> no exemption.
|
||||
# Advisory here, so the blast radius is a missing prompt rather than a green required check; the
|
||||
# construct is identical on purpose, because the two copies drifting is what ersatztv#649 was about.
|
||||
docs_nonmatching=$(printf '%s\n' "$files" | grep -cvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)') || docs_grep_status=$?
|
||||
if [ "${docs_grep_status:-0}" -gt 1 ]; then
|
||||
docs_nonmatching=1 # grep itself failed: cannot tell, so withhold the exemption
|
||||
fi
|
||||
if [ "$files_complete" = yes ] && [ -n "$files" ] && [ "${docs_nonmatching:-1}" -eq 0 ]; then
|
||||
# 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
|
||||
# process-control files (.claude/ / .gitea/ / .husky/ — the gate, CI, and git hooks themselves): a PR
|
||||
# that weakens the gate must not silently self-merge (ersatztv#317 review nit). Only the satisfied
|
||||
# merge path below auto-grants.
|
||||
decide allow "" # passthrough (exit 0 → normal prompt), NOT grant
|
||||
fi
|
||||
|
||||
# --- Base-change detection: a verdict is bound to a head AND to a base (ersatztv#632). ---
|
||||
# `review-verdict/h10` is per-sha, which makes "the head moved under a fixed verdict" impossible by
|
||||
# construction. Retargeting a PR's base is the mirror case and slips through: it changes neither the
|
||||
# head sha nor the status, so a verdict formed while the PR targeted `main` still reads green after
|
||||
# the PR is pointed at a branch with a very different merge-base. The diff moves while the verdict
|
||||
# and the head both hold still.
|
||||
#
|
||||
# DETECTION, NOT PREVENTION, and only on this path. A commit status carries no base, so the
|
||||
# server-side required check cannot see this; a merge driven through the Gitea UI or API is
|
||||
# unaffected. That is the accepted exposure — base changes are rare, manual, and this is a
|
||||
# two-account repo — but it is now recorded in a place that fails LOUD rather than only in a doc.
|
||||
#
|
||||
# GRACEFUL ADOPTION, mirroring (b) and (c): a description with no `(base: …)` field is a verdict
|
||||
# posted before ersatztv#632 and gets NO opinion, rather than denying every in-flight PR the day
|
||||
# this lands. The window closes on its own — verdicts are per-head and short-lived, so every verdict
|
||||
# posted after this carries the field.
|
||||
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded". Cold
|
||||
# review found the first draft collapsing it into the latter: an unreadable status response yielded
|
||||
# an empty `recorded_base`, which took the graceful-adoption path and skipped validation silently —
|
||||
# after which a later, successful status read could still auto-grant. A transient failure would then
|
||||
# have produced a "merge gate: satisfied" message for a comparison that never happened. Every
|
||||
# unreadable input here therefore falls through to a human (`ask`), never to silence.
|
||||
live_base=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
||||
if [ -z "$live_base" ]; then
|
||||
decide ask "H10 merge gate: PR #$pr reports no base branch (.base.ref), so the verdict cannot be checked against the branch it was formed for (ersatztv#632). Confirm the PR still targets the branch it was reviewed against before merging."
|
||||
fi
|
||||
if [ -n "$sha" ]; then
|
||||
# This is the THIRD read of this endpoint in a worst-case hook run (the ordinary-CI branch and the
|
||||
# scheduled-auto-merge branch each do their own). Sharing one snapshot would close a narrow
|
||||
# same-run window where two reads disagree, but the later branches derive different decisions from
|
||||
# a failed read than this one does, so threading a shared response through them is a change to
|
||||
# pre-existing logic rather than to ersatztv#632's. Left deliberately, noted so it is not
|
||||
# rediscovered as an oversight: every `decide` exits immediately, so the reads cannot produce a
|
||||
# single self-contradictory message — only a later decision made on a fresher snapshot.
|
||||
vjson_base=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
|
||||
# Same jq-1.6 rule as everywhere else in this file: check emptiness in SHELL first, never via
|
||||
# `jq -e`'s exit status over empty input.
|
||||
# VALIDATE EVERY FIELD THE EXTRACTION CONSUMES, on EVERY row — the same rule the file-enumeration
|
||||
# guard learned the hard way. Checking only that `.statuses` is an array left a hole one level
|
||||
# down: `{"statuses":[1]}` passes a top-level type check, then `.context` on a number errors, and
|
||||
# a `|| true` on the extraction turned that error into an empty `vdesc` — i.e. straight back onto
|
||||
# the graceful-adoption path this block exists to distinguish from. That is the identical
|
||||
# swallow-the-error shape fixed a few lines up, surviving one level deeper.
|
||||
if [ -z "${vjson_base//[[:space:]]/}" ] \
|
||||
|| ! printf '%s' "$vjson_base" \
|
||||
| jq -e '.statuses | type == "array"
|
||||
and all(.[]; type == "object"
|
||||
and (.context | type == "string")
|
||||
and (.description == null or (.description | type == "string")))' \
|
||||
>/dev/null 2>&1; then
|
||||
decide ask "H10 merge gate: could not read the commit statuses for PR #$pr head ${sha:0:7}, so the verdict could not be checked against the PR's base branch (ersatztv#632). Confirm the review covered the branch this PR currently targets ('$live_base') before merging."
|
||||
fi
|
||||
# No `|| true` here. The validation above makes an error unreachable, but a swallowed error would
|
||||
# be indistinguishable from "no base recorded" — the exact confusion this block removes — so the
|
||||
# failure is handled explicitly rather than left to a fallback that reads as a benign result.
|
||||
if ! vdesc=$(printf '%s' "$vjson_base" \
|
||||
| jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .description // ""' \
|
||||
2>/dev/null); then
|
||||
decide ask "H10 merge gate: the commit statuses for PR #$pr head ${sha:0:7} could not be parsed to find the review verdict, so it could not be checked against the PR's base branch (ersatztv#632). Confirm the review covered the branch this PR currently targets ('$live_base') before merging."
|
||||
fi
|
||||
# The field is written by scripts/post-review-verdict.sh as a trailing `(base: <ref>)`. Its
|
||||
# ABSENCE is the one benign case: a verdict posted before ersatztv#632 could not have carried it,
|
||||
# and denying those would block every in-flight PR the day this lands. The window closes on its
|
||||
# own, since verdicts are per-head and short-lived.
|
||||
recorded_base=$(printf '%s' "$vdesc" | sed -n 's/.*(base: \(.*\))$/\1/p')
|
||||
if [ -n "$recorded_base" ] && [ "$recorded_base" != "$live_base" ]; then
|
||||
decide deny "H10 merge gate: BLOCKED — the review verdict on head ${sha:0:7} was formed while PR #$pr targeted '$recorded_base', but it now targets '$live_base'. Retargeting a base does not move the head sha, so the per-sha verdict status still reads green even though the effective diff has changed (ersatztv#632). Re-review against the new base and run: scripts/post-review-verdict.sh $pr MERGEABLE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Linked issue: Gitea auto-close keywords in the PR body. ---
|
||||
issues=$(printf '%s' "$body" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
||||
[ -n "$issues" ] || decide ask "H6 merge gate: PR #$pr has no linked issue (no 'fixes #N' / 'closes #N' in its body), so there is no Done-when checklist to derive consent from. Confirm the work is complete + reviewed, then approve."
|
||||
|
||||
# --- (b) Done-when checkboxes: every linked issue must have an all-ticked section. ---
|
||||
for n in $issues; do
|
||||
ibody=$(gq "repos/$owner/$repo/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
|
||||
[ -n "$ibody" ] || decide ask "H6 merge gate: could not fetch linked issue #$n. Verify its Done-when checklist manually before merging."
|
||||
# Slice the "## Done-when" section: from that header to the next "## " (or EOF).
|
||||
section=$(printf '%s\n' "$ibody" | awk '
|
||||
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
|
||||
grab && /^##[[:space:]]/ {grab=0}
|
||||
grab {print}')
|
||||
if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
|
||||
decide ask "H6 merge gate: linked issue #$n has no '## Done-when' checklist section (the merge-consent convention — see CLAUDE.md Task Completion Protocol). Add one, or confirm completion manually and approve."
|
||||
fi
|
||||
unchecked=$(printf '%s\n' "$section" | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
|
||||
if [ "${unchecked:-0}" -gt 0 ]; then
|
||||
decide deny "H6 merge gate: BLOCKED — linked issue #$n has $unchecked unticked box(es) in its ## Done-when checklist. Finish (or explicitly tick) every completion criterion — including the adversarial-review box — before merging PR #$pr."
|
||||
fi
|
||||
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)
|
||||
case "$state" in
|
||||
success) : ;;
|
||||
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
|
||||
*)
|
||||
# `review-verdict/h10` is itself one of the contexts folded into the COMBINED state, so a PR
|
||||
# awaiting its verdict reports combined 'pending' and would otherwise be reported as a CI
|
||||
# problem — sending the reader to build logs when the missing thing is the review. Name the
|
||||
# real blocker when the verdict is the only thing outstanding.
|
||||
#
|
||||
# "Not green" is anything that is not `success`, NOT just pending/failure: Gitea also has
|
||||
# `error` (and `warning`), and omitting those would let an errored build hide behind the
|
||||
# verdict and produce the flatly false claim "every CI check is green". `skipped` IS treated
|
||||
# as green — the image-push job skips on every PR (ersatztv#593: a skipped context is not red).
|
||||
nongreen=$(printf '%s' "$cistatus" \
|
||||
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
|
||||
| map("\(.context)=\(.status)") | join(", ")' 2>/dev/null || true)
|
||||
# The verdict's OWN state decides the wording: absent/pending means nobody has reviewed this
|
||||
# head, while failure/error means someone reviewed it and said no. Telling a reviewer to "post
|
||||
# a verdict" when they already posted a BLOCKED one would be actively misleading.
|
||||
vonly=$(printf '%s' "$cistatus" \
|
||||
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
|
||||
| if (length == 1 and .[0].context == "review-verdict/h10") then .[0].status else "" end' 2>/dev/null || true)
|
||||
case "$vonly" in
|
||||
pending)
|
||||
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green; the only outstanding context is 'review-verdict/h10' on head ${sha:0:7}, i.e. this head has no review verdict yet. Review it and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
||||
failure|error)
|
||||
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green, but 'review-verdict/h10' is '$vonly' on head ${sha:0:7}: this head was reviewed and REJECTED. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
||||
esac
|
||||
decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success' (not green: ${nongreen:-unknown}). Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging."
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# --- SCHEDULED auto-merge: everything this hook proves is a SNAPSHOT (ersatztv#622). ----------
|
||||
# With merge_when_checks_succeed, Gitea performs the merge later, against whatever head is green
|
||||
# at THAT moment — but (b) and (c) below are evaluated against the head that exists right now.
|
||||
# Any commit pushed in between would merge with no verdict covering it. Demonstrated as a
|
||||
# controlled A/B (#622): with a slow CI check pending so Gitea waits, an unreviewed commit pushed
|
||||
# after scheduling MERGED without the required verdict context and was REFUSED with it.
|
||||
#
|
||||
# The durable fix is server-side and lives outside this hook: `review-verdict/h10` is a REQUIRED
|
||||
# status check on `main`, and a commit status belongs to exactly ONE sha, so a later commit cannot
|
||||
# inherit it and Gitea's own gate refuses to merge until that head is re-reviewed.
|
||||
#
|
||||
# What we add HERE is the matching precondition at SCHEDULING time: refuse to arm an auto-merge
|
||||
# unless the sha-bound status already exists on this head. Checking the comment alone (condition
|
||||
# (c) below) is not enough for this path — the comment is what a human reads, the status is what
|
||||
# the server enforces, and only the latter survives a new push. Deny rather than ask: the remedy
|
||||
# is a single documented command, so there is nothing here for a human to adjudicate.
|
||||
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check the review-verdict status. Verify the review covered the latest commit before scheduling an auto-merge."
|
||||
# Read the COMBINED endpoint, not `/statuses/{sha}`: the latter returns one row per status POST
|
||||
# rather than per context and pages at 50, so a head with a few CI reruns can push the verdict off
|
||||
# the first page and read as absent — a confusing false deny. The combined endpoint returns
|
||||
# latest-per-context, which is exactly the question being asked.
|
||||
vjson=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
|
||||
# Same portability point as the file-pagination guard above: do not let jq's empty-input exit
|
||||
# status decide this. Here the fallthrough happens to land on `vstate=""` -> deny (fail-CLOSED,
|
||||
# so this was never a hole), but it would have surfaced the wrong message — a "BLOCKED, no
|
||||
# verdict" deny instead of the "could not read the status" ask this branch exists to give.
|
||||
if [ -z "${vjson//[[:space:]]/}" ] || ! printf '%s' "$vjson" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
|
||||
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable or an unexpected response). Confirm the current head is reviewed before scheduling an auto-merge."
|
||||
fi
|
||||
vstate=$(printf '%s' "$vjson" | jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .status // ""')
|
||||
case "$vstate" in
|
||||
success) : ;;
|
||||
"") decide deny "H6/H10 merge gate: BLOCKED — PR #$pr has no 'review-verdict/h10' commit status on head ${sha:0:7}, so scheduling an auto-merge would freeze consent at a head Gitea may not be the one to merge (ersatztv#622). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
||||
pending) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is still pending on PR #$pr head ${sha:0:7} (no verdict posted for this commit yet). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
||||
*) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is '$vstate' on PR #$pr head ${sha:0:7}. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the
|
||||
# CURRENT head sha, so the latest commit is proven-reviewed (ersatztv#242: re-review the fix
|
||||
# commit, not just the initial diff). Graceful adoption mirrors (b): a verdict comment that
|
||||
# references head must be positive -> allow; one that exists only for an OLDER commit -> deny
|
||||
# (the stale-review failure mode); NO verdict comment at all -> ask (convention not yet used).
|
||||
[ -n "$sha" ] || decide ask "H10 merge gate: could not resolve PR #$pr head sha to verify a review verdict. Confirm the review covered the latest commit before merging."
|
||||
short=${sha:0:7}
|
||||
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."
|
||||
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."
|
||||
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
|
||||
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
|
||||
|
||||
# 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."
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / browser-navigate — deny opening download/stream endpoints in a tab
|
||||
# (they hang the MCP session; curl them instead). Fail-open on parse trouble.
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
url=$(printf '%s' "$input" | jq -r '.tool_input.url // ""' 2>/dev/null || true)
|
||||
|
||||
if printf '%s' "$url" | grep -qE '/iptv/|\.m3u8|/artwork/|playback\.m3u8'; then
|
||||
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Blocked: do not open download/stream endpoints (/iptv, .m3u8, /artwork, playback.m3u8) in a browser tab — they stall the MCP session. curl them instead (docs/handoffs lore)."}}'
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Bash — deny `git commit`/`git merge` inside a sibling worktree that
|
||||
# a DIFFERENT session created (burned us twice — #289 path-leak, the plumbing-merge
|
||||
# workaround exists precisely because of this). Ownership is a `.claude-worktree-owner`
|
||||
# marker (session id) written at `git worktree add` time by posttooluse-worktree-marker.sh.
|
||||
#
|
||||
# Fail-open by design: no marker, unparsable input, or marker == this session → allow.
|
||||
# So the main tree (never marked) and pre-convention worktrees (no marker) are unaffected;
|
||||
# only a commit/merge into another session's marked worktree is blocked.
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
me=$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)
|
||||
|
||||
# Only guard the state-mutating ops. Match `git commit`/`git merge` in command position
|
||||
# (line start or after a shell separator) so a quoted mention never false-trips.
|
||||
printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git[[:space:]]+(-C[[:space:]]+[^[:space:]]+[[:space:]]+)?(commit|merge)\b' || exit 0
|
||||
|
||||
[ -z "$cwd" ] && cwd="$PWD"
|
||||
|
||||
# Determine the effective directory the git op runs in. Two common redirections in the
|
||||
# lore's usage move it off the session cwd: `git -C <path>` and a leading `cd <path> &&`.
|
||||
effdir="$cwd"
|
||||
cpath=$(printf '%s' "$cmd" | grep -oE 'git[[:space:]]+-C[[:space:]]+[^[:space:]&|;]+' | head -1 | sed -E 's/^git[[:space:]]+-C[[:space:]]+//' | tr -d '"'"'"'' || true)
|
||||
cdpath=$(printf '%s' "$cmd" | grep -oE '^[[:space:]]*cd[[:space:]]+[^[:space:]&|;]+' | head -1 | sed -E 's/^[[:space:]]*cd[[:space:]]+//' | tr -d '"'"'"'' || true)
|
||||
if [ -n "${cpath:-}" ]; then
|
||||
effdir="$cpath"
|
||||
elif [ -n "${cdpath:-}" ]; then
|
||||
effdir="$cdpath"
|
||||
fi
|
||||
# Resolve a relative effective dir against the session cwd.
|
||||
case "$effdir" in /*) : ;; *) effdir="$cwd/$effdir" ;; esac
|
||||
|
||||
root=$(git -C "$effdir" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
[ -z "$root" ] && exit 0
|
||||
marker="$root/.claude-worktree-owner"
|
||||
[ -f "$marker" ] || exit 0
|
||||
owner=$(tr -d '[:space:]' < "$marker" 2>/dev/null || true)
|
||||
[ -z "$owner" ] && exit 0
|
||||
[ "$owner" = "$me" ] && exit 0
|
||||
|
||||
# Marker names a DIFFERENT session → deny.
|
||||
jq -n --arg o "$owner" --arg r "$root" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("Blocked: worktree \($r) is owned by session \($o), not this one. Never commit/merge inside a sibling worktree another session created (#289 path-leak, plumbing-merge workaround). Commit from your own tree; if you genuinely own this worktree now, overwrite its .claude-worktree-owner marker with your session id.")}}'
|
||||
exit 0
|
||||
@@ -1,95 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-bash-guard.sh\"",
|
||||
"timeout": 10
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-worktree-guard.sh\"",
|
||||
"timeout": 10
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-bom-guard.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__plugin_playwright_playwright__browser_navigate|mcp__claude-in-chrome__navigate",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-nav-guard.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Agent|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__gitea__pull_request_write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-merge-consent.sh\"",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" start",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse-worktree-marker.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" finish",
|
||||
"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">
|
||||
```
|
||||
@@ -1,463 +0,0 @@
|
||||
---
|
||||
name: ersatztv
|
||||
description: "ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when creating or modifying IPTV channels, managing collections and schedules, building playouts, adding channel logos, scanning media libraries, troubleshooting channel issues, or resetting playouts. Also use for any questions about the ErsatzTV database schema (Channel, Collection, ProgramSchedule, Playout tables), M3U/XMLTV feeds, custom TV channel setup, or the channel creation checklist. IMPORTANT: the fork has a full versioned REST API at /api/v1 including write paths — prefer it over SQLite scripting, which is a recovery fallback only."
|
||||
---
|
||||
|
||||
> **Canonical copy: `~/ersatztv/.claude/skills/ersatztv/SKILL.md`** (ersatztv owns this skill per that
|
||||
> repo's `CLAUDE.md` → Project Boundaries). `~/server-management/.claude/skills/ersatztv` is a symlink
|
||||
> to it. Edit it in the ersatztv repo; never fork a second copy (ersatztv#617).
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409`
|
||||
Web UI: `https://ersatztv.tblindustries.be` (via bumblebee's `external-proxy` → `192.168.1.29:8409`) or `http://localhost:8409` on the host
|
||||
Host: **jazz** (`192.168.1.29`) since 2026-07-20 (#633) — moved off bumblebee together with Jellyfin. `dispatcharr` and `plex` stayed on bumblebee, so Dispatcharr now reaches ErsatzTV **by IP** (`http://192.168.1.29:8409`), not by Docker DNS name.
|
||||
Compose env: `ForwardedHeaders__KnownNetworks=192.168.1.99/32` (proxied traffic arrives SNAT'd from bumblebee's LAN address; wrong value breaks Authelia OIDC login only, plain HTTP still works)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` (owned by root — use `sudo sqlite3`)
|
||||
Image: `192.168.1.95:3000/timothy/ersatztv:prod` (our fork; **floating** release tag — check `git tag -l 'v*' --sort=-v:refname | head -1` in `~/ersatztv` for the current release rather than trusting a version written here). Upstream `ghcr.io/ersatztv/ersatztv` was archived at v26.3.0 and is **not** what runs here.
|
||||
Release tags are `vYY.<release-seq>.<patch>` — year · sequential release-within-year · patch — **not** year.month.
|
||||
|
||||
## Test/Prod topology — fork CI images (#481)
|
||||
|
||||
We maintain an **ErsatzTV fork** (`~/ersatztv`); its Gitea Actions pipeline builds and pushes images to the
|
||||
private Gitea registry `192.168.1.95:3000/timothy/ersatztv` on every push to `main` (`:latest` + `:<short-sha>`)
|
||||
and, on a `v*` tag, additionally `:prod` + `:<version>`. jazz is `docker login`'d to that registry and has `192.168.1.95:3000` in `insecure-registries`.
|
||||
|
||||
| | Prod | Test |
|
||||
|---|---|---|
|
||||
| Container | `ersatztv` | `ersatztv-test` |
|
||||
| Host port | 8409 | 8410 |
|
||||
| Stack | Komodo **`jazz-media`**; source `docker/jazz/stacks/media-servers/compose.yaml` (stack name ≠ directory — `media-servers` is bumblebee's; Komodo stack names are globally unique) | Komodo `ersatztv`; source `docker/jazz/stacks/ersatztv/compose.yaml` |
|
||||
| Image | `192.168.1.95:3000/timothy/ersatztv:prod` (floating release tag) | `192.168.1.95:3000/timothy/ersatztv:latest` (fork CI) |
|
||||
| Config (host) | `~/downloadswarm/ersatztv/` → `/config` | `~/downloadswarm/ersatztv-test/` → `/config` (one-time prod snapshot, refresh on demand) |
|
||||
| Jellyfin/Dispatcharr tuner | connected (live lineup) | **NOT** wired downstream (avoids ghost channels) |
|
||||
| Media mounts | RO | same mounts, RO |
|
||||
| `/dev/dri` | yes (**VAAPI on Intel iHD**, jazz — see hw note) | yes (`/dev/dri` + `group_add: '992'`) |
|
||||
| Auto-update | **None** (`auto_update: false`) — promotion is a manual `DeployStack jazz-media`, with no 03:00 fallback | Komodo auto-update, daily 03:00 (tracks `:latest`) |
|
||||
| Env | `TZ`, restricted forwarded-header network, empty-by-default local-admin seed hook | `TZ`, `ETV_CONFIG_FOLDER=/config`, `ETV_TRANSCODE_FOLDER=/transcode`, `ETV_DISABLE_VULKAN=1` |
|
||||
|
||||
**Watchtower is retired.** Test auto-updates via Komodo; **prod does not** — `auto_update: false`, so
|
||||
promoting a release is always a manual `DeployStack jazz-media`. Prod's stack has a
|
||||
fail-closed pre-deploy hook: a changed compose block or `:prod` digest triggers a PBS-backed snapshot and then a
|
||||
migration rehearsal against a throwaway copy of that snapshot before container recreation (#585/#589).
|
||||
|
||||
**Refresh test snapshot from prod** (zero prod downtime — WAL online backup):
|
||||
```bash
|
||||
ssh timothy@192.168.1.29
|
||||
docker stop ersatztv-test
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 ".backup '/home/timothy/downloadswarm/ersatztv-test/ersatztv.sqlite3'"
|
||||
sudo rsync -a --exclude='ersatztv.sqlite3*' --exclude='logs/' ~/downloadswarm/ersatztv/ ~/downloadswarm/ersatztv-test/
|
||||
docker start ersatztv-test
|
||||
```
|
||||
|
||||
**Prod cutover to the fork** — ✅ DONE 2026-06-27 (#481). Prod runs `…/timothy/ersatztv:prod` (v26.3.1);
|
||||
validated `:prod` on test first, then `etv-prod-deploy.sh` backed up + cut over (43 channels, healthy,
|
||||
clean migrations). Downstream (Dispatcharr M3U acct 3 + EPG src 9) is name-based, so the container IP
|
||||
change was transparent. Prod stays a **manual** gate (no Watchtower label) and still lives in the
|
||||
`media-servers` stack (the optional move into the `ersatztv` stack was not done).
|
||||
|
||||
**Future prod releases** (push `v*` tag in `~/ersatztv` → CI builds `:prod`/`:<version>`): scan the immutable
|
||||
`:<version>` image on jazz first, then execute Komodo `DeployStack` for `jazz-media`. The pre-deploy hook
|
||||
backs up and runs the migration-on-prod-copy smoke before recreation. **There is no auto-update fallback for
|
||||
prod** — if you don't `DeployStack`, nothing ships. Note the stack is named **`jazz-media`** even though the
|
||||
compose *project* is still `media-servers`; a dead `media-servers` stack lingers on bumblebee and deploying it
|
||||
fails silently. Roll back with the immutable prior image plus the pre-deploy DB snapshot; migrations are
|
||||
forward-only. See the `komodo` skill and `docs/Docker/ErsatzTV.md` for the current procedure.
|
||||
|
||||
## Backup & deploy safety (#482)
|
||||
|
||||
Every prod deploy runs forward-only EF Core migrations against the live 285 MB SQLite DB — a bad one
|
||||
can't be undone by re-deploying the old image, so the **only** rollback is restoring a pre-deploy DB
|
||||
snapshot. Three scripts in `~/scripts/` (source of truth: `scripts/` in this repo) handle
|
||||
them. **⚠️ These were installed on bumblebee, where ErsatzTV no longer runs (#633) — verify they exist on
|
||||
jazz and that the Komodo `pre_deploy` hook is set on the `jazz-media` stack before relying on
|
||||
"no backup, no deploy". Until confirmed, take a manual `etv-backup.sh` snapshot before every prod deploy.**
|
||||
this. **Run as root** (DB + PBS creds are root-owned) except the deploy wrapper (run as `timothy`).
|
||||
|
||||
| Script | Run as | What it does |
|
||||
|---|---|---|
|
||||
| `etv-backup.sh [--target prod\|test] [--no-offbox]` | root (sudo) | Online `sqlite3 .backup` (zero-downtime) + `integrity_check`, provenance `manifest.txt` (image ref/digest + last `__EFMigrationsHistory` id), bundles `data-protection/` + `*-secrets.json`. Local **keep-last-5** under `~/downloadswarm/ersatztv-backups/<UTC-ts>/`; prod also pushes off-box to PBS. Prints the snapshot dir on stdout. |
|
||||
| `etv-prod-deploy.sh` | **timothy** (needs private-registry creds; sudo's for the backup) | Backup (abort deploy if it fails) → `compose pull` + `up -d ersatztv` → health + M3U gate → prints a copy-paste rollback block on trouble. |
|
||||
| `etv-restore.sh --target prod\|test --from <snapshot-dir>` | root (sudo) | Verifies snapshot → stop → saves current DB aside (`*.pre-restore-<ts>`) → swaps DB, drops stale `-wal/-shm`, restores `data-protection` → start → health/channel check. |
|
||||
|
||||
- **Off-box:** prod backups go to PBS `data-local` (.68) as backup-id **`ersatztv-predeploy`** (own
|
||||
group, dedups against the nightly host backup), via the existing `/root/.proxmox-backup-client.env`.
|
||||
- **Retention:** local keep-last-5 (instant rollback); PBS via the datastore-wide `data-local-prune`
|
||||
job (7 daily / 4 weekly / 6 monthly), no separate prune job needed.
|
||||
- **Restore from PBS** instead of a local dir:
|
||||
```bash
|
||||
source /root/.proxmox-backup-client.env
|
||||
proxmox-backup-client restore ersatztv-predeploy/<snapshot> etv.pxar <outdir>
|
||||
sudo ~/scripts/etv-restore.sh --target prod --from <outdir>
|
||||
```
|
||||
- `docker exec` always curls the container-internal port **8409** (even for test, whose host port is
|
||||
8410). `etv-restore.sh` leaves a `*.pre-restore-<ts>` safety copy in `/config` — delete once happy.
|
||||
- Validated 2026-06-27: first prod backup → PBS group created; full restore round-trip on `ersatztv-test`
|
||||
returned 43 channels. Design: `plans/2026-06-27-ersatztv-backup-before-deploy-design.md`.
|
||||
|
||||
## Architecture
|
||||
|
||||
**ErsatzTV is for channel creation only.** Consumers (Jellyfin, Kodi) never connect to ErsatzTV directly — everything goes through Dispatcharr as the single aggregation point. Pipeline: ErsatzTV → Dispatcharr → Jellyfin/Kodi.
|
||||
|
||||
ErsatzTV uses **MediatR + the ChicoryTV React SPA**. The legacy Blazor UI was removed in v26.7.0 (#91
|
||||
phase b) — the SPA at `/app` is the **only** UI, and legacy routes 302 there. The versioned `/api/v1`
|
||||
surface provides full CRUD — channels, collections, schedules, playouts and media sources; browser calls
|
||||
use a local-admin/OIDC session cookie plus `X-CSRF` on mutations, and machine clients use `X-Api-Key`.
|
||||
**Do not hand-edit SQLite for something the API can do** — direct SQLite writes are a recovery fallback,
|
||||
not the normal management path, and the DB recipes below survive only for gaps with no endpoint.
|
||||
|
||||
Controllers stay thin and delegate to MediatR handlers. **Authoritative endpoint list:
|
||||
`docs/endpoint-index.md` (generated) + `docs/api-conventions.md` in the ersatztv repo — prefer those
|
||||
over any list in this file**, which is hand-maintained and drifts.
|
||||
|
||||
## REST API
|
||||
|
||||
```bash
|
||||
# Via docker exec (api.key is readable inside the container)
|
||||
docker exec ersatztv curl -s -H "X-Api-Key: $(docker exec ersatztv cat /config/api.key)" \
|
||||
http://localhost:8409/api/v1/ENDPOINT
|
||||
```
|
||||
|
||||
From the **host**, the key file is root-owned `0600`, so an unsudo'd `cat` fails *silently* and sends an
|
||||
empty header. Read it with `sudo`, inline, so the value is never printed:
|
||||
|
||||
```bash
|
||||
# prod (8409); test is identical with .../ersatztv-test/api.key and port 8410
|
||||
ssh timothy@192.168.1.29 'K=$(sudo -n cat /home/timothy/downloadswarm/ersatztv/api.key); \
|
||||
curl -s -H "X-Api-Key: $K" http://localhost:8409/api/v1/channels'
|
||||
```
|
||||
|
||||
### Paging — 0-based (ersatztv#616, `api.paging-zero-based`)
|
||||
|
||||
- **`pageNum` is 0-based** across the whole `/api/v1` surface and every wrapper of it (MCP tools, SPA
|
||||
hooks, docs). Starting at 1 silently skips a page and returns a short set **with no error**.
|
||||
- **`pageSize` is clamped per-endpoint** — 100 typical, 200 auto-tune members, 1000 search/all-items —
|
||||
and the offset derives from the *effective* (clamped) size, not the requested one. Page to
|
||||
completeness against `totalCount`; never conclude "that's all of them" from a single page.
|
||||
- **`POST /api/v1/channels/{id}/playout/reset` takes a CHANNEL id, not the playout id.** The id spaces
|
||||
overlap numerically, so passing a playout row's `Id` returns a plausible 202 against a *different*
|
||||
channel. Playout rows carry `channelId` — use that.
|
||||
|
||||
Settings live under `/api/v1/settings/*` — `settings/ffmpeg` (`workAheadSegmenterLimit`,
|
||||
`qsvExtraHardwareFrames`) and `settings/logging` (`streamingMinimumLogLevel`). Note the order: it is
|
||||
`settings/ffmpeg`, **not** `ffmpeg/settings`.
|
||||
|
||||
Refresh test to the newest `:latest` without waiting for the 03:00 auto-update — scope it to the
|
||||
service, since a bare `up -d` would recreate everything else in the compose project:
|
||||
|
||||
```bash
|
||||
D=/etc/komodo/stacks/ersatztv/docker/jazz/stacks/ersatztv
|
||||
docker compose -f $D/compose.yaml pull ersatztv-test
|
||||
docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
|
||||
```
|
||||
|
||||
### Read Endpoints (GET)
|
||||
```
|
||||
/api/v1/channels # List channels
|
||||
/api/v1/collections # List collections
|
||||
/api/v1/schedules # List schedules
|
||||
/api/v1/playouts # List playouts
|
||||
/api/v1/media-items # List media items
|
||||
/api/v1/search # Search items
|
||||
/api/v1/ffmpeg/profiles # FFmpeg profiles
|
||||
/api/v1/settings/ffmpeg # Global FFmpeg settings — workAheadSegmenterLimit,
|
||||
# initialSegmentCount, hlsSegmenterIdleTimeout
|
||||
/api/v1/watermarks # Watermarks
|
||||
/iptv/channels.m3u # M3U playlist (for Jellyfin)
|
||||
/iptv/xmltv.xml # XMLTV guide data
|
||||
```
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
POST /api/v1/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
POST /api/v1/libraries/{id}/scan-show \
|
||||
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
|
||||
|
||||
# Reset channel playout (rebuilds schedule)
|
||||
POST /api/v1/channels/{channelId}/playout/reset
|
||||
```
|
||||
|
||||
## SQLite DB Operations
|
||||
|
||||
```bash
|
||||
# Read queries (safe while running, WAL mode)
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
|
||||
# Write queries — stop container first
|
||||
docker stop ersatztv
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
docker start ersatztv
|
||||
```
|
||||
|
||||
### Key Queries
|
||||
```sql
|
||||
-- List channels
|
||||
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
|
||||
|
||||
-- List collections with item counts (CollectionItem has no Id column — use rowid)
|
||||
SELECT c.Id, c.Name, COUNT(ci.rowid) as items
|
||||
FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
|
||||
|
||||
-- List schedules
|
||||
SELECT Id, Name FROM ProgramSchedule;
|
||||
|
||||
-- Playout with item count (check if playout is actually built)
|
||||
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule, p.ScheduleKind, COUNT(pi.Id) as items
|
||||
FROM Playout p JOIN Channel c ON p.ChannelId = c.Id
|
||||
LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id
|
||||
LEFT JOIN PlayoutItem pi ON pi.PlayoutId = p.Id
|
||||
GROUP BY p.Id ORDER BY CAST(c.Number AS INTEGER);
|
||||
|
||||
-- Media counts
|
||||
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
|
||||
|
||||
-- Collection content (via file paths — Movie table has only Id, metadata is via MediaVersion→MediaFile)
|
||||
SELECT ci.MediaItemId, mf.Path
|
||||
FROM CollectionItem ci
|
||||
JOIN MediaVersion mv ON mv.MovieId = ci.MediaItemId
|
||||
JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
|
||||
WHERE ci.CollectionId = <id>
|
||||
ORDER BY mf.Path;
|
||||
|
||||
-- Jellyfin source
|
||||
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
|
||||
|
||||
-- Library sync status
|
||||
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
|
||||
|
||||
-- Music library folder breakdown
|
||||
SELECT DISTINCT substr(mf.Path, 1, instr(substr(mf.Path, 13), '/') + 12) as folder, COUNT(*) as items
|
||||
FROM MediaFile mf WHERE mf.Path LIKE '/data/music/%' GROUP BY folder ORDER BY folder;
|
||||
```
|
||||
|
||||
### Table Schema Notes
|
||||
|
||||
**CollectionItem**: Has `CollectionId` + `MediaItemId` columns only (no `Id` column — use `rowid` for counting).
|
||||
|
||||
**MediaVersion**: Links to content via `MovieId`, `EpisodeId`, `MusicVideoId` columns (NOT a generic `MediaItemId`). Use `mv.MovieId = ci.MediaItemId` for movie/music video collections.
|
||||
|
||||
**Movie / Show / Episode / MusicVideo**: Inheritance from `MediaItem`. These tables have only an `Id` column (PK = MediaItem.Id). Titles and metadata are in separate `*Metadata` tables.
|
||||
|
||||
**Artwork**: Channel logos use `ArtworkKind=2` with `ChannelId` set. `Path` column is SHA256 hash (uppercase) of the image file. Files stored at `/config/cache/artwork/logos/{Path[0:2]}/{Path}`.
|
||||
|
||||
**ChannelWatermark**: Global watermark config (Id=1, "Channel Bug"). All channels share this via `Channel.WatermarkId=1`. This is the burn-in watermark overlay, NOT the channel logo.
|
||||
|
||||
**ProgramScheduleItem subtype tables**: `ProgramScheduleOneItem`, `ProgramScheduleDurationItem`, `ProgramScheduleFloodItem`, `ProgramScheduleMultipleItem`. MUST insert into the matching subtype table (usually `ProgramScheduleOneItem`).
|
||||
|
||||
### Channel Setup Workflow (DB)
|
||||
|
||||
**Show-specific channel** (single TV show, shuffled):
|
||||
```sql
|
||||
-- 1. Schedule
|
||||
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
|
||||
-- 2. Schedule item (CollectionType=1 for Show, PlaybackOrder=3 for Shuffle)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
|
||||
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
|
||||
-- 3. Channel (StreamingMode=4 = HLS Segmenter — ETV default; works fine through Dispatcharr. See Gotchas → Streaming mode.)
|
||||
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
|
||||
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
|
||||
-- 4. Playout (ScheduleKind=1 required — 0 is broken)
|
||||
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
|
||||
VALUES (<id>, <channel_id>, <schedule_id>, 1, abs(random()) % 1000000);
|
||||
```
|
||||
|
||||
**Collection-based channel** (multiple movies/videos, shuffled):
|
||||
```sql
|
||||
-- 1. Collection + items (MediaItemId = Movie.Id from MediaVersion→MediaFile lookup)
|
||||
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
|
||||
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <movie_id>);
|
||||
-- To bulk-add items from a folder:
|
||||
INSERT INTO CollectionItem (CollectionId, MediaItemId)
|
||||
SELECT <coll_id>, mv.MovieId FROM MediaFile mf
|
||||
JOIN MediaVersion mv ON mf.MediaVersionId = mv.Id
|
||||
WHERE mf.Path LIKE '/data/music/<folder>/%'
|
||||
AND mv.MovieId NOT IN (SELECT MediaItemId FROM CollectionItem WHERE CollectionId = <coll_id>);
|
||||
|
||||
-- 2. Schedule + item (CollectionType=0, PlaybackOrder=3)
|
||||
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
VALUES (<id>, 0, 0, '<name>', 1, 1, 0);
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, <coll_id>, 0, 0, 0, 0, 0, 0, 0, 3, <schedule_id>);
|
||||
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
|
||||
-- 3-4. Channel + Playout same as show-specific (ScheduleKind=1)
|
||||
```
|
||||
|
||||
After creating: `POST /api/v1/channels/{id}/playout/reset`
|
||||
|
||||
### Channel Logo Workflow
|
||||
|
||||
Logos are stored as `Artwork` rows (ArtworkKind=2) with images in the cache directory.
|
||||
|
||||
```bash
|
||||
# 1. Create logo PNG (transparent background, white text)
|
||||
magick -size 512x180 xc:transparent -font "DejaVu-Sans-Bold" -pointsize 48 \
|
||||
-fill white -stroke black -strokewidth 2 -gravity center \
|
||||
-annotate +0+0 "CHANNEL NAME" PNG32:/tmp/logo.png
|
||||
|
||||
# 2. Calculate SHA256 and place in ErsatzTV cache
|
||||
HASH=$(sha256sum /tmp/logo.png | cut -d' ' -f1 | tr 'a-f' 'A-F')
|
||||
LOGO_DIR=~/downloadswarm/ersatztv/cache/artwork/logos
|
||||
sudo mkdir -p "$LOGO_DIR/${HASH:0:2}"
|
||||
sudo cp /tmp/logo.png "$LOGO_DIR/${HASH:0:2}/$HASH"
|
||||
|
||||
# 3. Insert Artwork row (stop container first for writes)
|
||||
docker stop ersatztv
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "
|
||||
INSERT INTO Artwork (ArtworkKind, ChannelId, DateAdded, DateUpdated, Path)
|
||||
VALUES (2, <channel_db_id>, datetime('now'), datetime('now'), '$HASH');
|
||||
"
|
||||
docker start ersatztv
|
||||
|
||||
# 4. After ETV restarts, push logos to Jellyfin (see docs/Docker/ErsatzTV.md for fix_logos.py)
|
||||
```
|
||||
|
||||
**Important**: Channel DB Id (from Channel table) is NOT the channel number. E.g., channel #407 might have DB Id 43.
|
||||
|
||||
## Volume Mounts (matches Jellyfin)
|
||||
|
||||
| Host Path | Container Path |
|
||||
|-----------|---------------|
|
||||
| `~/downloadswarm/ersatztv` | `/config` |
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` (ro) |
|
||||
| `/mnt/episodes` | `/data/episodes` (ro) |
|
||||
| `/mnt/media/movies` | `/data/movies` (ro) |
|
||||
| `/mnt/media/standup` | `/data/standup` (ro) |
|
||||
| `/mnt/media/music_videos` | `/data/music` (ro) |
|
||||
|
||||
## FFmpeg & Hardware
|
||||
|
||||
- **QSV encode + VA-API decode on Intel (iHD)** — ErsatzTV runs on **jazz** (i7-10700K, Intel iGPU) since #633. The single `FFmpegProfile` row (`Id = 1`, referenced by all 43 channels) has `HardwareAcceleration = 1` (**Qsv**), `QsvPreferNativeDecoder = 1` (ON), `QsvExtraHardwareFrames = 64`, `VaapiDevice = /dev/dri/renderD128`. Verified live 2026-07-26. The profile is still *named* "1080p VAAPI h264 aac" — cosmetic, ignore the name.
|
||||
- **The old "do NOT set QSV" rule is RETIRED — #498 fixed the blocker it was based on.** The 2026-07-20 regression was real (QSV's *decoder* is far stricter than VAAPI about malformed NAL units and failed 3 of 6 cold-starts: `Error splitting the input into NAL units`), and the stated cause was that one `HardwareAcceleration` column governed both decode and encode. **#498 added `QsvPreferNativeDecoder` (default ON, Linux-only)**, which splits them exactly like Jellyfin: decode with the tolerant VA-API decoder, encode with QSV. That is what prod runs now. Do not "fix" prod back to `3` (Vaapi) on the strength of the old note.
|
||||
- **Two QSV traps already paid for, both fixed in code — don't re-derive them:**
|
||||
- `QsvExtraHardwareFrames` must never be `0`: the software→QSV `hwupload` bridge has no headroom and the transcode writes **zero segments** on any unthrottled read (#523/#529). Code now floors it at 64 (`ffmpeg.qsv-extra-hw-frames-floor`).
|
||||
- **HDR tonemapping never uses `vpp_qsv=tonemap`** — on this Gen9.5 iGPU that filter is a *silent no-op* (byte-identical output, exit 0, no warning), so it looked like GPU tonemapping while doing nothing. ErsatzTV now tonemaps via VA-API→OpenCL (#505, `ffmpeg.qsv-hdr-tonemap-opencl`). Same trap applies to Jellyfin's `EnableVppTonemapping` on this host — keep it off.
|
||||
- Fallback if VAAPI also misbehaves (see #631, VAAPI `hwupload -22` on 10-bit): `HardwareAcceleration = 0` (software). jazz has 16 threads at load ~2, so it is affordable and maximally tolerant of imperfect sources.
|
||||
- Resolution: 1920x1080, H264, AAC stereo
|
||||
- Device: `/dev/dri` passed through (`renderD128`)
|
||||
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf — **jazz uses 1 (Qsv)** with `QsvPreferNativeDecoder` ON (see above)
|
||||
- jazz's iGPU is shared with Jellyfin only (Frigate stayed on bumblebee); render GID is 992 on both hosts, so `group_add: '992'` carried over unchanged
|
||||
|
||||
## Jellyfin Integration
|
||||
|
||||
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
|
||||
- **ErsatzTV** library ids (verified 2026-07-26): Jellyfin source → Movies **10**, TV Shows **11**,
|
||||
Music Videos **16**; Local source → Standup **14**. These are *ErsatzTV* ids and are **not** the same
|
||||
as Jellyfin's own library ids — don't reuse one for the other. Re-derive with
|
||||
`GET /api/v1/media-sources` rather than trusting this list.
|
||||
- Scan a library with `POST /api/v1/libraries/{id}/scan` (there is no `PUT …/sync`).
|
||||
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Post-move to jazz (#633)
|
||||
- **Any rsync from bumblebee's `~/downloadswarm/ersatztv/` re-reverts the QSV setting** — it overwrites `ersatztv.sqlite3`, restoring bumblebee's AMD-era values. Apply config changes **after** the final sync, then re-verify. (Same trap for Jellyfin's `encoding.xml` and `livetv.xml`.)
|
||||
- **The config dir has root-owned files** (`ersatztv.sqlite3`, `cache/channel-guide/*`), so rsync needs sudo at **both** ends:
|
||||
```bash
|
||||
sudo rsync -a --delete -e "ssh -i /home/timothy/.ssh/id_rsa" --rsync-path="sudo rsync" \
|
||||
timothy@192.168.1.99:/home/timothy/downloadswarm/ersatztv/ /home/timothy/downloadswarm/ersatztv/
|
||||
```
|
||||
- **Dispatcharr caches ErsatzTV's XMLTV.** Repointing its DB rows is not enough — it keeps serving a stale EPG full of dead `ersatztv:8409` artwork URLs (breaks Kodi artwork). Force a refresh (EPG source 9):
|
||||
```bash
|
||||
ssh timothy@192.168.1.99 'docker exec dispatcharr python manage.py shell -c \
|
||||
"from apps.epg.tasks import refresh_epg_data; refresh_epg_data(9)"'
|
||||
```
|
||||
- **`/api/health` returns 401** (needs an API key). The Telegraf probe has no `response_string_match`, so ErsatzTV reads as **unhealthy in Grafana** — a false alarm, and **pre-existing**, not caused by the move. The container healthcheck uses the unauthenticated internal `/health` and is unaffected.
|
||||
- **A Komodo deploy alone may not apply bind-mounted config changes** — containers kept serving the pre-checkout inode despite a current `deployed_hash`. `docker restart` explicitly and verify inside the container.
|
||||
|
||||
### Common Mistakes (check every time)
|
||||
- **Playout not building**: Three things must all be correct: (1) `ProgramScheduleOneItem` row exists for the schedule item, (2) `PlaybackOrder=3` (Shuffle), (3) `ScheduleKind=1` on Playout. Missing any one results in 0 playout items — this is the most common issue.
|
||||
- **Collection queries fail**: `CollectionItem` has no `Id` column — use `rowid` for counting. Content lookup goes through `MediaVersion.MovieId` → `MediaFile.Path` (not a generic MediaItemId join).
|
||||
- **Channel logos forgotten**: After creating a channel, add an Artwork row (ArtworkKind=2) + logo file, then run `fix_logos.py` to push to Jellyfin. Without this, the channel shows no logo in the EPG.
|
||||
- **Playout reset required**: After any schedule/collection change, run `POST /api/v1/channels/{id}/playout/reset`. Wait 5-10s for the playout to build before verifying item count.
|
||||
|
||||
### Streaming mode + the Dispatcharr reliability fix — #500
|
||||
Consumers reach ETV **only through Dispatcharr** (`ErsatzTV → Dispatcharr → Jellyfin/Kodi`), which proxies every channel with `ffmpeg -i <etv-url> -c copy -f mpegts`. **Both HLS Segmenter (`StreamingMode=4`) and MPEG-TS (`StreamingMode=1`, `ts-legacy`) work** — Dispatcharr remuxes either to mpegts, and ETV's HLS segments are themselves mpegts with in-band SPS/PPS, so `-c copy` carries codec init either way. We run **42 channels on HLS** (ETV default; ts-legacy showed more visual glitching) + Jungle(407) on TS.
|
||||
- **What the ~6 s cold-start actually was — ersatztv#350 (fixed 2026-07-20).** `-readrate 1.05` paces input at wall clock so the channel behaves like live TV, and it applies from the **first** read; with 4 s HLS segments a throttled session could not serve the playlist sooner than ~3.8 s. Only `workAheadSegmenterLimit` sessions (prod: **1**, see `/api/v1/settings/ffmpeg`) start unthrottled, so **concurrent tune-ins are the slow ones** — measured 866 ms for the slot winner vs 3845/6357 ms for two simultaneous tunes. Subtitle burn-in, source GOP length and NFS were investigated and **ruled out** (accurate-seek costs 30–100 ms). Fixed with `-readrate_initial_burst` (5369 → 648 ms at the ffmpeg level); end-to-end verification tracked in `timothy/ersatztv#519`, so until that lands treat it as expected rather than confirmed. Diagnose with `docker logs ersatztv | grep "HLS cold-start"` — the line splits `setup / startup (prep + ffmpegInit + firstGop) / fill`.
|
||||
- **The reliability bug was NOT the streaming mode — it was a Dispatcharr teardown race.** Any tune spins up a fresh ETV transcode (historically ~6 s cold-start, same for HLS and TS — see above). With Dispatcharr's default `channel_shutdown_delay=0`, the instant a client's open-timeout drops it the channel tears down, and the retry hits a 503 → ETV cold-starts again → death-spiral (Dispatcharr#503/#851). **Fix lives in Dispatcharr: `channel_shutdown_delay=15`** (see dispatcharr skill → Gotchas). Verified by reverting all channels to HLS while keeping the delay → reliable starts + correct audio sync (2026-06-28).
|
||||
- **Corrected theory:** the first #500 pass blamed HLS for `Invalid avcC`/codec-init and switched everything to MPEG-TS. **That was wrong** — `-c copy` of mpegts HLS segments carries SPS/PPS fine; the `avcC` log line was transient/info-level and appeared on TS too. The isolation test (HLS + the delay) proved `channel_shutdown_delay` was the actual fix, and we reverted to HLS for better quality.
|
||||
- Flip a channel's mode live (no restart — ETV reads it per M3U request): `UPDATE Channel SET StreamingMode=4 WHERE …;` then sync Dispatcharr's stored stream URL for that channel (`.m3u8?mode=segmenter` ↔ `.ts?mode=ts-legacy`).
|
||||
- **Open / in progress:** through Dispatcharr's `-c copy` proxy, HLS showed a one-time skip-back shortly after start (Dispatcharr's `new_client_behind_seconds` repositioning the client behind live — set to 0 to test) and TS showed more glitching. Artifact tuning continues — see the dispatcharr skill and the #500 follow-up.
|
||||
|
||||
### Measuring what is actually deployed / what actually happened
|
||||
- **The api.key file is root-owned, and an unsudo'd read fails SILENTLY.** `cat` returns nothing, the
|
||||
header goes out empty, and the 401 body parses as a dict — so a naive script reports "0 channels"
|
||||
rather than an auth error. If a query returns a suspiciously empty result, **check auth before
|
||||
believing it.** (Cost a wrong reading on 2026-07-21.)
|
||||
- **A container's OCI labels lie about what is running** — they are inherited from the base image (they
|
||||
claimed `2026-06-27` on an image built minutes earlier). Tags and `StartedAt` lie too. To prove which
|
||||
build is live, compare `docker inspect <c> --format '{{.Image}}'` (the manifest digest on jazz) to the
|
||||
registry's `Docker-Content-Digest` header for that tag — not `.config.digest`. (ersatztv#350)
|
||||
- **Container log lines carry a LOCAL-time bracket (`[18:48:13 DBG]`) while `docker logs -t` emits
|
||||
UTC**, so `--since` windows silently mis-slice. For before/after measurements capture by **line
|
||||
offset** instead (`wc -l` before, `tail -n +N` after).
|
||||
|
||||
### DB & Architecture
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- Full REST CRUD is available under `/api/v1`; prefer it over direct DB writes
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — inserting into the subtype table is required or EF Core won't recognize the row
|
||||
- `/health` is the unauthenticated container-health gate; use an authenticated `/api/v1` read to verify the API
|
||||
|
||||
### Enums
|
||||
- PlaybackOrder: 2=Chronological (broken for collections — produces empty playouts), 3=Shuffle, 6=SeasonEpisode — use 3 for reliable results
|
||||
- CollectionType: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
|
||||
- ScheduleKind: 0=None (broken — playout never builds), 1=Fixed — use 1
|
||||
- StreamingMode: 4=HLS Segmenter (`…/channel/N.m3u8?mode=segmenter`) — **ETV default, what we run** (42 channels); 1=MPEG-TS (`…/channel/N.ts?mode=ts-legacy`, Jungle/407 only). Both work through Dispatcharr (it remuxes either to mpegts via `-c copy`). Read live per M3U request → flipping needs **no container restart**. The #500 reliability fix was a Dispatcharr setting (`channel_shutdown_delay`), NOT the mode — see "Streaming mode" gotcha.
|
||||
|
||||
### Channel Creation Checklist
|
||||
1. Collection + CollectionItems (for collection-based) OR MediaItemId (for show-specific)
|
||||
2. ProgramSchedule (all NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
3. ProgramScheduleItem (PlaybackOrder=3) + ProgramScheduleOneItem subtype row
|
||||
4. Channel (SongVideoMode=0, WatermarkId=1, all required columns)
|
||||
5. Playout (ScheduleKind=1)
|
||||
6. Artwork (ArtworkKind=2) + logo file in cache
|
||||
7. `POST /api/v1/channels/{id}/playout/reset`
|
||||
8. Run `fix_logos.py` to push logo to Jellyfin
|
||||
|
||||
### Logo System
|
||||
- **External-URL logos now work for the on-screen bug too** — fixed in ersatztv#502 (2026-07-20,
|
||||
`ffmpeg.external-logo-graphics-engine`). The old claim that they work for M3U but not watermark
|
||||
burn-in described a `WatermarkSelector` `File.Exists()` gate that is gone; an external logo is
|
||||
fetched, decode-budget-validated and stored in the image cache at **save** time
|
||||
(`graphics.channel-logo-caching`), so the render path never fetches over HTTP and a bad URL fails
|
||||
the save with a 422.
|
||||
- **M3U/XMLTV absolute URLs are no longer stuck on the request-derived host.** They used to bake in
|
||||
whatever host fetched the feed (the historical `http://localhost:8409` symptom, Gitea #1/#171),
|
||||
which Jellyfin can't resolve from inside its container. Set the optional advertised base URL —
|
||||
`GET`/`PUT /api/v1/settings/iptv` (`iptv.base_url`, ersatztv#340, `iptv.base-url`) — to pin them to
|
||||
a fixed public origin; unset falls back byte-identical to the old behavior. The base64-upload
|
||||
workaround in `docs/Docker/ErsatzTV.md` is only needed if that setting is left unset.
|
||||
- **No usable logo ⇒ no on-screen bug, from every attachment point** (ersatztv#510, 2026-07-26,
|
||||
`ffmpeg.watermark-resolution-unified`). A `ChannelLogo` watermark resolves through one shared
|
||||
`WatermarkSelector.ResolveWatermark` whether it came from a playout item, the channel, the global
|
||||
setting, **or a deco**. A missing cached file, an un-migrated external URL, and a channel with no logo
|
||||
artwork each render *without* a bug and log a warning. So when debugging "this channel has a watermark
|
||||
configured but no bug appears", grep the log for `has no logo artwork` / `no longer exists` before
|
||||
suspecting the ffmpeg pipeline.
|
||||
- Before #510 the **deco** path alone was unchecked and returned the generated-initials nameplate
|
||||
(`/iptv/logos/gen`) for a logoless channel — it genuinely rendered. That fallback is now off
|
||||
everywhere; reviving it via the image cache is ersatztv#652.
|
||||
- **Not covered:** the song-progress overlay is built as a `WatermarkOptions` directly by the
|
||||
streaming/troubleshooting handlers, bypassing the resolver, and is still unchecked — ersatztv#653.
|
||||
- **`/iptv/logos/gen` is unauthenticated**, unlike the rest of `/iptv`: `ConditionalIptvAuthorizeFilter`
|
||||
is a class-level attribute on `IptvController` only, and that route lives on `ArtworkController`.
|
||||
Handy for probing, and the reason a container-internal self-fetch of a generated logo succeeds.
|
||||
- **Seeding a deco watermark for testing is fully API-driven** (no SQLite needed): `POST /api/v1/watermarks`
|
||||
(needs the full required field set — check `v1.json`), `POST /api/v1/decos/groups`, `POST /api/v1/decos`,
|
||||
`PUT /api/v1/decos/{id}` (set `watermarkMode` + `watermarkIds`), then `PUT /api/v1/playouts/{id}/deco`.
|
||||
Use `watermarkMode: "Override"` to make the deco watermark the only one selected. Note branding is
|
||||
**not** testable through the troubleshooting-playback API (`testing.troubleshoot-path-cannot-test-branding`)
|
||||
— drive a real channel playout and capture a frame.
|
||||
- `logo_XX.png` files in the logos root dir are HTML garbage (broken downloads), not actual logos — ignore them
|
||||
|
||||
### Other
|
||||
- Upstream was archived in Feb 2026; `timothy/ersatztv` is the maintained fork and release source
|
||||
@@ -1 +0,0 @@
|
||||
../../../server-management/.claude/skills/jellyfin
|
||||
@@ -3,11 +3,10 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.3.4.1",
|
||||
"version": "2024.1.1",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
"rollForward": false
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-41
@@ -1,8 +1,9 @@
|
||||
|
||||
[*]
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=true
|
||||
insert_final_newline=true
|
||||
insert_final_newline=false
|
||||
indent_style=space
|
||||
indent_size=4
|
||||
|
||||
@@ -14,7 +15,7 @@ csharp_style_expression_bodied_constructors=true:none
|
||||
csharp_style_expression_bodied_methods=true:none
|
||||
csharp_style_expression_bodied_properties=true:suggestion
|
||||
csharp_style_var_elsewhere=false:suggestion
|
||||
csharp_style_var_for_built_in_types=false:none
|
||||
csharp_style_var_for_built_in_types=false:suggestion
|
||||
csharp_style_var_when_type_is_apparent=true:suggestion
|
||||
dotnet_naming_rule.local_constants_rule.severity=warning
|
||||
dotnet_naming_rule.local_constants_rule.style=all_upper_style
|
||||
@@ -41,8 +42,6 @@ resharper_braces_for_for=required
|
||||
resharper_braces_for_foreach=required
|
||||
resharper_braces_for_ifelse=required
|
||||
resharper_braces_for_while=required
|
||||
resharper_csharp_arguments_literal=positional
|
||||
resharper_csharp_arguments_named=positional
|
||||
resharper_csharp_insert_final_newline=true
|
||||
resharper_csharp_max_attribute_length_for_same_line=0
|
||||
resharper_csharp_place_accessorholder_attribute_on_same_line=never
|
||||
@@ -67,7 +66,7 @@ resharper_built_in_type_reference_style_highlighting=hint
|
||||
resharper_redundant_base_qualifier_highlighting=warning
|
||||
resharper_suggest_var_or_type_built_in_types_highlighting=hint
|
||||
resharper_suggest_var_or_type_elsewhere_highlighting=hint
|
||||
resharper_suggest_var_or_type_simple_types_highlighting=none
|
||||
resharper_suggest_var_or_type_simple_types_highlighting=hint
|
||||
resharper_web_config_module_not_resolved_highlighting=warning
|
||||
resharper_web_config_type_not_resolved_highlighting=warning
|
||||
resharper_web_config_wrong_module_highlighting=warning
|
||||
@@ -85,42 +84,7 @@ tab_width=4
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
ij_json_array_wrapping = normal
|
||||
ij_json_keep_blank_lines_in_code = 0
|
||||
ij_json_keep_indents_on_empty_lines = false
|
||||
ij_json_keep_line_breaks = true
|
||||
ij_json_keep_trailing_comma = false
|
||||
ij_json_object_wrapping = normal
|
||||
ij_json_property_alignment = do_not_align
|
||||
ij_json_space_after_colon = true
|
||||
ij_json_space_after_comma = true
|
||||
ij_json_space_before_colon = false
|
||||
ij_json_space_before_comma = false
|
||||
ij_json_spaces_within_braces = true
|
||||
ij_json_spaces_within_brackets = true
|
||||
ij_json_wrap_long_lines = false
|
||||
|
||||
[*.cs]
|
||||
# disable CA1848: Use the LoggerMessage delegates`
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
# --- Static-analysis pack adoption (ersatztv#15) ---
|
||||
# Threading analyzers and Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are enabled centrally.
|
||||
# Default their diagnostics to `suggestion`; the SDK's exact per-rule suggestion baseline lives in
|
||||
# eng/analyzers/sdk-all-suggestion.globalconfig because AnalysisLevel=latest-All otherwise injects
|
||||
# exact warning severities that outrank this bulk setting. High-value rules are promoted one at a
|
||||
# time. Explicit per-rule severities (e.g. ca1848 above) take precedence over both baselines.
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
# A collection count can never be negative. Treat comparisons that therefore collapse to a
|
||||
# constant as errors; the first promotion caught a busy/idle branch that was permanently busy.
|
||||
dotnet_diagnostic.S3981.severity = warning
|
||||
|
||||
# Blazor components: analyzers run on .razor/.cshtml @code too, and TWAE would otherwise
|
||||
# turn their default-severity findings into build errors — keep them at suggestion as well.
|
||||
[*.razor]
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
[*.cshtml]
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
@@ -1,141 +0,0 @@
|
||||
name: Build CI Toolchain Image
|
||||
|
||||
# Builds the shared CI toolchain image (.NET 10 SDK + Node 22 + prod-identical ffmpeg) and
|
||||
# pushes it to the Gitea container registry (ersatztv#390). The toolchain jobs in
|
||||
# docker-build.yml consume it via `container:`, pinned to an immutable :<sha>.
|
||||
#
|
||||
# push touching docker/ci/** -> :<short-sha> (+ :latest only from main)
|
||||
# workflow_dispatch -> manual rebuild
|
||||
# schedule (weekly) -> picks up base-image security updates
|
||||
#
|
||||
# Deliberately separate from docker-build.yml: this image changes rarely (a Dockerfile edit or
|
||||
# the weekly cron), while docker-build.yml runs on every push/PR. Coupling them would rebuild a
|
||||
# ~2GB toolchain image on every commit.
|
||||
#
|
||||
# ROLLOUT NOTE: the jobs pin an immutable :<sha>, never :latest — a broken toolchain image would
|
||||
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is therefore
|
||||
# a deliberate two-step: merge a docker/ci/Dockerfile change (this workflow publishes a new :<sha>),
|
||||
# then update the pin in docker-build.yml in a follow-up PR whose CI proves the new image works.
|
||||
# See docs/ci-cd.md -> "CI toolchain image".
|
||||
#
|
||||
# Like docker-build.yml: the Gitea registry is HTTP-only, so BuildKit needs the inline
|
||||
# `http = true` config (it does not inherit the host daemon's insecure-registries setting).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'docker/ci/**'
|
||||
- '.gitea/workflows/ci-image.yml'
|
||||
schedule:
|
||||
# Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main).
|
||||
#
|
||||
# What this cron does and does NOT do — it does **not** update any running job. The jobs in
|
||||
# docker-build.yml pin an immutable :<sha> (deliberately), so a rebuilt image is consumed only
|
||||
# when a human bumps that pin. Its actual value is twofold:
|
||||
# 1. a weekly CANARY — catches "the toolchain image no longer builds" (a NodeSource/apt/base
|
||||
# change) at a time of our choosing, rather than when you next need to bump the pin;
|
||||
# 2. it leaves a freshly-patched :latest so the next pin bump starts from a current base.
|
||||
# `no-cache` on this path is what makes both real: with the shared :buildcache, the
|
||||
# `apt-get update && apt-get install` layer would restore from cache and re-fetch nothing.
|
||||
- cron: '0 5 * * 1'
|
||||
|
||||
# Serialize per ref: concurrent builds would race on the shared :buildcache tag.
|
||||
# No cancel-in-progress — a half-pushed toolchain image is worse than a redundant build.
|
||||
concurrency:
|
||||
group: ersatztv-ci-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
CI_IMAGE: 192.168.1.95:3000/timothy/ersatztv-ci
|
||||
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# only docker/ci/Dockerfile is needed; no git describe/log here
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
# Always publish the immutable :<sha> — that is what docker-build.yml pins.
|
||||
TAGS=("${CI_IMAGE}:${SHORT}")
|
||||
# :latest is a convenience/floating pointer for humans and the weekly rebuild; jobs must
|
||||
# never consume it. Only main may move it.
|
||||
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
|
||||
TAGS+=("${CI_IMAGE}:latest")
|
||||
fi
|
||||
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<__EOT__"
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
echo "__EOT__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
printf 'tag: %s\n' "${TAGS[@]}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."192.168.1.95:3000"]
|
||||
http = true
|
||||
|
||||
- name: Login to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/ci/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
# The scheduled rebuild must bypass the cache or it is pointless: `mode=max` buildcache
|
||||
# would restore the `apt-get update && apt-get install` layer verbatim and pull in none of
|
||||
# the base updates the cron exists to collect. Push-triggered builds keep the cache.
|
||||
no-cache: ${{ github.event_name == 'schedule' }}
|
||||
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache,mode=max,ignore-error=true
|
||||
|
||||
# The Dockerfile's own build-time smoke test (dotnet --info, node, ffmpeg, ...) already ran
|
||||
# inside the build. This re-checks the *pushed* artifact end-to-end: that the registry copy
|
||||
# pulls and its toolchain runs, which is exactly what `container:` will do on every job.
|
||||
- name: Verify the pushed image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="${CI_IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
docker run --rm --entrypoint /bin/bash "$IMG" -euxc '
|
||||
dotnet --version
|
||||
dotnet ef --version
|
||||
node --version
|
||||
ffmpeg -version | head -1
|
||||
git --version
|
||||
python3 --version
|
||||
# reportgenerator --version exits 1 ("No report files specified"); probe the shim.
|
||||
command -v reportgenerator
|
||||
'
|
||||
echo "CI image OK. Pin this in .gitea/workflows/docker-build.yml -> CI_IMAGE_REF:"
|
||||
echo " ${IMG}"
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Dependency vulnerability scan
|
||||
|
||||
# Scheduled NuGet advisory scan — a Gitea-native stand-in for Dependabot (ersatztv#14).
|
||||
# Surfaces vulnerable direct/transitive packages on a schedule instead of only when a
|
||||
# `dotnet restore` happens to break. This is DETECTION ONLY; automated update PRs are
|
||||
# tracked separately (self-hosted Renovate — server-management#484).
|
||||
#
|
||||
# Scans the FULL solution (including the Scanner project, which the image build strips)
|
||||
# so coverage isn't narrower than the code we ship.
|
||||
#
|
||||
# NOTE: Gitea runs `schedule` triggers only from the default branch (main); the workflow
|
||||
# must be merged to main before the cron registers. Use `workflow_dispatch` to run on demand.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Mondays 06:00 UTC
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
# Independent of the build pipeline's concurrency group; a stale scan can be cancelled.
|
||||
concurrency:
|
||||
group: ersatztv-depscan
|
||||
cancel-in-progress: true
|
||||
|
||||
# No persistent MSBuild/Roslyn servers (ersatztv#406). Workflow `env:` does not cross workflow
|
||||
# files, so docker-build.yml's copy of these does not apply here and this has to be repeated.
|
||||
# Smaller stakes than the build pipeline — `dotnet restore` + `dotnet list` are MSBuild-driven and
|
||||
# never invoke csc, so this is lingering worker nodes (hundreds of MiB), not a 7.8 GB VBCSCompiler.
|
||||
# Worth setting anyway: this runs unattended on a Monday 06:00 cron against the same host that runs
|
||||
# prod media, and node reuse keeps workers alive ~15 min after the job.
|
||||
env:
|
||||
UseSharedCompilation: "false"
|
||||
DOTNET_CLI_USE_MSBUILD_SERVER: "0"
|
||||
MSBUILDDISABLENODEREUSE: "1"
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: NuGet vulnerable packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ErsatzTV.sln
|
||||
|
||||
- name: Scan for vulnerable packages (direct + transitive)
|
||||
# bash + `set -euo pipefail` so a failing `dotnet list` (e.g. the audit source
|
||||
# is unreachable while restore served from cache) fails the job instead of
|
||||
# falling through to a false "no vulnerable packages" green.
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Running: dotnet list package --vulnerable --include-transitive"
|
||||
dotnet list ErsatzTV.sln package --vulnerable --include-transitive 2>&1 | tee depscan.txt
|
||||
# `dotnet list package --vulnerable` exits 0 even when advisories exist, so detect
|
||||
# findings by the report marker and fail the run if any are present. Expect this to
|
||||
# be RED until ersatztv#8 clears the current NCalcSync / SQLitePCLRaw advisories;
|
||||
# after that, a red run means a NEW advisory has appeared.
|
||||
if grep -q "has the following vulnerable packages" depscan.txt; then
|
||||
echo "::error::Vulnerable NuGet packages detected — see report above (tracked: ersatztv#8)."
|
||||
exit 1
|
||||
fi
|
||||
echo "No vulnerable packages found."
|
||||
@@ -1,841 +0,0 @@
|
||||
name: Build ErsatzTV Image
|
||||
|
||||
# Builds the fork's own amd64 image and pushes it to the Gitea container registry.
|
||||
# pull_request -> test job only (no image build/push)
|
||||
# push to main -> :latest + :<short-sha> (test image; does NOT touch prod)
|
||||
# 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).
|
||||
#
|
||||
# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins
|
||||
# `:prod`, never `:latest` (enforced in the prod compose — server-management#481).
|
||||
#
|
||||
# TOOLCHAIN IMAGE (ersatztv#390): the jobs that need a toolchain (`test`, `migrations`,
|
||||
# `functional-e2e`, `api-docs`, `format`) run inside our shared CI image via `container:`
|
||||
# instead of installing .NET/Node/ffmpeg per run. It ships the .NET 10 SDK, Node 22,
|
||||
# prod-identical ffmpeg, and the dotnet-ef/reportgenerator global tools — so those jobs carry
|
||||
# no setup-dotnet, no setup-node, no apt, no `dotnet tool install`. Built by ci-image.yml from
|
||||
# docker/ci/Dockerfile. Project deps (NuGet/npm) are NOT baked in and stay on actions/cache.
|
||||
#
|
||||
# The pin below is an IMMUTABLE :<sha>, never :latest — a bad toolchain push would otherwise
|
||||
# break every converted job at once. It is repeated per job because `jobs.<id>.container.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
|
||||
#
|
||||
# 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
|
||||
# `scripts/ci-detect-docs-only.sh` as their first post-checkout step (id: detect) and gate every
|
||||
# real step on `steps.detect.outputs.docs_only != 'true'`. Crucially they STILL RUN and STILL
|
||||
# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration
|
||||
# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT
|
||||
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
|
||||
# (verified, throwaway PR #418) 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:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Concurrency is scoped per ref (originally one global group for the single
|
||||
# jazz runner; with 3 runners that serialized the whole queue). PR runs
|
||||
# parallelize across PRs and a new sync auto-cancels its superseded run.
|
||||
# Real image builds (main / v* tags) still serialize within their own ref;
|
||||
# don't push main and a v* tag simultaneously — they share :buildcache and
|
||||
# the smoke container name.
|
||||
concurrency:
|
||||
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Inside a `container:`, act_runner does NOT default `run` steps to bash — it falls back to
|
||||
# `sh -e {0}` (dash), because it can't assume bash exists in an arbitrary image. Every multi-line
|
||||
# script here is bash (`set -o pipefail`, arrays, `shopt`, `mapfile`), so dash fails them
|
||||
# immediately: `set: Illegal option -o pipefail`. Declare the shell once for the whole workflow
|
||||
# rather than per step. Non-container jobs already defaulted to bash, so this changes nothing for
|
||||
# them. (ersatztv#390 — see docs/ci-cd.md -> "CI toolchain image".)
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
IMAGE: 192.168.1.95:3000/timothy/ersatztv
|
||||
|
||||
# --- CI build memory (ersatztv#406, server-management#604) ---
|
||||
# Roslyn's `VBCSCompiler` is a *persistent* compiler server: it outlives the `dotnet build` that
|
||||
# started it and keeps its managed heap warm for the next one. Locally that is a real speedup.
|
||||
# In CI it buys nothing — each job container is torn down at the end of the run, so there is
|
||||
# never a "next build" to warm — while costing a lot: 7.8 GB RSS was measured live on bumblebee,
|
||||
# the single largest consumer on a 25 GiB host that also runs prod media. Several of those, one
|
||||
# per concurrent job container, is what drove the host to load 340 with 21 GiB swapped.
|
||||
#
|
||||
# These are MSBuild properties/switches, set here as environment variables so they apply to every
|
||||
# dotnet invocation in every job (restore/build/test/format/api-docs) without touching each call
|
||||
# site. MSBuild surfaces environment variables as properties, and `UseSharedCompilation` is only
|
||||
# defaulted to true when empty, so setting it here wins.
|
||||
#
|
||||
# NOTE: this reaches the *runner-side* dotnet jobs only. The `build` job compiles inside
|
||||
# `docker build`, where these do not propagate — the same switches are set as ENV in the
|
||||
# Dockerfile's SDK stage (docker/Dockerfile) to cover it.
|
||||
UseSharedCompilation: "false" # no persistent VBCSCompiler; csc runs per-project and exits
|
||||
DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process
|
||||
MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- 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
|
||||
|
||||
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
|
||||
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
|
||||
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
|
||||
- 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'
|
||||
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.docs_only != 'true' && steps.revalidate.outputs.skip != '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'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != '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'
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != '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'
|
||||
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'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
|
||||
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
|
||||
# per test project (via --collect above); ReportGenerator merges them into a human-readable
|
||||
# summary printed to the log and the job step summary. No floor is enforced yet ("decide on a
|
||||
# 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'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s globstar nullglob
|
||||
reports=(coverage/**/coverage.cobertura.xml)
|
||||
if [ ${#reports[@]} -eq 0 ]; then
|
||||
echo "No coverage reports found under ./coverage -- skipping summary."
|
||||
exit 0
|
||||
fi
|
||||
echo "Found ${#reports[@]} coverage report(s)."
|
||||
# reportgenerator is baked into the CI toolchain image (docker/ci/Dockerfile) and already
|
||||
# on PATH — no per-run `dotnet tool` install. Bump its version there (ersatztv#390).
|
||||
reportgenerator \
|
||||
"-reports:coverage/**/coverage.cobertura.xml" \
|
||||
"-targetdir:coverage/report" \
|
||||
"-reporttypes:TextSummary;MarkdownSummaryGithub"
|
||||
echo "::group::Coverage summary"
|
||||
cat coverage/report/Summary.txt
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then
|
||||
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".
|
||||
#
|
||||
# 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).
|
||||
- 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' }}
|
||||
continue-on-error: true
|
||||
run: scripts/ci-peak-anon.sh report
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
|
||||
# can't block image builds until it's proven reliable on the runner. Promote to a
|
||||
# required check / build dependency once green. (ersatztv#13)
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: ersatztv
|
||||
MYSQL_DATABASE: ersatztv_migrations
|
||||
# No host-port binding: the job reaches this service as mysql:3306 on the shared
|
||||
# runner network. Publishing 3306 made concurrent runs collide ("port is already
|
||||
# allocated") whenever two migrations jobs overlapped.
|
||||
#
|
||||
# `--memory`/`--cpus` here because the runner's `container.options` (`--memory=10g`)
|
||||
# applies to the JOB container ONLY, not to `services:` — verified by inspecting a live
|
||||
# migrations job: the job container reported HostConfig.Memory=10737418240, its mysql
|
||||
# service reported `mem=0 nanocpus=0`, i.e. unbounded. So every migrations run was adding
|
||||
# an uncapped MySQL to an already-tight host (ersatztv#406, server-management#604).
|
||||
#
|
||||
# NOTE (ersatztv#416): a `services:` container starts whenever the JOB starts, regardless
|
||||
# of step `if:`. So a docs-only migrations run still spins this mysql (capped, seconds) even
|
||||
# though the DDL-replay steps below are skipped. Fully skipping the service would require an
|
||||
# `if:`-skipped job, which we deliberately do NOT do for a required context — the heavy cost
|
||||
# (the 787-migration replay) is what the step gating removes.
|
||||
#
|
||||
# `--memory-swap=2g` is NOT redundant with `--memory=2g` — it is the point. Docker defaults
|
||||
# an unset `--memory-swap` to *twice* `--memory`, so `--memory=2g` alone would grant 2g RAM
|
||||
# **plus 2g of swap** (verified on bumblebee: `--memory=2g` alone → memory.max=2147483648
|
||||
# AND memory.swap.max=2147483648; with `--memory-swap=2g` → memory.swap.max=0). Setting it
|
||||
# equal to --memory disables swap for this container. That matters more here than anywhere:
|
||||
# swap thrash on this host is the whole reason this cap exists, and a swapping mysqld mid-DDL
|
||||
# is precisely the pathology behind the known `Command Timeout expired` migrations flake. We
|
||||
# want a loud OOM over silent swapping — an OOM is a clear signal to raise the cap.
|
||||
#
|
||||
# 2g is sized on measurement rather than inheritance, but honestly: a mysql:8.4 container
|
||||
# with this exact env peaked at 543 MiB during init and settled at 481 MiB idle (probed on
|
||||
# bumblebee 2026-07-17). That is init+idle, NOT the 787-migration replay, which grows caches
|
||||
# idle never touches — so treat 2g as a measured floor with headroom, not a measured
|
||||
# ceiling. The migrations job going green is what validates it. If this OOM-kills the
|
||||
# service, raise it deliberately — do not remove the cap, and do not re-enable swap.
|
||||
#
|
||||
# `--cpus=2` is a ceiling, not a reservation, and is the one number here with no measurement
|
||||
# behind it: 787 sequential DDL statements on one connection are ~1-core-bound, so 2 is
|
||||
# judgement. Revisit if the apply step's tail latency grows.
|
||||
options: >-
|
||||
--memory=2g
|
||||
--memory-swap=2g
|
||||
--cpus=2
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=30
|
||||
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
|
||||
|
||||
# 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'
|
||||
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.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
|
||||
# — no per-run `dotnet tool install`. Bump its version there (ersatztv#390).
|
||||
|
||||
# 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'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
echo "::endgroup::"
|
||||
echo "::group::SQLite apply all migrations to a fresh DB"
|
||||
export ETV_CONFIG_FOLDER="$(mktemp -d)" ETV_TRANSCODE_FOLDER="$(mktemp -d)"
|
||||
dotnet ef database update --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
echo "::endgroup::"
|
||||
|
||||
# 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'
|
||||
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
|
||||
# share a runner host (each spins its own mysql:8.4 service) and starve each other. That
|
||||
# contention produced both "Command Timeout expired" and mid-replay connection drops
|
||||
# (MySqlEndOfStreamException) — neither is a model problem. See #13 / #236.
|
||||
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::MySql model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
echo "::endgroup::"
|
||||
echo "::group::MySql apply all migrations to a fresh DB"
|
||||
# Retry the apply: under concurrent-runner MySQL contention the server can drop the
|
||||
# connection mid-replay. Each attempt resumes from __EFMigrationsHistory (EF wraps each
|
||||
# migration in its own transaction, so an interrupted migration rolls back cleanly and the
|
||||
# retry continues from the last committed one) — so this only papers over infra flakiness,
|
||||
# never a real migration failure, which fails deterministically on every attempt.
|
||||
attempt=1
|
||||
max=3
|
||||
until dotnet ef database update --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql; do
|
||||
if [ "$attempt" -ge "$max" ]; then
|
||||
echo "MySql apply failed after ${max} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "MySql apply attempt ${attempt} failed (likely runner MySQL contention); retrying in 15s..." >&2
|
||||
attempt=$((attempt + 1))
|
||||
sleep 15
|
||||
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)
|
||||
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,
|
||||
# If-Match/412, and since ersatztv#363 two lock-contention 409s) that sessions have been
|
||||
# re-running by hand. Deliberately NOT a `needs:` of `build` and not (yet) a required check, so a
|
||||
# functional-E2E flake can't block image builds or the unit-test gate — promote it to a required
|
||||
# check / build dependency once it's proven reliable (same rollout the `migrations` job used).
|
||||
# SQLite default provider -> no DB service. Runs on PRs and on main (regression net); skipped for
|
||||
# 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
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- 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
|
||||
|
||||
# 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'
|
||||
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.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != '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):
|
||||
# the toolchain image ships the same ffmpeg build prod runs, so the binary is already here.
|
||||
# That step also cost 110s of every run. The harness never *transcodes*, but since ersatztv#363
|
||||
# it does use ffmpeg to synthesize ~60 tiny testsrc clips to seed the scan-lock 409 flow (and
|
||||
# python3's stdlib sqlite3 to seed the DB rows the API can't create) — both already present in
|
||||
# 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'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
CFG="$(mktemp -d)"
|
||||
# e2e-local.sh copies wwwroot, launches the DLL in the background (logging to a file, so
|
||||
# this command substitution returns as soon as the app is ready), and prints PID/CONFIG_DIR.
|
||||
OUT="$(scripts/e2e-local.sh "$CFG")"
|
||||
printf '%s\n' "$OUT"
|
||||
PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')"
|
||||
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
|
||||
needs: [test, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image),
|
||||
# so skip the build/push/smoke steps — the job still reports success. Tag builds force
|
||||
# docs_only=false in the script, so a release is never skipped.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
|
||||
- name: Compute version and tags
|
||||
id: meta
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
INFO_VERSION="${VERSION}"
|
||||
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
|
||||
else
|
||||
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
|
||||
INFO_VERSION="${DESC#v}-${SHORT}"
|
||||
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
|
||||
fi
|
||||
echo "info_version=${INFO_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<__EOT__"
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
echo "__EOT__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "INFO_VERSION=${INFO_VERSION}"
|
||||
printf 'tag: %s\n' "${TAGS[@]}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."192.168.1.95:3000"]
|
||||
http = true
|
||||
|
||||
- name: Login to Gitea registry
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
# only publish from main or a v* tag; other refs (e.g. branch dispatch) build only
|
||||
push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
provenance: false
|
||||
build-args: |
|
||||
INFO_VERSION=${{ steps.meta.outputs.info_version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
|
||||
|
||||
- name: Smoke + IPTV E2E (assert key endpoints)
|
||||
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
NAME="etv-smoke-${{ github.run_id }}"
|
||||
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
# --memory-swap equal to --memory disables swap. Without it Docker defaults --memory-swap
|
||||
# to 2x --memory, so `--memory 2g` alone silently grants 2g RAM + 2g swap (ersatztv#406).
|
||||
docker run -d --name "$NAME" --memory 2g --memory-swap 2g \
|
||||
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
|
||||
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
|
||||
"$IMG"
|
||||
# probe ErsatzTV's web server from inside the container (image ships python3)
|
||||
cat > probe.py <<'PY'
|
||||
import urllib.request, urllib.error, sys
|
||||
try:
|
||||
urllib.request.urlopen("http://localhost:8409/", timeout=3)
|
||||
except urllib.error.HTTPError:
|
||||
pass # any HTTP status means the server is serving
|
||||
except Exception:
|
||||
sys.exit(1) # not listening yet
|
||||
PY
|
||||
ok=0
|
||||
for _ in $(seq 1 60); do
|
||||
if [ -z "$(docker ps -q --filter name="$NAME" --filter status=running)" ]; then
|
||||
echo "Container exited early"; break
|
||||
fi
|
||||
if docker exec -i "$NAME" python3 - < probe.py >/dev/null 2>&1; then
|
||||
ok=1; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$ok" != "1" ]; then
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
echo "Smoke test FAILED: ErsatzTV did not serve HTTP on :8409"
|
||||
exit 1
|
||||
fi
|
||||
echo "HTTP ready; asserting key IPTV endpoints (ersatztv#16)"
|
||||
# E2E: assert the real Jellyfin-facing surfaces serve a valid playlist + guide, not just
|
||||
# that the app answers HTTP. xmltv.xml needs channels.xml, which the scheduler writes a
|
||||
# few seconds after boot, so poll each endpoint until it returns 2xx with the right shape.
|
||||
# urlopen() returns only on 2xx (raises on 4xx/5xx), so reaching sys.exit means status OK.
|
||||
check() {
|
||||
local path="$1" needle="$2" i
|
||||
for i in $(seq 1 20); do
|
||||
if docker exec "$NAME" python3 -c "import urllib.request,sys; b=urllib.request.urlopen('http://localhost:8409$path',timeout=5).read(512).decode('utf-8','replace'); sys.exit(0 if '$needle' in b else 1)" 2>/dev/null; then
|
||||
echo " OK $path (2xx, contains '$needle')"; return 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1
|
||||
}
|
||||
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv" && check "/app/" "ChicoryTV"; then
|
||||
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide; /app/ serves the ChicoryTV SPA"
|
||||
else
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
exit 1
|
||||
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
|
||||
# artifacts — v1.json (OpenAPI spec), v1.d.ts (SPA client), endpoint-index.md — MUST
|
||||
# already be regenerated in the diff. We rebuild them from source and fail on any drift.
|
||||
# Also covers the "regenerate artifacts after merging main into a PR branch" lore bullet.
|
||||
#
|
||||
# Path-gated INSIDE the job (not via top-level `if:`) so the check always reports a
|
||||
# status on every PR and can be a required check without stalling API-free PRs: when no
|
||||
# API path changed, the expensive steps skip and the job passes trivially.
|
||||
api-docs:
|
||||
name: API docs in sync (OpenAPI + endpoint index)
|
||||
# `small` lane (ersatztv#390): this job is ~5s on the ~90% of PRs that touch no API path, but
|
||||
# it was queueing ~29 min behind the heavy jobs in the contended `ubuntu-latest` lane, which
|
||||
# only has bumblebee-runner (capacity 2) + ci-runner. `small` has capacity 4, the same base
|
||||
# image, and answers in ~5s. Moving it here (and `format`) also drops `ubuntu-latest` from 5
|
||||
# jobs to 3, which shortens the queue for `test`/`migrations`/`functional-e2e` too.
|
||||
# This is only possible because `container:` makes the job self-contained — it no longer needs
|
||||
# the runner image to supply .NET/Node.
|
||||
#
|
||||
# REVERTED to `ubuntu-latest` (server-management#604 / ersatztv#406). The caveat below the
|
||||
# original #390 rationale turned out to be the deciding factor: on an API-touching PR this
|
||||
# job does a full `dotnet build`, so it is NOT a small job, and "capacity 4 absorbs that" was
|
||||
# only true while nothing enforced the SUM of the lanes' memory caps. It didn't: 6 slots x 10g
|
||||
# on a 25 GiB host drove bumblebee to load 713 with 21 GiB swapped. The `small` lane is now
|
||||
# sized for genuinely-tiny jobs, and #604 grew the `ubuntu-latest` lane instead (ci-runner
|
||||
# 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
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect API-surface changes
|
||||
id: detect
|
||||
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"
|
||||
if printf '%s\n' "$changed" | grep -Eq '^ErsatzTV/Controllers/Api/|^ErsatzTV\.Core/Api/'; then
|
||||
echo "api_changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "API surface changed -> will verify generated artifacts are in sync."
|
||||
else
|
||||
echo "api_changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No API-surface change -> skipping regeneration (job passes)."
|
||||
fi
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.api_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.api_changed == 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Regenerate OpenAPI spec + endpoint index
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
run: ./scripts/update-openapi.sh
|
||||
|
||||
- name: Regenerate SPA API client types
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
working-directory: web
|
||||
run: npm run generate:api
|
||||
|
||||
- name: Fail on stale generated artifacts
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
run: |
|
||||
if ! git diff --exit-code -- \
|
||||
ErsatzTV/wwwroot/openapi/v1.json \
|
||||
web/src/api/generated/v1.d.ts \
|
||||
docs/endpoint-index.md; then
|
||||
echo "::error::This PR changes the API surface but its generated artifacts are stale. Run './scripts/update-openapi.sh && (cd web && npm run generate:api)' and commit v1.json / v1.d.ts / endpoint-index.md in THIS PR (CLAUDE.md → Conventions; ersatztv#303 H4/H5)."
|
||||
exit 1
|
||||
fi
|
||||
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.
|
||||
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).
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed C# files
|
||||
id: detect
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only --diff-filter=ACM "origin/${base_ref}...HEAD" -- '*.cs' 2>/dev/null || true)"
|
||||
echo "Changed .cs files in this PR:"; printf '%s\n' "$changed"
|
||||
if [ -n "$changed" ]; then
|
||||
printf '%s\n' "$changed" > /tmp/changed-cs.txt
|
||||
echo "cs_changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "-> will verify these files conform to .editorconfig."
|
||||
else
|
||||
echo "cs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No .cs change -> skipping format verify (job passes)."
|
||||
fi
|
||||
|
||||
- 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."
|
||||
exit 1
|
||||
fi
|
||||
echo "All changed .cs files conform to .editorconfig."
|
||||
@@ -1,262 +0,0 @@
|
||||
name: PR Gates
|
||||
|
||||
# Fast, git-only PR gates split out of docker-build.yml into a dedicated `on: pull_request`
|
||||
# workflow (ersatztv#535) so they are NEVER created on a tag/main push.
|
||||
#
|
||||
# WHY THIS FILE EXISTS. These checks are cheap `checkout + git diff` gates (or, for `script-tests`,
|
||||
# checkout + pytest): they carry no `container:`, run on the `small` lane (git-only, 1 GiB;
|
||||
# server-management#639), and are PR-only.
|
||||
# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags —
|
||||
# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because
|
||||
# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the
|
||||
# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase
|
||||
# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened
|
||||
# the tag's overall commit status even though the release built, scanned, and deployed fine
|
||||
# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical
|
||||
# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens
|
||||
# in the dispatch window before any step or `if:`-skip runs.
|
||||
#
|
||||
# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow
|
||||
# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole
|
||||
# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders
|
||||
# (they also encode "these steps need a PR base_ref"; harmless given the trigger).
|
||||
#
|
||||
# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of
|
||||
# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/
|
||||
# api-docs/format) that remain there. None of these jobs are required checks — branch protection
|
||||
# requires only `Build & test (.NET)`, `EF migration integrity` and `review-verdict/h10` — so
|
||||
# relocating them (which changes their status-context prefix from "Build ErsatzTV Image / …" to
|
||||
# "PR Gates / …") does not affect merges. See docs/ci-cd.md -> "PR gates workflow".
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but
|
||||
# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel.
|
||||
concurrency:
|
||||
group: ersatztv-pr-gates-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the image that
|
||||
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
|
||||
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
|
||||
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
|
||||
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
|
||||
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
|
||||
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
|
||||
#
|
||||
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
|
||||
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
|
||||
# git+grep -> keep it off the build runners.
|
||||
ci-image-pin:
|
||||
name: CI image pin matches docker/ci
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# need real history: `git log -- <path>` on a shallow clone can't find the last
|
||||
# commit that touched the image sources
|
||||
fetch-depth: 0
|
||||
- name: Verify the pin matches the last-published image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
|
||||
# only builds on pushes touching these paths — so the published image is named by the last
|
||||
# commit to touch them.
|
||||
#
|
||||
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
||||
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
|
||||
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
|
||||
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
|
||||
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
|
||||
echo "Image sources last changed in: ${expected}"
|
||||
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
|
||||
if [ "${#pins[@]}" -eq 0 ]; then
|
||||
echo "::error::No ersatztv-ci pin found in docker-build.yml at all. Every container: job must pin ersatztv-ci:<7-char-sha>; if the grep pattern stopped matching, fix it here too (docs/ci-cd.md -> 'CI toolchain image')."
|
||||
exit 1
|
||||
fi
|
||||
if [ "${#pins[@]}" -ne 1 ]; then
|
||||
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
|
||||
exit 1
|
||||
fi
|
||||
# LENGTH is a separate invariant from CORRECTNESS, and only this check covers it
|
||||
# (ersatztv#594). The resolve + staleness checks below compare RESOLVED shas, so a
|
||||
# 8/9/10-char abbreviation of the right commit sails through them green — while
|
||||
# matching NO tag in the registry, because ci-image.yml tags with
|
||||
# `git rev-parse --short HEAD` under `fetch-depth: 1`, which always yields exactly 7.
|
||||
# The failure would otherwise surface far downstream as all five `container:` jobs
|
||||
# dying at image-pull with `manifest unknown`, which reads like a registry outage.
|
||||
# This is an easy mistake to make: the natural local command prints 8 chars.
|
||||
#
|
||||
# Deliberately a literal 7, not a derived `git rev-parse --short=7`: in this full
|
||||
# clone git may widen an ambiguous abbreviation past 7, which would demand a pin
|
||||
# ci-image.yml can never publish — the exact clone-depth asymmetry noted above.
|
||||
# `${expected:0:7}` is plain string truncation, so it is safe to suggest.
|
||||
#
|
||||
# ESCAPE HATCH, if you are ever stuck: this makes 7 mandatory, so if `${expected:0:7}` ever
|
||||
# became an AMBIGUOUS prefix (two objects sharing it), the resolve check below would fail
|
||||
# and a longer pin — previously the workaround — is now rejected here first. There is no
|
||||
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
|
||||
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
|
||||
# empirical property of today's shallow clone, not an enforced invariant. Making the
|
||||
# publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of
|
||||
# scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs
|
||||
# the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') —
|
||||
# ci-image.yml's push trigger has no branches: filter, so a feature branch does publish.
|
||||
if [ "${#pins[0]}" -ne 7 ]; then
|
||||
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
|
||||
exit 1
|
||||
fi
|
||||
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
|
||||
if [ -z "$pin_full" ]; then
|
||||
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$pin_full" != "$expected" ]; then
|
||||
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
|
||||
exit 1
|
||||
fi
|
||||
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
|
||||
|
||||
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
|
||||
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
|
||||
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
|
||||
# cache-save issues seen on the relocated runner (server-management#570).
|
||||
docs-reminder:
|
||||
name: Docs update reminder
|
||||
runs-on: small # seconds-long git diff; keep it off the build runners
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Warn when a screen/route change skips the parity doc
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
|
||||
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
||||
screen_or_route=no
|
||||
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
||||
screen_or_route=yes
|
||||
fi
|
||||
parity=no
|
||||
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
|
||||
parity=yes
|
||||
fi
|
||||
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
|
||||
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
|
||||
else
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
||||
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
||||
# supersedes/superseded-by links, no rationale-prose rewrite without a Decisions-Edit: yes git
|
||||
# trailer (ersatztv#609 — never a bare substring, which prose about the marker could arm), no record
|
||||
# vanishing from the active set without an archive copy) and that the generated active catalog
|
||||
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
|
||||
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
|
||||
decisions-guard:
|
||||
name: decisions lifecycle
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Validate decision lifecycle
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
||||
- name: Active catalog in sync
|
||||
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
||||
- name: Kickoff guard
|
||||
run: bash scripts/check-kickoff-guard.sh
|
||||
|
||||
# FAILS THE RUN on a red (ersatztv#631) — like its sibling gates here it is not (yet) a required
|
||||
# status check, so it reddens the PR without hard-blocking the merge button; see the header.
|
||||
# Runs scripts/tests/ — the pytest suite covering the decision-corpus
|
||||
# parser/validator/catalog builder, the #610 migration-equivalence harness, the merge-consent
|
||||
# exemption logic and the #622 review-verdict poster. Until #631 NOTHING executed these: no
|
||||
# workflow and no Husky hook invoked pytest, so the suite guarding our merge-gating machinery was
|
||||
# local-only and a regression in it was caught only by luck. `decisions-guard` above runs that
|
||||
# code, but never its tests.
|
||||
#
|
||||
# WHY ITS OWN JOB rather than a step inside decisions-guard (which the issue proposed as the
|
||||
# cheapest home): `ci.decisions-lifecycle-flake` is a STANDING instruction that a lone
|
||||
# `decisions lifecycle` red is a known infra flake to be ignored — "do not investigate". Folding
|
||||
# the suite into that job would make a genuine pytest regression present as exactly the red every
|
||||
# session is told to wave through, which is the same silently-green failure mode #631 exists to
|
||||
# close. A distinct job name keeps a real failure unambiguous.
|
||||
#
|
||||
# Runs UNCONDITIONALLY on every PR rather than behind a `scripts/**` path filter. The suite's
|
||||
# corpus tests are fixture/tmp-repo based, but test_post_review_verdict.py and
|
||||
# test_merge_consent_exemption.py execute the REAL `scripts/post-review-verdict.sh` and
|
||||
# `.claude/hooks/pretooluse-merge-consent.sh`, so its true input set spans at least two top-level
|
||||
# directories. A `scripts/**` filter would silently miss a `.claude/hooks/**` edit — and at ~10s a
|
||||
# filter buys nothing but drift.
|
||||
script-tests:
|
||||
name: Script tests (pytest)
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
# pytest + PyYAML. PyYAML is NOT a contradiction of the dependency-free decisions READ path:
|
||||
# `decisions_lib._read_frontmatter` is hand-written precisely so validation runs where nothing
|
||||
# is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by
|
||||
# design — and `test_migration_equivalence.py` imports that module, so the suite needs it.
|
||||
# `pytest` and `yaml` are the complete third-party set, established by an AST import scan over
|
||||
# all of scripts/ rather than by reading the files that seemed relevant: the first cut of this
|
||||
# job claimed "pure stdlib", passed locally on a machine that happened to have PyYAML, and
|
||||
# went red in CI on a collection error.
|
||||
- name: Install test dependencies
|
||||
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
|
||||
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
|
||||
# test_post_review_verdict.py and test_merge_consent_exemption.py exec the REAL
|
||||
# post-review-verdict.sh / pretooluse-merge-consent.sh, which shell out to `jq` ~26 times.
|
||||
# `curl` those tests shim on PATH; `jq` they do NOT. If it were missing, the suite would fail
|
||||
# as ~20 opaque assertion errors — this turns that into one actionable line.
|
||||
- name: Preflight external tools
|
||||
run: |
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "::error::script-tests needs git on PATH but it is absent. The suite execs real" \
|
||||
"shell scripts that use it. Bake it into the runner image rather than apt-get" \
|
||||
"installing here (see ersatztv#390)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Preflight OK: $(git --version)"
|
||||
# jq gets its OWN step because its VERSION, not merely its presence, is load-bearing
|
||||
# (ersatztv#648). `--expect` makes this a TRIPWIRE: scripts/tests exercises the jq 1.6 code path
|
||||
# only because this runner ships 1.6, so an upgrade would silently delete that coverage — and
|
||||
# the three divergences found in ersatztv#643/#647 all lived exactly there. Going red forces an
|
||||
# explicit human decision instead of letting the coverage evaporate.
|
||||
#
|
||||
# The pin lives HERE and deliberately NOT in review-verdict.yml: that workflow writes the
|
||||
# branch-protection-required `review-verdict/h10` status, so pinning a version there would turn
|
||||
# any jq bump on the runner into a repo-wide merge deadlock. It gets the floor-only mode.
|
||||
# See docs/ci-cd.md -> "The jq contract".
|
||||
- name: Preflight jq version
|
||||
run: ./scripts/jq-preflight.sh --expect 1.6
|
||||
- name: Run scripts/tests
|
||||
run: PYTHONPATH=. python3 -m pytest scripts/tests -q
|
||||
@@ -1,70 +0,0 @@
|
||||
name: Renovate
|
||||
|
||||
# Self-hosted Renovate for the ErsatzTV fork (server-management#484).
|
||||
#
|
||||
# Opens dependency-update PRs against this repo (managers: nuget via CPM, github-actions).
|
||||
# Runs on the shared Gitea act_runner (bumblebee). It supersedes the *proposing* half that
|
||||
# the dependency-scan.yml (ersatztv#14) deliberately left out — that scan stays as a cheap
|
||||
# in-repo detector for now.
|
||||
#
|
||||
# Config: repo-root renovate.json (package rules, grouping, automerge policy).
|
||||
# Bot identity + tokens are injected from repo Actions secrets:
|
||||
# RENOVATE_TOKEN — PAT of the dedicated `renovate` Gitea bot (write:repository,
|
||||
# read:user, write:issue, read:organization)
|
||||
# GH_COM_TOKEN — no-scope github.com PAT for changelog/release-note fetching
|
||||
# (Renovate needs this on non-GitHub platforms; optional, degrades
|
||||
# gracefully to anonymous if unset). Named GH_, not GITHUB_, because
|
||||
# Gitea reserves the GITHUB_ secret-name prefix.
|
||||
#
|
||||
# NOTE: Gitea runs `schedule` triggers ONLY from the default branch (main); this file must
|
||||
# be on main before the cron registers. Use workflow_dispatch to run on demand — it defaults
|
||||
# to a DRY RUN (logs only, no PRs); dispatch with "Dry run" cleared to create real PRs.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dryRun:
|
||||
description: 'Dry run (full = log only, no PRs; clear for a live run)'
|
||||
type: choice
|
||||
options:
|
||||
- 'full'
|
||||
- ''
|
||||
default: 'full'
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
type: choice
|
||||
options:
|
||||
- 'info'
|
||||
- 'debug'
|
||||
default: 'info'
|
||||
schedule:
|
||||
# Mondays 03:00 UTC — ahead of the 06:00 vulnerability scan
|
||||
- cron: '0 3 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: ersatztv-renovate
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Renovate
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: renovate/renovate:43
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: http://192.168.1.95:3000/api/v1
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GH_COM_TOKEN }}
|
||||
RENOVATE_REPOSITORIES: timothy/ersatztv
|
||||
RENOVATE_AUTODISCOVER: 'false'
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@tblindustries.be>'
|
||||
# Let the dockerfile manager query our HTTP-only Gitea container registry for the
|
||||
# ersatztv-ffmpeg base image. Creds (reused from the image-push secrets) + insecureRegistry
|
||||
# live here, NOT in renovate.json, so they stay out of the committed config.
|
||||
RENOVATE_HOST_RULES: '[{"matchHost":"192.168.1.95:3000","hostType":"docker","username":"${{ secrets.REGISTRY_USER }}","password":"${{ secrets.REGISTRY_PASSWORD }}","insecureRegistry":true}]'
|
||||
RENOVATE_DRY_RUN: ${{ inputs.dryRun }}
|
||||
LOG_LEVEL: ${{ inputs.logLevel || 'info' }}
|
||||
run: renovate
|
||||
@@ -1,540 +0,0 @@
|
||||
name: Review verdict
|
||||
|
||||
# ersatztv#622 — server-side H10 enforcement.
|
||||
#
|
||||
# THE INVARIANT. `review-verdict/h10` is a REQUIRED status check on `main`, and a Gitea commit
|
||||
# status belongs to exactly ONE sha. So a commit that did not exist when a verdict was written can
|
||||
# never inherit that verdict: push a new head and the required context is simply absent, which
|
||||
# Gitea's merge-requirement check treats as not-passing. `merge_when_checks_succeed` therefore
|
||||
# refuses to fire until someone re-reviews THAT head. This is what closes the ersatztv#622 hole,
|
||||
# where the PreToolUse hook proved conditions (b) and (c) against the head at SCHEDULING time and
|
||||
# Gitea then merged whatever head happened to be green minutes later.
|
||||
#
|
||||
# WHAT THIS WORKFLOW DOES — and, importantly, does NOT do. It does NOT decide whether code was
|
||||
# reviewed; only a human/agent review does that, via `scripts/post-review-verdict.sh`, which writes
|
||||
# the `review-verdict/h10` status directly. This workflow only handles the two EXEMPT classes that
|
||||
# would otherwise deadlock, and marks everything else `pending` so the PR shows an explicit,
|
||||
# actionable blocking reason instead of a silently-missing check:
|
||||
#
|
||||
# 1. Bot-authored PRs (Renovate). Renovate uses `platformAutomerge: true` — i.e. Gitea's OWN
|
||||
# auto-merge — to land patch bumps unattended. A required verdict context with no exemption
|
||||
# would stall every dependency PR forever waiting on a human verdict.
|
||||
# 2. Docs-only PRs, mirroring the merge-consent hook's existing docs-only carve-out.
|
||||
#
|
||||
# BOTH exemptions are void when the PR touches a PROTECTED path (see PROTECTED below): the gate,
|
||||
# the CI definition, the git hooks, the scripts they call, and the CI toolchain image. A PR that
|
||||
# weakens the merge gate must never be able to exempt itself from the merge gate — that is the one
|
||||
# self-referential failure worth spending an explicit rule on. Note this also (deliberately) means
|
||||
# Renovate's `docker/ci/Dockerfile` base bumps need a real verdict; those already require the
|
||||
# manual publish-then-pin two-step (docs/ci-cd.md -> "CI toolchain image"), so unattended merge was
|
||||
# never correct for them anyway.
|
||||
#
|
||||
# WHY ITS OWN FILE, not a job in pr-checks.yml: that workflow sets `cancel-in-progress: true`, so a
|
||||
# superseding push cancels its runs. A cancelled run here would leave an EXEMPT PR with no success
|
||||
# status and no further pushes to re-trigger it — Renovate would stall silently. This workflow
|
||||
# therefore takes no cancelling concurrency group.
|
||||
#
|
||||
# This job's OWN status context ("Review verdict / Set review-verdict status (pull_request_target)")
|
||||
# is NOT the required check and is not what gates merges — `review-verdict/h10`, the status it
|
||||
# POSTS, is. Keeping them distinct is deliberate: a workflow cannot be allowed to satisfy the gate
|
||||
# merely by running successfully. The context string carries the trigger name, so the #672 switch
|
||||
# renamed it; that is safe only because it was never in branch protection's required list (which is
|
||||
# the two `docker-build.yml` job contexts plus `review-verdict/h10`). Adding it there later would
|
||||
# undo the distinction this paragraph exists to protect.
|
||||
#
|
||||
# THE CHANGED-FILE ENUMERATION IS NOT INLINE HERE (ersatztv#649). It lives in
|
||||
# `scripts/pr-changed-files.sh`, the single implementation this job and the advisory hook
|
||||
# `.claude/hooks/pretooluse-merge-consent.sh` both call. It used to be written twice, and drifted in
|
||||
# the dangerous direction: four rounds of ersatztv#643 hardening landed on the ADVISORY copy (whose
|
||||
# failure mode is a human prompt) and never reached THIS one (whose failure mode is a `success`
|
||||
# write to a required status with nobody in the loop). See
|
||||
# `docs/decisions/records/ci/shared-pr-file-enumeration.md`.
|
||||
#
|
||||
# WHY THE CHECKOUT TAKES THE PR'S **BASE** REF, NEVER THE HEAD. This job judges the PR, so the PR
|
||||
# must not be able to supply the code that judges it. Checking out `head.sha` would let a PR edit
|
||||
# `scripts/pr-changed-files.sh` to return an empty list and exempt itself — the `PROTECTED` list
|
||||
# below would flag the edit, but only if the enumeration that feeds it were trustworthy, and it
|
||||
# would be the PR's own. `base.sha` is the commit the PR merges INTO: already on `main`, already
|
||||
# reviewed. `persist-credentials: false` because nothing here pushes, and a checkout that leaves a
|
||||
# token in `.git/config` hands it to every script the job runs.
|
||||
#
|
||||
# WHY THE TRIGGER IS `pull_request_target`, NOT `pull_request` (ersatztv#672). The base-ref checkout
|
||||
# above binds the SCRIPTS this job runs to the base. It does not bind the job DEFINITION. Gitea
|
||||
# resolves a `pull_request` workflow definition from the PR's own head commit, so a PR editing THIS
|
||||
# FILE ran its own rewritten copy — which could delete the checkout above, or skip straight to
|
||||
# posting `review-verdict/h10=success` for its head sha. `PROTECTED` did not help (the rewrite
|
||||
# defines `PROTECTED` too) and neither did branch protection, which requires the *context* and
|
||||
# carries `required_approvals: 0`, so a self-posted success satisfied it outright.
|
||||
#
|
||||
# Measured on this instance (Gitea 1.25.4) rather than inferred from GitHub, because the whole point
|
||||
# is that the gate's authority is derived, not asserted. A scratch PR rewriting this file to post a
|
||||
# distinct probe context posted exactly that context, and the real `review-verdict/h10` was never
|
||||
# written at all — the base's definition never ran. Under `pull_request_target` the same rewrite was
|
||||
# ignored: the BASE definition ran and posted `h10=pending`, on both `opened` and `synchronize`,
|
||||
# with `secrets` still available.
|
||||
#
|
||||
# `pull_request_target` is normally the DANGEROUS trigger, and it is worth being explicit about why
|
||||
# that reputation does not transfer here. Its footgun is running untrusted HEAD code with a
|
||||
# privileged token. This job never checks out the head and never executes anything the PR supplies:
|
||||
# it checks out `base.sha` and runs only scripts from that tree. The base-ref checkout is what makes
|
||||
# this trigger safe, so the two must be read as one decision — reintroducing a head checkout under
|
||||
# this trigger would be far worse than the bug being fixed here.
|
||||
#
|
||||
# `branches: [main]` IS LOAD-BEARING, not cosmetic. Base resolution means the BASE branch supplies
|
||||
# the definition, so without this filter a PR opened into an attacker-pushed base branch would run
|
||||
# THAT branch's rewritten gate — trading a head-supplied definition for a base-supplied one and
|
||||
# closing nothing. It matters more than it looks because a commit status is repo-global per sha
|
||||
# (#663): a `success` forged on a head sha under a scratch base is inherited by a later, real PR
|
||||
# into `main` carrying the same head. With the filter, a PR whose base is not `main` produces no run
|
||||
# and no status at all (verified the same way).
|
||||
|
||||
# `edited` IS LOAD-BEARING (ersatztv#698 route 1), not completeness for its own sake. Gitea fires it
|
||||
# when a PR's base is retargeted, and a retarget changes the effective diff WITHOUT moving the head
|
||||
# sha — so none of the other four types fire and the per-sha status stays exactly as it was. That is
|
||||
# what made route 1 persist rather than merely exist: a PR was opened into `main`, retargeted to a
|
||||
# scratch base while this job was in flight so the enumeration read docs-only and posted an exemption
|
||||
# `success`, then retargeted BACK to `main`, where the forged success sat unchallenged on a head whose
|
||||
# diff against `main` carried a C# file (reproduced as probe PR #703; `created_at == updated_at`
|
||||
# afterwards proves nothing reclassified). With `edited`, the retarget back re-runs this job — and the
|
||||
# short-circuit below now re-derives machine-written successes instead of inheriting them, which is
|
||||
# the half that makes the re-run actually change the answer. The two are one fix; `edited` alone would
|
||||
# re-run and then bail out on the existing `success`.
|
||||
#
|
||||
# BE PRECISE ABOUT WHAT THIS BUYS: detection, not atomicity or ordering. Runs are NOT serialized, so
|
||||
# the stale run can post `success` AFTER the reclassifying run posts `pending` — restoring the forged
|
||||
# state with no further event left to correct it — and an already-scheduled auto-merge can fire in the
|
||||
# green window between them. The `main -> scratch -> main` ABA transition is therefore NARROWED and
|
||||
# observable, not closed. Tracked as ersatztv#706; do not read this block as claiming otherwise.
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, reopened, synchronize, ready_for_review, edited]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
set-verdict-status:
|
||||
name: Set review-verdict status
|
||||
runs-on: small # a few API calls; keep it off the build runners
|
||||
steps:
|
||||
# BASE, not head — see the header. `fetch-depth: 1` is enough: nothing here reads history,
|
||||
# only the working tree's `scripts/`.
|
||||
- name: Checkout the PR's BASE ref
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
# FLOOR ONLY — never `--expect` in this workflow. `--expect` pins an exact version and fails
|
||||
# when it drifts, which is right for `script-tests` (advisory) and catastrophic here: this job
|
||||
# writes `review-verdict/h10`, a REQUIRED check on `main`, so a pin would turn any jq bump on
|
||||
# the runner into a repo-wide merge deadlock. Asserting the 1.6 floor is what the gates below
|
||||
# are written against; see docs/ci-cd.md -> "The jq contract".
|
||||
#
|
||||
# A hard failure here is correct and fails CLOSED: the job dies, no `review-verdict/h10` is
|
||||
# posted, and an absent required check blocks the merge. Guarded on presence because a PR
|
||||
# whose BASE predates ersatztv#658 has no such script, and "the base is old" is not a jq
|
||||
# problem — that case is handled as an enumeration failure below, with an actionable status.
|
||||
- name: jq preflight (floor only)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -x ./scripts/jq-preflight.sh ]; then
|
||||
./scripts/jq-preflight.sh
|
||||
else
|
||||
echo "::warning::The PR's base ref has no scripts/jq-preflight.sh; skipping the version assertion. The enumeration step below will fail closed on its own."
|
||||
fi
|
||||
|
||||
- name: Classify the PR and post the review-verdict status
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
BASE_URL: ${{ github.server_url }}/api/v1
|
||||
# `scripts/pr-changed-files.sh` reads GITEA_BASE_URL (not BASE_URL) and takes owner/repo as
|
||||
# two SEPARATE arguments (not one `owner/repo` string). Getting either wrong is silent, not
|
||||
# loud: the script would fall back to its hardcoded LAN default and enumerate the wrong
|
||||
# repo, or a wrong host that answers, rather than erroring. A value already ending in
|
||||
# /api/v1 is used as-is by the script.
|
||||
GITEA_BASE_URL: ${{ github.server_url }}/api/v1
|
||||
# BOTH names, same value, on purpose. The script's precedence is
|
||||
# `ETV_GITEA_URL` > `GITEA_BASE_URL` > a hardcoded LAN default (and `ETV_GITEA_TOKEN` >
|
||||
# `GITEA_TOKEN`), because its other caller is a developer Mac using the ETV_* convention.
|
||||
# Setting only the GITEA_* names would leave this job's explicit configuration NON-
|
||||
# authoritative: a runner that happened to export a stale ETV_GITEA_URL would silently
|
||||
# enumerate a different Gitea instance and post the verdict here from a diff read there.
|
||||
# Cheap to make deterministic; leave both set even though only one is read.
|
||||
ETV_GITEA_URL: ${{ github.server_url }}/api/v1
|
||||
ETV_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
# The base BRANCH the event was raised for, passed down to the enumeration so the diff it
|
||||
# reads cannot silently be one against a different base (ersatztv#698 route 1). This comes
|
||||
# from the `pull_request_target` event payload, which is fixed at event time and is exactly
|
||||
# what a mid-run retarget cannot rewrite — the live PR object can, which is the whole bug.
|
||||
# `branches: [main]` means this is always `main` today; it is threaded through as a value
|
||||
# rather than hardcoded so the two stay consistent if the filter ever widens.
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CONTEXT="review-verdict/h10"
|
||||
# Accounts whose PRs may merge without a human verdict. Renovate only — keep this list
|
||||
# minimal and explicit; every entry is an account that can land code unreviewed.
|
||||
BOTS="renovate"
|
||||
# The bot exemption is additionally constrained by CONTENT (ersatztv#698 route 2), because
|
||||
# identity alone is not attributable to whoever wrote the code. `AUTHOR` is
|
||||
# `pull_request.user.login` — the PR's CREATOR, which is immutable — while the head a PR
|
||||
# points at is not: force-push application code onto an open Renovate branch and the PR is
|
||||
# still authored by `renovate`, still touches no protected path, and was exempted. Nothing
|
||||
# in the identity check attributes the CODE to the bot.
|
||||
#
|
||||
# Checking the pusher instead would not fix it — a git author/committer is self-asserted
|
||||
# text and forgeable. So the exemption is gated on what a dependency bump can legitimately
|
||||
# BE: an unattended merge is justified only for the manifests Renovate actually edits.
|
||||
#
|
||||
# The set is measured, not guessed: across all 11 Renovate PRs this repo has ever had, the
|
||||
# paths touched were `Directory.Packages.props` (10 of them) and `.config/dotnet-tools.json`
|
||||
# (1). The npm manifests are deliberately NOT included — see the BOT_MANIFESTS note below.
|
||||
#
|
||||
# Deliberately EXCLUDED, with the cost stated: `*.csproj` and any source file. The one
|
||||
# historical Renovate PR outside the set above is #20, which touched a `.csproj` AND two C#
|
||||
# files — and received an unattended bot exemption for a source change. Under Central
|
||||
# Package Management versions live in `Directory.Packages.props`, so a `.csproj` edit
|
||||
# attributed to Renovate is anomalous by construction. Such a PR is not blocked, it simply
|
||||
# needs a real verdict, which is the correct handling for a PR carrying source changes.
|
||||
# NOTE the npm manifests are deliberately ABSENT. An earlier draft included
|
||||
# `web/package.json` / `web/package-lock.json` "so a first SPA bump cannot deadlock". That was
|
||||
# a self-inflicted code-execution vector for zero benefit: `renovate.json` sets
|
||||
# `enabledManagers: ["nuget", "github-actions", "dockerfile"]`, so Renovate does not manage npm
|
||||
# in this repo at all, while `package.json` carries `scripts` that CI EXECUTES (`npm ci`,
|
||||
# `npm run build` in docker-build.yml). Exempting it would let a hijacked bot branch run
|
||||
# arbitrary shell in CI while every path still "looked like a manifest". If npm is ever added
|
||||
# to enabledManagers, the lockfile may be exemptible but `package.json` is not.
|
||||
BOT_MANIFESTS='^(Directory\.Packages\.props|\.config/dotnet-tools\.json)$'
|
||||
# Paths where NEITHER exemption applies, because a change here can alter the gate itself,
|
||||
# what CI runs, or what the hooks enforce.
|
||||
PROTECTED='^(\.claude/|\.gitea/|\.husky/|scripts/|docker/ci/)'
|
||||
# Docs-only: prose and decision records. Deliberately narrower than the hook's pattern,
|
||||
# which also lets .claude/.gitea/.husky through — that carve-out is safe there only
|
||||
# because it falls through to a HUMAN PROMPT, whereas here it would post a green status
|
||||
# with nobody in the loop.
|
||||
DOCS_ONLY='^(docs/|[^/]*\.md$)'
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::No GITEA_TOKEN available, so the ${CONTEXT} status cannot be written. An exempt (bot/docs-only) PR will stall until this is fixed; a normal PR is unaffected — post its verdict with scripts/post-review-verdict.sh."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh() { curl -sf -H "Authorization: token $GITEA_TOKEN" "$@"; }
|
||||
|
||||
# DEFINED HERE, BEFORE ANY USE. An earlier round defined these AFTER the classification
|
||||
# chain that calls them, so `count_matching` was `command not found` on every run, the
|
||||
# PROTECTED branch silently never fired, and three "protected path" tests still passed —
|
||||
# they reached `pending` by another route, so the guard being dead was invisible.
|
||||
#
|
||||
# HOW THE PATH PREDICATES ARE EVALUATED, and why neither obvious spelling is used.
|
||||
#
|
||||
# `producer | grep -q…` is FORBIDDEN here: `grep -q` exits at its first match, the producer
|
||||
# then takes SIGPIPE and exits 141 once the list exceeds the pipe buffer, and under
|
||||
# `set -o pipefail` the pipeline is a FAILURE even though grep MATCHED — inverting the guard
|
||||
# for exactly the large PRs that matter. Reproduced with `A.cs` + 1900 docs paths (171KB,
|
||||
# inside the enumerator's 2000-file cap): `docs_only=yes`, status 141; and a `.gitea/` path
|
||||
# made `PROTECTED` MISS. That construct predates #698 and was live on `main`.
|
||||
#
|
||||
# A here-string (`grep -q… <<< "$files"`) fixes the SIGPIPE but bash materialises a large
|
||||
# here-string via TEMPORARY STORAGE, so it can fail when the runner's temp space is full or
|
||||
# unwritable — and because these run inside `if`/`!`, that failure would flip the predicate
|
||||
# the same way. Trading a buffer bug for an environmental one is not a fix.
|
||||
#
|
||||
# So: count with `grep -c`, which DRAINS stdin (no early exit, no SIGPIPE) over an ordinary
|
||||
# pipe (no temp file), and treat grep's own exit status honestly — `grep -c` exits 1 when the
|
||||
# count is zero, which is a legitimate answer, while anything >1 is a real error and must FAIL
|
||||
# THE JOB rather than silently read as "no match". `set -e` would not catch these on its own
|
||||
# because they sit inside command substitution in a conditional.
|
||||
count_matching() { # how many lines of $2 match $1
|
||||
local out st=0
|
||||
out=$(printf '%s\n' "$2" | grep -cE "$1") || st=$?
|
||||
# NOT `exit 1`: these run inside `$( )`, so an exit leaves only the SUBSHELL and, because
|
||||
# the substitution sits in a conditional, `set -e` does not fire either — the job would sail
|
||||
# on with the predicate silently reading as "no match". Emit a NON-NUMERIC sentinel instead
|
||||
# and let the caller, at top level, refuse to classify.
|
||||
if [ "$st" -gt 1 ]; then
|
||||
echo "::error::grep failed (status ${st}) evaluating a path predicate." >&2
|
||||
printf 'ERR'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "${out:-0}"
|
||||
}
|
||||
count_not_matching() { # how many lines of $2 do NOT match $1
|
||||
local out st=0
|
||||
out=$(printf '%s\n' "$2" | grep -cvE "$1") || st=$?
|
||||
if [ "$st" -gt 1 ]; then
|
||||
echo "::error::grep failed (status ${st}) evaluating a path predicate." >&2
|
||||
printf 'ERR'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "${out:-0}"
|
||||
}
|
||||
|
||||
|
||||
# --- Is there already a verdict for THIS sha? ----------------------------------------
|
||||
# NOTE the heading no longer says "never overwrite". It cannot promise that: the read below
|
||||
# and the POST at the end of this job are not atomic, so a human verdict posted in between is
|
||||
# still overwritten. The re-read immediately before the POST narrows that window; it does not
|
||||
# close it. Tracked as ersatztv#706 rather than claimed as solved.
|
||||
# Reads the CONTEXT row for $SHA and sets ex_state / ex_creator / ex_desc / ex_human.
|
||||
# Factored into a function because it is now called TWICE — once here, and once immediately
|
||||
# before the POST (see below). An unreadable/unparseable response must NOT be read as "no
|
||||
# verdict exists": the job dies WITHOUT posting, so a transient API error can never overwrite
|
||||
# a verdict.
|
||||
#
|
||||
# The empty case is checked EXPLICITLY, not left to jq's exit status: `jq -e` over empty input
|
||||
# exits 4 on jq >= 1.7 but 0 on jq 1.6, and THE RUNNER SHIPS 1.6 (ersatztv#647) — so on a
|
||||
# transient error this guard passed, the row came back "", and the job posted over a
|
||||
# possibly-existing human verdict.
|
||||
#
|
||||
# The COMBINED endpoint is read, not `/statuses/{sha}`: the latter returns one row per POST
|
||||
# (not per context) and pages at 50, so a head with a few CI reruns can push an earlier verdict
|
||||
# off the first page.
|
||||
read_existing_verdict() {
|
||||
local json row
|
||||
json=$(gh "$BASE_URL/repos/$REPO/commits/$SHA/status?limit=100") || json=""
|
||||
if [ -z "${json//[[:space:]]/}" ] || ! printf '%s' "$json" | jq -e '.statuses | type == "array"' >/dev/null 2>&1; then
|
||||
echo "::error::Could not read existing commit statuses for ${SHA:0:7}. Refusing to post anything rather than risk overwriting an existing verdict."
|
||||
exit 1
|
||||
fi
|
||||
row=$(printf '%s' "$json" | jq -r --arg c "$CONTEXT" '[.statuses[] | select(.context == $c)] | first // {}')
|
||||
ex_state=$(printf '%s' "$row" | jq -r '.status // ""')
|
||||
ex_creator=$(printf '%s' "$row" | jq -r '.creator.login // ""')
|
||||
ex_desc=$(printf '%s' "$row" | jq -r '.description // ""')
|
||||
# A `case` prefix test rather than grep: the description is a single short string, and this
|
||||
# removes one more pipeline from a security predicate entirely. The PATTERN is a literal, so
|
||||
# there is no glob-injection concern from $ex_desc.
|
||||
# A human verdict also has to have been formed against THIS base (ersatztv#698, found in
|
||||
# round-4 review). `post-review-verdict.sh` records the base it reviewed in the status
|
||||
# description — `Review-verdict: MERGEABLE @ abc1234 (base: main)` — precisely because
|
||||
# retargeting changes the effective diff without moving the head sha (ersatztv#632).
|
||||
# Without this check the sha-binding is escapable through the HUMAN path rather than the
|
||||
# exemption path: get a genuine `success` on head H while it targets a scratch base S with
|
||||
# a benign diff, then retarget H onto `main`, where its diff contains unreviewed code. The
|
||||
# status is real, its creator is real, and it was silently inherited. The merge-consent
|
||||
# hook compares the base and would object, but that is advisory and covers only its own
|
||||
# path — a merge through the Gitea UI or API just sees a green required check.
|
||||
#
|
||||
# An ABSENT base is deliberately NOT treated as a mismatch: verdicts predating #632 carry
|
||||
# no `(base: …)`, and re-deriving over one would un-approve a genuinely reviewed head. Only
|
||||
# a base that is PRESENT and DIFFERENT is rejected, which is exactly the escape above.
|
||||
ex_human=no
|
||||
case "$ex_desc" in
|
||||
"Review-verdict:"*)
|
||||
if [ -n "$ex_creator" ]; then ex_human=yes; fi
|
||||
;;
|
||||
esac
|
||||
if [ "$ex_human" = yes ]; then
|
||||
# COMPARE, NEVER PARSE. Two earlier attempts both extracted the base out of the
|
||||
# description and both were defeated, the second in a way that looked like a fix for the
|
||||
# first:
|
||||
# * `${ex_desc##*"(base: "}` (LAST occurrence) let an APPENDED `(base: main)` override a
|
||||
# genuine `(base: probe/scratch)`;
|
||||
# * `${ex_desc#*"(base: "}` (FIRST occurrence) fixed that, but `${...%%)*}` still
|
||||
# truncates at the first `)`. `main)evil` IS A VALID GIT BRANCH NAME
|
||||
# (`git check-ref-format --branch 'main)evil'` succeeds), so a verdict earned while
|
||||
# targeting it reads `(base: main)evil)`, truncates to exactly `main`, and is
|
||||
# INHERITED after retargeting onto `main`. No forged description, no #697 needed.
|
||||
# The comment here previously asserted a `)` in a branch name "mismatches — safe
|
||||
# direction"; that was generalised from `feat/foo)bar` and is FALSE for any branch
|
||||
# whose name starts with the target base.
|
||||
#
|
||||
# So extract nothing. `post-review-verdict.sh` writes the marker LAST, so require the
|
||||
# description to END with the exact literal `(base: <this PR's base>)` and to contain
|
||||
# exactly ONE marker — which kills the append trick without having to decide which
|
||||
# occurrence is authoritative. Pure shell; no truncation exists to abuse.
|
||||
#
|
||||
# `${#}` arithmetic rather than a `grep -o | wc -l` pipeline; 7 is the length of
|
||||
# "(base: ". An ABSENT marker is still not a mismatch (verdicts predate #632).
|
||||
ex_stripped=${ex_desc//"(base: "/}
|
||||
ex_markers=$(( (${#ex_desc} - ${#ex_stripped}) / 7 ))
|
||||
if [ "$ex_markers" -ne 0 ]; then
|
||||
ex_base_ok=no
|
||||
if [ "$ex_markers" -eq 1 ]; then
|
||||
case "$ex_desc" in
|
||||
*"(base: $BASE_REF)") ex_base_ok=yes ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$ex_base_ok" != yes ]; then
|
||||
ex_human=no
|
||||
echo "${CONTEXT} on ${SHA:0:7} is a human verdict, but its recorded base does not match this PR's base '${BASE_REF}' (description: ${ex_desc}) — the reviewed diff is not this PR's diff, so it is NOT treated as a verdict for this base."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Whose verdict is it? (ersatztv#698 route 3) -------------------------------------
|
||||
# This short-circuit used to exit on ANY existing `success`, which made an exemption this job
|
||||
# wrote indistinguishable from a verdict a human wrote. That is what let a forged exemption
|
||||
# survive: obtained once — via the route-1 retarget race, a sibling workflow holding
|
||||
# status-write credentials (#697), a direct API call, or inheritance across PRs by sha (#663)
|
||||
# — it was thereafter accepted unchanged on every run, because the guard exited before it
|
||||
# looked at the PR, the base, the author or the files.
|
||||
#
|
||||
# The guard still exists for its original reason: re-posting `pending` over a real human
|
||||
# verdict would un-approve a reviewed head and stall the PR. So it discriminates by PROVENANCE.
|
||||
#
|
||||
# MEASURED on this instance (Gitea 1.25.4), on the COMBINED endpoint this job reads: a status
|
||||
# POSTed with a USER credential — how `scripts/post-review-verdict.sh` writes a verdict —
|
||||
# carries `.creator.login`, while one POSTed by an Actions job with the built-in `GITEA_TOKEN`
|
||||
# carries `"creator": null`. A real verdict read back `creator=timothy`; this job's own
|
||||
# exemption read back `creator=null`.
|
||||
#
|
||||
# BOTH conditions are required, and the DIRECTION of the test is the point: we short-circuit
|
||||
# only on something POSITIVELY identified as a human verdict. Anything else, including anything
|
||||
# we do not recognise, is RE-DERIVED. Written the other way round ("skip if it looks
|
||||
# machine-written") an unrecognised shape would be trusted — the fail-open this issue is about.
|
||||
#
|
||||
# What this does NOT claim: the test asks "was this POSTed by a user credential", NOT "by a
|
||||
# reviewer". `ETV_STATUS_AUTH` is basic auth, so head-controlled code can POST a success with a
|
||||
# non-null creator AND an attacker-chosen `Review-verdict:` description, which this guard then
|
||||
# preserves. That is #697 — provenance, not authentication.
|
||||
read_existing_verdict
|
||||
if [ "$ex_human" = yes ] && { [ "$ex_state" = "success" ] || [ "$ex_state" = "failure" ]; }; then
|
||||
echo "${CONTEXT} is already '${ex_state}' on ${SHA:0:7}, written by '${ex_creator}' as a human verdict — leaving it alone."
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$ex_state" ]; then
|
||||
echo "${CONTEXT} is '${ex_state}' on ${SHA:0:7} but is NOT an attributable human verdict (creator='${ex_creator:-null}', description='${ex_desc}') — re-deriving it from the PR's current state rather than inheriting it."
|
||||
fi
|
||||
|
||||
# --- Changed files: the SHARED enumeration, or no exemption. -------------------------
|
||||
# `scripts/pr-changed-files.sh` (from the BASE checkout) owns every guard this job used to
|
||||
# carry inline and six it did not: CR/LF rejection, `..` rejection, a closed `.status`
|
||||
# allow-list, `previous_filename` validated on EVERY row rather than only `renamed` ones,
|
||||
# termination only on a validated EMPTY page rather than a merely short one, and head-sha
|
||||
# binding across the paging round-trips. ersatztv#649.
|
||||
#
|
||||
# READ THE EXIT STATUS, NEVER THE STDOUT OF A FAILED RUN. exit 0 means "complete and bound
|
||||
# to $SHA"; anything else means "could not tell" and stdout is meaningless. That the
|
||||
# script happens to print nothing on its failure paths is redundancy, not contract —
|
||||
# `files` is therefore cleared explicitly rather than trusted to be empty. stderr is left
|
||||
# attached to the job log on purpose: its diagnostic is the only thing that distinguishes
|
||||
# a force-push mid-enumeration from a dead API.
|
||||
ENUM=./scripts/pr-changed-files.sh
|
||||
files=""
|
||||
complete=no
|
||||
enum_error=""
|
||||
if [ ! -x "$ENUM" ]; then
|
||||
# Only reachable for a PR whose BASE predates ersatztv#658. Fail closed with a readable
|
||||
# status rather than an absent one, so the PR shows why instead of stalling silently.
|
||||
enum_error="the PR's base ref (${BASE_SHA:0:7}) has no executable ${ENUM}"
|
||||
elif files=$("$ENUM" "${REPO%%/*}" "${REPO#*/}" "$PR" "$SHA" "$BASE_REF"); then
|
||||
complete=yes
|
||||
else
|
||||
files=""
|
||||
enum_error="scripts/pr-changed-files.sh could not enumerate PR #${PR} at ${SHA:0:7} exhaustively (see the step log)"
|
||||
fi
|
||||
|
||||
files=$(printf '%s\n' "$files" | grep -v '^$' || true)
|
||||
count=$(printf '%s\n' "$files" | grep -c . || true)
|
||||
echo "Changed files (${count}, complete=${complete}):"
|
||||
printf '%s\n' "$files" | sed 's/^/ /'
|
||||
|
||||
# Evaluated ONCE, at TOP LEVEL, so a failure can actually stop the job. Evaluating them
|
||||
# inline inside the `if`/`elif` chain is what hid the two defects above: a bad status or a
|
||||
# missing function turned into an empty string, `[ "" -gt 0 ]` errored, and the branch was
|
||||
# simply skipped. A non-numeric result here is fatal and posts nothing — an absent required
|
||||
# check blocks the merge, which is the correct direction.
|
||||
n_protected=$(count_matching "$PROTECTED" "$files")
|
||||
n_not_manifest=$(count_not_matching "$BOT_MANIFESTS" "$files")
|
||||
n_not_docs=$(count_not_matching "$DOCS_ONLY" "$files")
|
||||
for v in "$n_protected" "$n_not_manifest" "$n_not_docs"; do
|
||||
case "$v" in
|
||||
''|*[!0-9]*)
|
||||
echo "::error::A path predicate returned '${v}' instead of a count — the classifier is not operating, so no ${CONTEXT} status will be written for ${SHA:0:7}."
|
||||
exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
exempt=no
|
||||
reason=""
|
||||
if [ "$complete" != yes ]; then
|
||||
reason="${enum_error} — no exemption"
|
||||
elif [ "${count:-0}" -eq 0 ]; then
|
||||
reason="no changed files could be read from the API — no exemption"
|
||||
elif [ "$n_protected" -gt 0 ]; then
|
||||
reason="touches a protected path (gate/CI/hooks/scripts/ci-image) — exemptions do not apply"
|
||||
else
|
||||
# The two exemptions are evaluated as INDEPENDENT predicates rather than as a chain.
|
||||
# An `elif` chain was wrong once the bot exemption gained a second condition
|
||||
# (ersatztv#698 route 2): a Renovate PR that changes only `docs/` would enter the bot
|
||||
# branch, fail the manifest test, and never reach the docs-only branch at all — silently
|
||||
# withdrawing an exemption that the docs-only rule grants on its own merits, for any
|
||||
# author. Composing the predicates and deciding afterwards keeps each rule's meaning
|
||||
# independent of the order they happen to be written in.
|
||||
#
|
||||
# `grep -qv` asks "is there any line NOT in this allow-list", so an unrecognised path
|
||||
# withholds the exemption instead of being ignored — the same closed-set direction the
|
||||
# enumeration itself uses. Both are safe against an empty `$files` because `count -eq 0`
|
||||
# is handled above.
|
||||
# Written as `if`/`then`, never as `cmd && var=yes`: under `set -e` a bare `A && B`
|
||||
# statement whose `A` fails takes the failure as the statement's own exit status and
|
||||
# kills the job. That would fail closed here (no status posted, absent required check
|
||||
# blocks the merge) but it would do so on the ORDINARY path — every non-bot PR — so the
|
||||
# gate would look broken rather than strict. `cmd || var=yes` is safe for the same
|
||||
# reason it is confusing; both are spelled out instead.
|
||||
# BOTS is a short fixed literal, so it cannot reach the pipe buffer; it is still written
|
||||
# with an explicit status capture so a grep error cannot read as "not a bot" by accident.
|
||||
is_bot=no
|
||||
bot_hits=$(printf '%s\n' "$BOTS" | tr ' ' '\n' | grep -cxF "$AUTHOR") || bot_hits=0
|
||||
if [ "${bot_hits:-0}" -gt 0 ]; then is_bot=yes; fi
|
||||
manifests_only=no
|
||||
if [ "$n_not_manifest" -eq 0 ]; then manifests_only=yes; fi
|
||||
docs_only=no
|
||||
if [ "$n_not_docs" -eq 0 ]; then docs_only=yes; fi
|
||||
|
||||
if [ "$is_bot" = yes ] && [ "$manifests_only" = yes ]; then
|
||||
exempt=yes
|
||||
reason="authored by the '$AUTHOR' bot account, touches no protected path, and changes only dependency manifests"
|
||||
elif [ "$docs_only" = yes ]; then
|
||||
exempt=yes
|
||||
reason="docs-only change (no code, no protected path)"
|
||||
elif [ "$is_bot" = yes ]; then
|
||||
reason="authored by the '$AUTHOR' bot account, but changes files outside the dependency-manifest set — a bot ACCOUNT does not attribute the CODE at this head (the account is the PR's immutable creator; the head is not), so this needs a real verdict"
|
||||
else
|
||||
reason="awaiting an H10 review verdict for head ${SHA:0:7}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$exempt" = yes ]; then
|
||||
state=success
|
||||
desc="Exempt: $reason"
|
||||
else
|
||||
state=pending
|
||||
desc="Awaiting review verdict for ${SHA:0:7}"
|
||||
fi
|
||||
echo "Decision: state=${state} — ${reason}"
|
||||
|
||||
# LAST-MOMENT RE-READ (ersatztv#706). Classification takes several API round-trips, and a
|
||||
# reviewer can post a verdict during them — most dangerously a `failure`, which this job would
|
||||
# then overwrite with an exemption `success`, turning an explicit human rejection green. The
|
||||
# first read cannot see that; this one can. It NARROWS the window, it does not close it: there
|
||||
# is no compare-and-set on Gitea's status API, so a verdict landing between this read and the
|
||||
# POST below is still lost. Said plainly here rather than left as an implied guarantee.
|
||||
read_existing_verdict
|
||||
if [ "$ex_human" = yes ]; then
|
||||
echo "::notice::A human verdict ('${ex_state}' by '${ex_creator}') landed on ${SHA:0:7} while this job was classifying — leaving it alone and posting nothing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
payload=$(jq -n --arg s "$state" --arg c "$CONTEXT" --arg d "$desc" --arg u "$PR_URL" \
|
||||
'{state:$s, context:$c, description:$d, target_url:$u}')
|
||||
gh -X POST -H 'Content-Type: application/json' -d "$payload" \
|
||||
"$BASE_URL/repos/$REPO/statuses/$SHA" >/dev/null
|
||||
echo "Posted ${CONTEXT}=${state} on ${SHA:0:7}."
|
||||
|
||||
if [ "$state" = "pending" ]; then
|
||||
echo "::notice::This PR needs an H10 review verdict for head ${SHA:0:7} before it can merge. After reviewing, run: scripts/post-review-verdict.sh ${PR} MERGEABLE"
|
||||
fi
|
||||
@@ -0,0 +1,2 @@
|
||||
github: jasongdove
|
||||
custom: "https://www.paypal.me/jasongdove"
|
||||
@@ -1,14 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://features.ersatztv.org
|
||||
about: Features
|
||||
- name: Contact
|
||||
url: https://ersatztv.org/contact
|
||||
about: Chat Options
|
||||
- name: Community
|
||||
url: https://discuss.ersatztv.org
|
||||
about: Forum
|
||||
- name: Discussions
|
||||
url: https://github.com/ErsatzTV/ErsatzTV/discussions
|
||||
about: Discuss
|
||||
@@ -1,77 +0,0 @@
|
||||
name: Issue Report
|
||||
description: Report an issue
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this form! Please make sure to fill all fields, including the Title above.
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
|
||||
options:
|
||||
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://ersatztv.org/contact) first to troubleshoot with volunteers before creating a report.
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I'm using an up to date version of ErsatzTV (full release or develop release); We generally do not support previous older versions. If possible, please update to the latest version before opening an issue.
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Description of the problem or issue here.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro-steps
|
||||
attributes:
|
||||
label: Steps to reproduce the problem.
|
||||
description: |
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
3. Step 3
|
||||
|
||||
If this is a playback issue, follow these steps and post the resulting zip:
|
||||
1. Search for the required content using the search bar.
|
||||
2. Use the overflow/three dots menu on the content and select Troubleshoot Playback.
|
||||
3. Select the appropriate Playback Settings that trigger the undesired behavior.
|
||||
4. Click Play to start playback.
|
||||
5. Repeat steps 3 and 4 until the undesired behavior is reproduced.
|
||||
6. Click Download Results to have ErsatzTV collect relevant troubleshooting logs (ffmpeg log, ffmpeg profile, hardware capabilities, media info, etc) and compress them in a zip file.
|
||||
7. Attach the zip to this field.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: What is the current _bug_ behavior?
|
||||
description: Write down the incorrect behavior that currently happens after following the reproduction steps.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: What is the expected _correct_ behavior?
|
||||
description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Specify full version
|
||||
description: Provide the full version of ErsatzTV, which can be found below the left menu.
|
||||
placeholder: |
|
||||
25.5.0-bd695412-docker-amd64
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any additional information that might be useful to this issue.
|
||||
@@ -0,0 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/nvidia"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/vaapi"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
@@ -0,0 +1,242 @@
|
||||
name: Build Artifacts
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag'
|
||||
required: true
|
||||
type: string
|
||||
release_version:
|
||||
description: 'Release version number (e.g. v0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
info_version:
|
||||
description: 'Informational version number (e.g. 0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64:
|
||||
required: true
|
||||
apple_developer_certificate_password:
|
||||
required: true
|
||||
ac_username:
|
||||
required: true
|
||||
ac_password:
|
||||
required: true
|
||||
gh_token:
|
||||
required: true
|
||||
jobs:
|
||||
build_and_upload_mac:
|
||||
name: Mac Build & Upload
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-13
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-13
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target}}"
|
||||
|
||||
- name: Import Code-Signing Certificates
|
||||
uses: Apple-Actions/import-codesign-certs@v2
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
|
||||
p12-password: ${{ secrets.apple_developer_certificate_password }}
|
||||
|
||||
- name: Calculate Release Name
|
||||
shell: bash
|
||||
run: |
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
brew install coreutils
|
||||
plutil -replace CFBundleShortVersionString -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
|
||||
plutil -replace CFBundleVersion -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
|
||||
scripts/macOS/bundle.sh
|
||||
|
||||
- name: Sign
|
||||
shell: bash
|
||||
run: scripts/macOS/sign.sh
|
||||
|
||||
- name: Create DMG
|
||||
shell: bash
|
||||
run: |
|
||||
brew install create-dmg
|
||||
create-dmg \
|
||||
--volname "ErsatzTV" \
|
||||
--volicon "artwork/ErsatzTV.icns" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "ErsatzTV.app" 200 190 \
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
--no-internet-enable \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
|
||||
xcrun stapler staple ErsatzTV.dmg
|
||||
|
||||
- name: Cleanup
|
||||
shell: bash
|
||||
run: |
|
||||
mv ErsatzTV.dmg "${{ env.RELEASE_NAME }}.dmg"
|
||||
rm -r publish
|
||||
rm -r ErsatzTV.app
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.dmg
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.dmg
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
build_and_upload:
|
||||
name: Build & Upload
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-musl-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm64
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target }}"
|
||||
|
||||
- uses: suisei-cn/actions-download-file@v1.3.0
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
with:
|
||||
url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-06-12-14-05/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
# Define some variables for things we need
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
mkdir "$release_name"
|
||||
mv scanner/* "$release_name/"
|
||||
mv main/* "$release_name/"
|
||||
|
||||
# Build Windows launcher
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||
ls -l ErsatzTV-Windows/target/release
|
||||
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
fi
|
||||
|
||||
# Download ffmpeg
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
fi
|
||||
|
||||
# Pack files
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
else
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
fi
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.zip
|
||||
*${{ matrix.target }}.tar.gz
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.zip
|
||||
${{ env.RELEASE_NAME }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
calculate_version:
|
||||
name: Calculate version information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
tag2="${tag:1}"
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag2}-${short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag}-${short}"
|
||||
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
git_tag: ${{ env.GIT_TAG }}
|
||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||
info_version: ${{ env.INFO_VERSION }}
|
||||
build_and_upload:
|
||||
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
release_tag: develop
|
||||
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
info_version: ${{ needs.calculate_version.outputs.info_version }}
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
|
||||
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_and_push:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
base_version: develop
|
||||
info_version: ${{ needs.calculate_version.outputs.git_tag }}
|
||||
tag_version: ${{ github.sha }}
|
||||
secrets:
|
||||
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,125 @@
|
||||
name: Build & Publish to Docker Hub
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
base_version:
|
||||
description: 'Base version (latest or develop)'
|
||||
required: true
|
||||
type: string
|
||||
info_version:
|
||||
description: 'Informational version number (e.g. 0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
tag_version:
|
||||
description: 'Docker tag version (e.g. v0.3.7)'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
docker_hub_username:
|
||||
required: true
|
||||
docker_hub_access_token:
|
||||
required: true
|
||||
jobs:
|
||||
build_and_push:
|
||||
name: Build & Publish
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: base
|
||||
path: ''
|
||||
suffix: ''
|
||||
qemu: false
|
||||
- name: nvidia
|
||||
path: 'nvidia/'
|
||||
suffix: '-nvidia'
|
||||
qemu: false
|
||||
- name: vaapi
|
||||
path: 'vaapi/'
|
||||
suffix: '-vaapi'
|
||||
qemu: false
|
||||
- name: arm32v7
|
||||
path: 'arm32v7/'
|
||||
suffix: '-arm'
|
||||
qemu: true
|
||||
- name: arm64
|
||||
path: 'arm64/'
|
||||
suffix: '-arm64'
|
||||
qemu: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
if: ${{ matrix.qemu == true }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: docker-buildx
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
platforms: 'linux/arm64'
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm64' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
platforms: 'linux/arm/v7'
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
@@ -0,0 +1,27 @@
|
||||
name: 'Close stale issues'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
ascending: true
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: 'regression,security,roadmap,future,feature,enhancement,confirmed'
|
||||
stale-issue-label: 'stale'
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||
|
||||
If you have any questions you can use one of several ways to [contact us](https://ersatztv.org).
|
||||
close-issue-message: |-
|
||||
This issue was closed due to inactivity.
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Pull Request
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
build_and_test_windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
cd ErsatzTV-Windows
|
||||
cargo build --release --all-features
|
||||
build_and_test_linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Release
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
jobs:
|
||||
calculate_version:
|
||||
name: Calculate version information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "ARTIFACTS_VERSION=${tag}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
git_tag: ${{ env.GIT_TAG }}
|
||||
docker_tag: ${{ env.DOCKER_TAG }}
|
||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||
info_version: ${{ env.INFO_VERSION }}
|
||||
build_and_upload:
|
||||
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
info_version: ${{ needs.calculate_version.outputs.info_version }}
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
|
||||
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_and_push:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
base_version: latest
|
||||
info_version: ${{ needs.calculate_version.outputs.git_tag }}
|
||||
tag_version: ${{ needs.calculate_version.outputs.docker_tag }}
|
||||
secrets:
|
||||
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
+1
-43
@@ -2,16 +2,7 @@
|
||||
*.*~
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
# Code-coverage output (dotnet test --results-directory ./coverage, ersatztv#15)
|
||||
/coverage/
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
.mcp.json
|
||||
.agents/
|
||||
plugins/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
@@ -46,43 +37,10 @@ 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
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
ErsatzTV/wwwroot/v2/
|
||||
ErsatzTV/wwwroot/app/
|
||||
web/dist/
|
||||
web/node_modules
|
||||
|
||||
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
|
||||
/*.png
|
||||
.playwright-mcp/
|
||||
# UI-E2E run artifacts: traces/screenshots Playwright writes on failure (outputDir in
|
||||
# web/playwright.config.ts), plus the report dir it would use if a reporter is ever added (#445).
|
||||
web/e2e/.output/
|
||||
web/playwright-report/
|
||||
|
||||
# Per-session worktree-ownership marker (H7, ersatztv#303) — local, never committed
|
||||
.claude-worktree-owner
|
||||
|
||||
# Codex CLI project scaffolding — a machine-local mirror of the .claude hooks, generated by
|
||||
# `codex exec`. Deliberately NOT tracked even though `.claude/` is: its config.toml embeds a
|
||||
# plaintext Gitea credential and absolute /Users paths, so it is neither portable nor safe to
|
||||
# commit. See ersatztv#711 for the related merge-gate gap.
|
||||
.codex/
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Enforce the CLAUDE.md protocol: every commit message must carry a Co-Authored-By
|
||||
# trailer. Merge commits are exempt (their MERGE_MSG has no trailer and shouldn't be
|
||||
# rewritten).
|
||||
if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
grep -q '^Co-Authored-By:' "$1" || {
|
||||
echo 'husky - commit message missing Co-Authored-By trailer'
|
||||
exit 1
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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)
|
||||
if [ -n "$root_png" ]; then
|
||||
echo "husky - refusing to commit root-level screenshot(s):"
|
||||
printf ' %s\n' $root_png
|
||||
echo " Move it out of the repo root or drop it (root *.png are review/debug artifacts; see .gitignore)."
|
||||
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).
|
||||
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"
|
||||
# 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"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
@@ -1,28 +0,0 @@
|
||||
# H6 merge-consent backstop (ersatztv#303): gate a direct push to main on the linked issue's
|
||||
# ## Done-when checklist. Read git's pre-push ref lines FIRST (before the web checks below, which
|
||||
# may consume stdin) and forward them. Fail-open: no creds / not main / docs-only -> allow.
|
||||
_prepush_refs="$(cat)"
|
||||
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-donewhen.sh || exit 1
|
||||
|
||||
# Git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE while running hooks. In a worktree
|
||||
# (or any subdir), an explicit GIT_DIR makes nested `git` commands mislocate the working
|
||||
# tree — notably `check:api`'s `git diff --exit-code` (run from web/) silently reports "no
|
||||
# diff" and lets drift through. Unset them so nested git rediscovers the repo normally.
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
|
||||
# H11 (ersatztv#311): refuse to push a branch that is BEHIND origin/main — rebase, don't merge
|
||||
# main in (a merge drags in files you never touched, e.g. legacy-BOM .cs, and trips the format
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
|
||||
./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
|
||||
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
|
||||
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
|
||||
# index/worktree trap: a review fix left in the working tree shipped without being committed).
|
||||
# Runs before the slow CI-parity checks so it fails fast. Fail-open; escape ETV_ALLOW_DIRTY_PUSH=1.
|
||||
./.claude/hooks/prepush-clean-worktree-check.sh || exit 1
|
||||
|
||||
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
|
||||
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
|
||||
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
|
||||
# only sees staged files).
|
||||
cd web && npm run check:api && npm run lint && npm run typecheck && npm run build
|
||||
@@ -1,15 +0,0 @@
|
||||
# Codex Instructions
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution.
|
||||
|
||||
Preferred verification commands:
|
||||
|
||||
```bash
|
||||
TZ=UTC dotnet restore ErsatzTV.sln -v minimal
|
||||
TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal
|
||||
TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal
|
||||
```
|
||||
|
||||
Use scoped escalated execution for these commands rather than first trying a sandboxed run.
|
||||
+4
-999
File diff suppressed because it is too large
Load Diff
@@ -1,113 +0,0 @@
|
||||
# ErsatzTV Fork
|
||||
|
||||
Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https://github.com/ErsatzTV/ErsatzTV) after upstream archival (Feb 2026, v26.3.0). Our fork lives on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **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`
|
||||
|
||||
### Key Files
|
||||
|
||||
- **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs` → `ToM3U()`
|
||||
- **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
|
||||
- **IPTV controller**: `ErsatzTV/Controllers/IptvController.cs` — `/iptv/*` routes
|
||||
- **Logo generation**: `ErsatzTV.Core/Images/ChannelLogoGenerator.cs`
|
||||
- **Channel entities**: `ErsatzTV.Core/Domain/Channel.cs`
|
||||
- **DB context**: `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
|
||||
## 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
|
||||
- **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`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Docker build
|
||||
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-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 |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| 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.
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
|
||||
- Keep UI thin: the SPA talks to `/api/*` only; controllers delegate to MediatR handlers. All UI is in the SPA (`web/`)
|
||||
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
|
||||
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
|
||||
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
|
||||
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Their `review-verdict/h10` required check is auto-passed **only when BOTH hold**: the PR touches none of `.claude/`/`.gitea/`/`.husky/`/`scripts/`/`docker/ci/`, **and** every changed path is a dependency manifest (`Directory.Packages.props`, `.config/dotnet-tools.json`) — ersatztv#698. A bot ACCOUNT does not attribute the CODE at a head, so identity alone is no longer sufficient; a Renovate PR touching a `.csproj` or a source file is not blocked, it just needs a real verdict. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
|
||||
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
|
||||
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
|
||||
|
||||
## Working in parallel with other sessions
|
||||
|
||||
**Subagents are explicitly permitted and encouraged here.** Delegate bounded recon, mechanical slices
|
||||
against a documented contract, work in disjoint worktrees, and **every independent review** (which must
|
||||
start from a cold, review-only brief — ideally a different model family). Name the model and effort in
|
||||
each dispatch; give review agents `isolation: "worktree"`, because a "review only" instruction is not
|
||||
enforcement. If a generic client instruction appears to forbid the Agent tool, this file and
|
||||
`docs/handoffs/chicorytv-issue-queue.md` override it — say so once and carry on. Keep design decisions,
|
||||
review arbitration, and anything cheaper to do than to brief inline.
|
||||
|
||||
**Claiming an issue is a check, not just a label** (`process.parallel-session-claim`). `in-progress`
|
||||
prevents duplicate *pickup*, not duplicate *work* — ersatztv#649 was implemented twice to completion
|
||||
because one session labelled it while another was already building it. Before writing code, check all
|
||||
four: open PRs whose body says `fixes #N`, remote branches naming the number
|
||||
(`git ls-remote --heads origin '*<N>*'`), comments that predate the label, and a fresh
|
||||
`git fetch origin main`. Then apply the label **and** a claiming comment.
|
||||
|
||||
**Re-fetch `origin/main` before every push, not only at branch time.** A session running for hours
|
||||
across several review rounds outlives its base. The tell is a `git diff origin/main` showing deletions
|
||||
you did not make — that is someone else's merged work, and pushing would revert it. Rebase (never merge
|
||||
main in) and re-run the local gate whenever the fetch shows movement.
|
||||
|
||||
## Task Completion Protocol
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
|
||||
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), run **`scripts/post-review-verdict.sh <pr> <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> [note]`** — it posts both the `Review-verdict: … @ <head-sha>` comment and the sha-bound `review-verdict/h10` commit status, proving the *latest* commit was reviewed rather than a stale earlier diff (ersatztv#242). Do not hand-write the comment: the **status** is the required check branch protection enforces, and a comment alone leaves it absent.
|
||||
- **The gate is enforced server-side, per sha (ersatztv#622).** `review-verdict/h10` is a required status check on `main`. Because a commit status belongs to one sha, a commit pushed *after* an auto-merge is scheduled clears it and blocks the merge — closing the hole where `merge_when_checks_succeed` froze consent at scheduling time and Gitea later merged an unreviewed head. Renovate-authored and docs-only PRs are auto-passed by `.gitea/workflows/review-verdict.yml`, **except** when they touch `.claude/`, `.gitea/`, `.husky/`, `scripts/` or `docker/ci/`. See `docs/ci-cd.md` → Review-verdict gate.
|
||||
- `.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.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, and the **`ersatztv` skill** — whose canonical copy is `.claude/skills/ersatztv/SKILL.md` **here**; `~/server-management/.claude/skills/ersatztv` is a symlink to it (ersatztv#617). Edit it in this repo; never fork a second copy.
|
||||
|
||||
**ersatztv does NOT own**:
|
||||
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
|
||||
- NFS mounts, Ansible, DNS, networking → server-management
|
||||
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
|
||||
- Jellyfin skill → server-management. `.claude/skills/jellyfin` here is a **relative symlink** to `~/server-management/.claude/skills/jellyfin` (ersatztv#617 — it had silently become a stale divergent copy). It therefore resolves only in a checkout at `~/ersatztv`, not inside a git worktree; that is inherent to the cross-repo symlink pattern server-management already uses (`beets`, `radarr`, `sonarr`, …).
|
||||
|
||||
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
|
||||
|
||||
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
|
||||
|
||||
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
|
||||
|
||||
**Full cross-project rules**: `~/homelab-docs/Operations/Project Boundaries.md` (https://docs.tblindustries.be).
|
||||
**ErsatzTV docs**: `~/homelab-docs/Docker/ErsatzTV.md` + project-local `docs/` (fork strategy, channels, M3U/XMLTV).
|
||||
+1
-23
@@ -2,27 +2,5 @@
|
||||
<PropertyGroup>
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<!-- Analyzer posture (ersatztv#15): enable the complete SDK rule set and the
|
||||
threading analyzer in every centrally managed project. The checked-in globalconfig
|
||||
keeps the SDK baseline at suggestion; individually promoted rules become CI-blocking. -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>latest-All</AnalysisLevel>
|
||||
<EnableThreadingAnalyzers>true</EnableThreadingAnalyzers>
|
||||
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
|
||||
packages as NU1901-1904 warnings. Several projects set
|
||||
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
|
||||
on advisories we can't immediately fix. Demote low/moderate/high audit
|
||||
advisories to warnings (still printed in build logs); NU1904 (critical)
|
||||
stays an error so criticals still block. Track fixes separately.
|
||||
WarningsAsErrors promotes NU1904 in EVERY project (even those without
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide.
|
||||
S3981 is the first explicitly promoted analyzer rule (ersatztv#15). -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904;S3981</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EditorConfigFiles Include="$(MSBuildThisFileDirectory)eng/analyzers/sdk-all-suggestion.globalconfig" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,39 +0,0 @@
|
||||
<Project>
|
||||
<!-- Guard on CPM so the gitignored .mcp tool, which deliberately uses inline package
|
||||
versions, does not inherit a versionless analyzer PackageReference. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every centrally managed project.
|
||||
Versions are central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored
|
||||
.mcp tool (which opts out of CPM) doesn't pull versionless references. They start at
|
||||
`suggestion` severity in .editorconfig so they don't fail the TreatWarningsAsErrors build;
|
||||
high-value rules are promoted to warning/error incrementally. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference Include="Roslynator.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="SonarAnalyzer.CSharp">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Meziantou.Analyzer">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<!-- StyleCop.Analyzers intentionally omitted: its latest stable (1.1.118) crashes
|
||||
(AD0001) on C# records and its rules overlap the existing .editorconfig/Roslynator.
|
||||
Revisit via the record-compatible 1.2.0-beta if StyleCop is specifically wanted. (#15) -->
|
||||
<PackageReference Include="AsyncFixer">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,109 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
|
||||
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.3" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.79" />
|
||||
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
|
||||
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6053" />
|
||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.10" />
|
||||
<PackageVersion Include="Jint" Version="4.5.0" />
|
||||
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
|
||||
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageVersion Include="LanguageExt.Transformers" Version="4.4.8" />
|
||||
<PackageVersion Include="Lennox.NvEncSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.129" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<!-- Direct-pin over the 2.0.0 transitive (from Microsoft.AspNetCore.OpenApi + Scalar.AspNetCore):
|
||||
2.0.0 is GHSA-v5pm-xwqc-g5wc (High — stack overflow parsing a circular $ref). Fixed in 2.7.5.
|
||||
Referenced directly in ErsatzTV.csproj so the override actually resolves (CPM). See ersatztv#314/#8. -->
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="[8.0.2]" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
|
||||
<PackageVersion Include="NCalcSync" Version="6.3.2" />
|
||||
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="NUnit" Version="4.4.0" />
|
||||
<PackageVersion Include="NUnit.Analyzers" Version="4.11.2" />
|
||||
<PackageVersion Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<PackageVersion Include="Refit" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.HttpClientFactory" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.Newtonsoft.Json" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.Xml" Version="9.0.2" />
|
||||
<PackageVersion Include="RichTextKit.Stbear" Version="0.4.167.3" />
|
||||
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.12.32" />
|
||||
<PackageVersion Include="Scriban.Signed" Version="7.2.5" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
|
||||
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.27.0.140913" />
|
||||
<!-- Direct pin to override EF Core 9's transitive SQLitePCLRaw 2.1.10 (vulnerable
|
||||
bundled SQLite, GHSA-2m69-gcr7-jv3q). The 3.x line ships the patched native
|
||||
(lib.e_sqlite3 3.50.3); core 3.0.4 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Testably.Abstractions.Testing" Version="5.1.0" />
|
||||
<PackageVersion Include="TimeSpanParserUtil" Version="1.2.0" />
|
||||
<PackageVersion Include="TimeZoneConverter" Version="7.2.0" />
|
||||
<PackageVersion Include="VueCliMiddleware" Version="6.0.0" />
|
||||
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageVersion Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
|
||||
Generated
+1035
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "ersatztv_windows"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tray-item = { git = "https://github.com/olback/tray-item-rs" }
|
||||
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
|
||||
process_path = "0.1.4"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.43.0"
|
||||
features = [
|
||||
"Win32_System_Console",
|
||||
"Win32_Foundation"
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
windres = "*"
|
||||
static_vcruntime = "2.0"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,6 @@
|
||||
use windres::Build;
|
||||
|
||||
fn main() {
|
||||
static_vcruntime::metabuild();
|
||||
Build::new().compile("ersatztv_windows.rc").unwrap();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
id ICON "ersatztv.ico"
|
||||
ersatztv-icon ICON "ersatztv.ico"
|
||||
@@ -0,0 +1,109 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use special_folder::SpecialFolder;
|
||||
use std::fs;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use windows::Win32::System::Console;
|
||||
use {std::sync::mpsc, tray_item::TrayItem};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
enum Message {
|
||||
Exit,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
tray.add_menu_item("Launch Web UI", || {
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg("http://localhost:8409")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.add_menu_item("Show Logs", || {
|
||||
let path = SpecialFolder::LocalApplicationData
|
||||
.get()
|
||||
.unwrap()
|
||||
.join("ersatztv")
|
||||
.join("logs");
|
||||
match path.to_str() {
|
||||
None => {}
|
||||
Some(folder) => {
|
||||
fs::create_dir_all(folder).unwrap();
|
||||
let _ = Command::new("explorer.exe")
|
||||
.arg(folder)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.inner_mut().add_separator().unwrap();
|
||||
|
||||
tray.add_menu_item("Exit", move || {
|
||||
tx.send(Message::Exit).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = process_path::get_executable_path();
|
||||
let mut child: Option<Child> = None;
|
||||
match path {
|
||||
None => {}
|
||||
Some(path) => {
|
||||
let etv = path.parent().unwrap().join("ErsatzTV.exe");
|
||||
if etv.exists() {
|
||||
match etv.to_str() {
|
||||
None => {}
|
||||
Some(etv) => {
|
||||
child = Some(
|
||||
Command::new(etv)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Message::Exit) => {
|
||||
match child {
|
||||
None => {}
|
||||
Some(mut child) => {
|
||||
unsafe {
|
||||
if Console::AttachConsole(child.id()) == true
|
||||
{
|
||||
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
|
||||
}
|
||||
}
|
||||
child.wait().unwrap();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule ErsatzTV-macOS updated: 8dbe1e22f2...bf18a5e4a4
@@ -29,10 +29,9 @@ internal static class Mapper
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
|
||||
return languages
|
||||
.Map(lang => allCultures.Filter(ci => string.Equals(
|
||||
ci.ThreeLetterISOLanguageName,
|
||||
lang,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
.Map(
|
||||
lang => allCultures.Filter(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
|
||||
.Flatten()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Artists.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetArtistByIdHandler(
|
||||
IArtistRepository artistRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ILanguageCodeService languageCodeService)
|
||||
: IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
|
||||
{
|
||||
_artistRepository = artistRepository;
|
||||
_searchRepository = searchRepository;
|
||||
}
|
||||
|
||||
public async Task<Option<ArtistViewModel>> Handle(
|
||||
GetArtistById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Artist> maybeArtist = await artistRepository.GetArtist(request.ArtistId);
|
||||
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
|
||||
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
|
||||
async artist =>
|
||||
{
|
||||
List<string> mediaCodes = await searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = languageCodeService.GetAllLanguageCodes(mediaCodes);
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(artist, languageCodes);
|
||||
},
|
||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public record ArtworkContentTypeModel(string Path, string ContentType)
|
||||
{
|
||||
public static readonly ArtworkContentTypeModel None = new(string.Empty, string.Empty);
|
||||
|
||||
public bool IsExternalUrl => Artwork.IsExternalUrl(Path);
|
||||
|
||||
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
|
||||
|
||||
// The artwork serve routes now sniff the content type from the stored file and no longer honor a
|
||||
// client-supplied ?contentType= (issue #283 — that reflection was the stored-XSS sink), so the
|
||||
// directly-usable URL is just the path.
|
||||
public string UrlWithContentType => Path;
|
||||
|
||||
// Defense-in-depth: never persist a content type outside the image allow-list, so a value that
|
||||
// slipped in via the {path, contentType} JSON DTOs can't later be reflected anywhere. The serve
|
||||
// path derives the type from the file regardless; this only keeps stored metadata honest.
|
||||
public ArtworkContentTypeModel Sanitized() =>
|
||||
ImageContentTypes.IsAccepted(ContentType) ? this : this with { ContentType = string.Empty };
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Artwork;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
/// <summary>
|
||||
/// Validates and stores an uploaded image as channel logo or watermark artwork,
|
||||
/// landing it in the same on-disk cache the Blazor UI uses (via <c>IImageCache</c>),
|
||||
/// so the returned path is equivalent to a Blazor-uploaded image.
|
||||
/// </summary>
|
||||
public record UploadArtwork(Stream Stream, ArtworkKind ArtworkKind)
|
||||
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
|
||||
@@ -1,81 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Artwork;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
|
||||
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 async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
|
||||
UploadArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Buffer the upload so we can sniff its true format before storing it. The request body is
|
||||
// already bounded by the Kestrel MaxRequestBodySize / the controller's size check, so this
|
||||
// is a bounded read.
|
||||
byte[] bytes;
|
||||
await using (var buffer = new MemoryStream())
|
||||
{
|
||||
await request.Stream.CopyToAsync(buffer, cancellationToken);
|
||||
bytes = buffer.ToArray();
|
||||
}
|
||||
|
||||
// Derive the content type from the actual bytes, never from the client-declared value
|
||||
// (issue #283 — a spoofed image/png header let a <script> payload be stored and later served
|
||||
// as HTML). A payload that isn't a supported raster image is rejected here.
|
||||
Option<string> maybeContentType = ImageContentTypes.DetectContentType(bytes);
|
||||
if (maybeContentType.IsNone)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Uploaded file is not a supported image; supported types are: {string.Join(", ", ImageContentTypes.Accepted)}");
|
||||
}
|
||||
|
||||
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,
|
||||
request.ArtworkKind);
|
||||
|
||||
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
|
||||
BuildPath(request.ArtworkKind, fileName),
|
||||
contentType));
|
||||
}
|
||||
|
||||
// Mirror the on-disk conventions the Blazor editors use so the returned path is a drop-in
|
||||
// for ArtworkContentTypeModel.Path: channel logos are addressed as "iptv/logos/{file}"
|
||||
// (see ChannelEditor.UploadLogo), watermarks by the bare cache file name (see WatermarkEditor).
|
||||
private static string BuildPath(ArtworkKind artworkKind, string fileName) =>
|
||||
artworkKind switch
|
||||
{
|
||||
ArtworkKind.Logo => $"iptv/logos/{fileName}",
|
||||
_ => fileName
|
||||
};
|
||||
}
|
||||
@@ -6,25 +6,24 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
|
||||
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) : IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, Artwork>> Handle(
|
||||
GetArtwork request,
|
||||
GetArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
try {
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Artwork> artwork = await dbContext.Artwork
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.Id, cancellationToken)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
|
||||
.MapT(Project);
|
||||
|
||||
|
||||
return artwork.ToEither(BaseError.New("Artwork not found"));
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -32,11 +31,12 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
}
|
||||
}
|
||||
|
||||
private static Artwork Project(Artwork artwork) =>
|
||||
new()
|
||||
{
|
||||
private static Artwork Project(Artwork artwork)
|
||||
{
|
||||
return new Artwork {
|
||||
Id = artwork.Id,
|
||||
Path = artwork.Path,
|
||||
ArtworkKind = artwork.ArtworkKind
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Shared constants for the browser-SPA session authentication (issue #295): the cookie scheme name,
|
||||
/// the custom claim types the local-login path stamps onto the principal, and the auth-method marker
|
||||
/// values. The web host (cookie <c>OnValidatePrincipal</c>, <c>AuthController</c>) and the Application
|
||||
/// handlers both reference these so the claim contract has a single definition.
|
||||
/// </summary>
|
||||
public static class AuthConstants
|
||||
{
|
||||
/// <summary>The cookie authentication scheme name shared by local login and the OIDC callback.</summary>
|
||||
public const string CookieScheme = "cookie";
|
||||
|
||||
/// <summary>The OIDC challenge scheme name.</summary>
|
||||
public const string OidcScheme = "oidc";
|
||||
|
||||
/// <summary>Claim type recording how the principal signed in (<see cref="MethodLocal" /> / <see cref="MethodOidc" />).</summary>
|
||||
public const string AuthMethodClaim = "etv:auth_method";
|
||||
|
||||
/// <summary>Claim type carrying the local admin's security stamp (checked on every request to revoke sessions).</summary>
|
||||
public const string SecurityStampClaim = "etv:security_stamp";
|
||||
|
||||
public const string MethodLocal = "local";
|
||||
public const string MethodOidc = "oidc";
|
||||
|
||||
/// <summary>Minimum length for a local admin password.</summary>
|
||||
public const int MinPasswordLength = 8;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Changes the local admin password after verifying the current one. Rotates the security stamp so all
|
||||
/// other sessions are revoked. <see cref="Username" /> is the signed-in principal's name.
|
||||
/// </summary>
|
||||
public record ChangeLocalAdminPassword(string Username, string CurrentPassword, string NewPassword)
|
||||
: IRequest<Either<BaseError, LocalAdminPrincipal>>;
|
||||
@@ -1,68 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class ChangeLocalAdminPasswordHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalPasswordHasher passwordHasher)
|
||||
: IRequestHandler<ChangeLocalAdminPassword, Either<BaseError, LocalAdminPrincipal>>
|
||||
{
|
||||
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
|
||||
ChangeLocalAdminPassword request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.NewPassword))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<ConfigElement> rows = await dbContext.ConfigElements
|
||||
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|
||||
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|
||||
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
ConfigElement userRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminUsername.Key);
|
||||
ConfigElement hashRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key);
|
||||
ConfigElement stampRow = rows.Find(r => r.Key == ConfigElementKey.AuthSecurityStamp.Key);
|
||||
|
||||
if (hashRow is null)
|
||||
{
|
||||
return BaseError.New("No local administrator is configured");
|
||||
}
|
||||
|
||||
string username = (request.Username ?? string.Empty).Trim();
|
||||
bool userMatches = userRow is not null
|
||||
&& string.Equals(userRow.Value, username, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
LocalPasswordVerification result =
|
||||
passwordHasher.Verify(hashRow.Value, request.CurrentPassword ?? string.Empty);
|
||||
|
||||
if (!userMatches || result == LocalPasswordVerification.Failed)
|
||||
{
|
||||
return BaseError.New("Current password is incorrect");
|
||||
}
|
||||
|
||||
// Atomic: the new hash and rotated stamp commit together, so a crash can't leave the new password
|
||||
// active with the old stamp still authorizing revoked sessions.
|
||||
string stamp = LocalAdminHelpers.NewSecurityStamp();
|
||||
hashRow.Value = passwordHasher.Hash(request.NewPassword);
|
||||
if (stampRow is null)
|
||||
{
|
||||
dbContext.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
|
||||
}
|
||||
else
|
||||
{
|
||||
stampRow.Value = stamp;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new LocalAdminPrincipal(userRow.Value, stamp);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// First-run setup-claim: creates the single local administrator. Fails if one already exists
|
||||
/// (first-claim-wins), so a later anonymous call cannot take over the account.
|
||||
/// </summary>
|
||||
public record ClaimLocalAdmin(string Username, string Password) : IRequest<Either<BaseError, LocalAdminPrincipal>>;
|
||||
@@ -1,68 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class ClaimLocalAdminHandler(IDbContextFactory<TvContext> dbContextFactory, ILocalPasswordHasher passwordHasher)
|
||||
: IRequestHandler<ClaimLocalAdmin, Either<BaseError, LocalAdminPrincipal>>
|
||||
{
|
||||
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
|
||||
ClaimLocalAdmin request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (BaseError error in LocalAdminHelpers.ValidateNewCredentials(request.Username, request.Password))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
string username = request.Username.Trim();
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Fast path for the common already-configured case (clean 409). The real first-claim-wins guard is
|
||||
// the unique index on ConfigElement.Key + the single atomic SaveChanges below: two concurrent claims
|
||||
// both pass this check, but only one INSERT of the three credential rows commits — the loser's
|
||||
// SaveChanges violates the unique Key index and rolls back wholesale (no mixed-state credential).
|
||||
bool alreadyConfigured = await dbContext.ConfigElements
|
||||
.AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
|
||||
if (alreadyConfigured)
|
||||
{
|
||||
return BaseError.New("A local administrator has already been configured");
|
||||
}
|
||||
|
||||
string stamp = LocalAdminHelpers.NewSecurityStamp();
|
||||
dbContext.ConfigElements.AddRange(
|
||||
new ConfigElement { Key = ConfigElementKey.AuthLocalAdminUsername.Key, Value = username },
|
||||
new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.AuthLocalAdminPasswordHash.Key,
|
||||
Value = passwordHasher.Hash(request.Password)
|
||||
},
|
||||
new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A write conflict here is (almost always) a lost first-claim race — a concurrent claim inserted
|
||||
// these keys first (unique Key index). Confirm the row now exists on a fresh context before
|
||||
// reporting "already configured"; otherwise this was a genuine/transient DB error → rethrow rather
|
||||
// than mask it.
|
||||
await using TvContext verifyContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
bool nowConfigured = await verifyContext.ConfigElements
|
||||
.AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
|
||||
if (nowConfigured)
|
||||
{
|
||||
return BaseError.New("A local administrator has already been configured");
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return new LocalAdminPrincipal(username, stamp);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// The current local-admin security stamp, or <c>None</c> if no local admin is configured. The cookie
|
||||
/// <c>OnValidatePrincipal</c> compares this to the principal's stamp claim on every request; a mismatch
|
||||
/// (i.e. the password was changed) rejects the session.
|
||||
/// </summary>
|
||||
public record GetLocalAdminSecurityStamp : IRequest<Option<string>>;
|
||||
@@ -1,11 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class GetLocalAdminSecurityStampHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetLocalAdminSecurityStamp, Option<string>>
|
||||
{
|
||||
public async Task<Option<string>> Handle(GetLocalAdminSecurityStamp request, CancellationToken cancellationToken) =>
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, cancellationToken);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public enum LocalPasswordVerification
|
||||
{
|
||||
Failed,
|
||||
Success,
|
||||
SuccessRehashNeeded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps ASP.NET Core Identity's <c>PasswordHasher</c> (PBKDF2) behind a minimal, framework-agnostic
|
||||
/// surface so the Auth handlers don't depend on Identity types directly.
|
||||
/// </summary>
|
||||
public interface ILocalPasswordHasher
|
||||
{
|
||||
/// <summary>Hashes a password for storage (random per-hash salt embedded in the returned string).</summary>
|
||||
string Hash(string password);
|
||||
|
||||
/// <summary>Verifies a password against a stored hash in constant time (delegated to Identity).</summary>
|
||||
LocalPasswordVerification Verify(string hash, string password);
|
||||
|
||||
/// <summary>
|
||||
/// A stable, valid hash of a throwaway password. Verify against this when no real credential exists
|
||||
/// so an unknown-username / unconfigured login costs the same as a real one (no user enumeration).
|
||||
/// </summary>
|
||||
string DummyHash { get; }
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>True once a local administrator credential has been set (first-run setup is complete).</summary>
|
||||
public record IsLocalAdminConfigured : IRequest<bool>;
|
||||
@@ -1,15 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class IsLocalAdminConfiguredHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<IsLocalAdminConfigured, bool>
|
||||
{
|
||||
public async Task<bool> Handle(IsLocalAdminConfigured request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ConfigElement> hash =
|
||||
await configElementRepository.GetConfigElement(ConfigElementKey.AuthLocalAdminPasswordHash, cancellationToken);
|
||||
return hash.IsSome;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
internal static class LocalAdminHelpers
|
||||
{
|
||||
public const int MaxUsernameLength = 256;
|
||||
|
||||
// Upper bound so an absurdly long password can't burn CPU in PBKDF2 (the request body is also capped
|
||||
// by Kestrel, #283; this is defense-in-depth on the field itself).
|
||||
public const int MaxPasswordLength = 1024;
|
||||
|
||||
/// <summary>128 bits of random, lowercase hex. Rotated on every password change to revoke sessions.</summary>
|
||||
public static string NewSecurityStamp() =>
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
|
||||
/// <summary>Validates a new username + password. Returns the error, or None if valid.</summary>
|
||||
public static Option<BaseError> ValidateNewCredentials(string username, string password)
|
||||
{
|
||||
string trimmed = (username ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return BaseError.New("Username is required");
|
||||
}
|
||||
|
||||
if (trimmed.Length > MaxUsernameLength)
|
||||
{
|
||||
return BaseError.New("Username is too long");
|
||||
}
|
||||
|
||||
return ValidatePassword(password);
|
||||
}
|
||||
|
||||
public static Option<BaseError> ValidatePassword(string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password) || password.Length < AuthConstants.MinPasswordLength)
|
||||
{
|
||||
return BaseError.New($"Password must be at least {AuthConstants.MinPasswordLength} characters");
|
||||
}
|
||||
|
||||
if (password.Length > MaxPasswordLength)
|
||||
{
|
||||
return BaseError.New($"Password must be at most {MaxPasswordLength} characters");
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// The identity of the single local administrator, as returned by a successful claim / login / password
|
||||
/// change. The web host turns this into a cookie principal: <see cref="Username" /> becomes the name claim
|
||||
/// and <see cref="SecurityStamp" /> is stamped as <see cref="AuthConstants.SecurityStampClaim" /> so a later
|
||||
/// password change (which rotates the stamp) revokes the session.
|
||||
/// </summary>
|
||||
public record LocalAdminPrincipal(string Username, string SecurityStamp);
|
||||
@@ -1,33 +0,0 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ILocalPasswordHasher" /> backed by ASP.NET Core Identity's <see cref="PasswordHasher{TUser}" />
|
||||
/// (PBKDF2-HMAC-SHA512, per-hash random salt, format-versioned so a future work-factor bump is a
|
||||
/// transparent rehash-on-verify). Stateless and thread-safe → registered as a singleton.
|
||||
/// </summary>
|
||||
public sealed class LocalPasswordHasher : ILocalPasswordHasher
|
||||
{
|
||||
// The generic user parameter is unused by the hasher (it takes no per-user data), so a shared sentinel
|
||||
// is fine.
|
||||
private static readonly object Sentinel = new();
|
||||
|
||||
private readonly PasswordHasher<object> _hasher = new();
|
||||
private readonly Lazy<string> _dummyHash;
|
||||
|
||||
public LocalPasswordHasher() =>
|
||||
_dummyHash = new Lazy<string>(() => _hasher.HashPassword(Sentinel, "not-a-real-password"));
|
||||
|
||||
public string DummyHash => _dummyHash.Value;
|
||||
|
||||
public string Hash(string password) => _hasher.HashPassword(Sentinel, password);
|
||||
|
||||
public LocalPasswordVerification Verify(string hash, string password) =>
|
||||
_hasher.VerifyHashedPassword(Sentinel, hash, password) switch
|
||||
{
|
||||
PasswordVerificationResult.Success => LocalPasswordVerification.Success,
|
||||
PasswordVerificationResult.SuccessRehashNeeded => LocalPasswordVerification.SuccessRehashNeeded,
|
||||
_ => LocalPasswordVerification.Failed
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Rotates the local admin security stamp, revoking every outstanding local session server-side (their
|
||||
/// cookies carry the old stamp and fail <c>OnValidatePrincipal</c> on their next request). Used by logout
|
||||
/// so signing out actually ends the session server-side, not just client-side. A no-op when no local
|
||||
/// admin is configured. OIDC sessions are unaffected (they carry no stamp).
|
||||
/// </summary>
|
||||
public record RotateLocalAdminSecurityStamp : IRequest<Unit>;
|
||||
@@ -1,28 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class RotateLocalAdminSecurityStampHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<RotateLocalAdminSecurityStamp, Unit>
|
||||
{
|
||||
public async Task<Unit> Handle(RotateLocalAdminSecurityStamp request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
ConfigElement stampRow = await dbContext.ConfigElements
|
||||
.FirstOrDefaultAsync(c => c.Key == ConfigElementKey.AuthSecurityStamp.Key, cancellationToken);
|
||||
|
||||
// No local admin configured → nothing to revoke.
|
||||
if (stampRow is null)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
stampRow.Value = LocalAdminHelpers.NewSecurityStamp();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Recovery/bootstrap path: (re)sets the local admin from configuration (env
|
||||
/// <c>Auth:LocalAdmin:Username</c>/<c>Password</c>). Overwrites any existing credential and rotates the
|
||||
/// stamp (revoking sessions), so an operator who is locked out can reset by setting the env and
|
||||
/// restarting. Runs at startup only when a password is configured.
|
||||
/// </summary>
|
||||
public record SeedLocalAdminFromEnvironment(string Username, string Password) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,63 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class SeedLocalAdminFromEnvironmentHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalPasswordHasher passwordHasher)
|
||||
: IRequestHandler<SeedLocalAdminFromEnvironment, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
SeedLocalAdminFromEnvironment request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string username = (request.Username ?? string.Empty).Trim();
|
||||
if (username.Length == 0)
|
||||
{
|
||||
username = "admin";
|
||||
}
|
||||
|
||||
if (username.Length > LocalAdminHelpers.MaxUsernameLength)
|
||||
{
|
||||
return BaseError.New("Seed username is too long");
|
||||
}
|
||||
|
||||
foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.Password))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<ConfigElement> rows = await dbContext.ConfigElements
|
||||
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|
||||
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|
||||
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Overwrite (recovery/bootstrap) atomically: username + new hash + rotated stamp commit together.
|
||||
Upsert(dbContext, rows, ConfigElementKey.AuthLocalAdminUsername.Key, username);
|
||||
Upsert(dbContext, rows, ConfigElementKey.AuthLocalAdminPasswordHash.Key, passwordHasher.Hash(request.Password));
|
||||
Upsert(dbContext, rows, ConfigElementKey.AuthSecurityStamp.Key, LocalAdminHelpers.NewSecurityStamp());
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static void Upsert(TvContext dbContext, List<ConfigElement> existing, string key, string value)
|
||||
{
|
||||
ConfigElement row = existing.Find(r => r.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
dbContext.ConfigElements.Add(new ConfigElement { Key = key, Value = value });
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a local-login username/password. On success returns the principal (username + current
|
||||
/// security stamp) to sign into a cookie. A generic error (no username enumeration) on any failure.
|
||||
/// </summary>
|
||||
public record VerifyLocalAdminLogin(string Username, string Password) : IRequest<Either<BaseError, LocalAdminPrincipal>>;
|
||||
@@ -1,53 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class VerifyLocalAdminLoginHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalPasswordHasher passwordHasher)
|
||||
: IRequestHandler<VerifyLocalAdminLogin, Either<BaseError, LocalAdminPrincipal>>
|
||||
{
|
||||
private static readonly BaseError InvalidCredentials = BaseError.New("Invalid username or password");
|
||||
|
||||
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
|
||||
VerifyLocalAdminLogin request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string username = (request.Username ?? string.Empty).Trim();
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Read the hash and stamp in ONE snapshot so they are consistent (issue: a login racing a password
|
||||
// change must not return a stamp newer than the hash it verified). A concurrent change is then either
|
||||
// wholly before this read (the old password fails to verify) or wholly after it (we return the
|
||||
// pre-change stamp, so the cookie AuthController issues is revoked on its very next request by
|
||||
// CookieSecurityStampValidator). No writes happen here, so there is nothing to clobber.
|
||||
Dictionary<string, string> config = await dbContext.ConfigElements
|
||||
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|
||||
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|
||||
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
|
||||
.ToDictionaryAsync(c => c.Key, c => c.Value, cancellationToken);
|
||||
|
||||
config.TryGetValue(ConfigElementKey.AuthLocalAdminUsername.Key, out string storedUser);
|
||||
config.TryGetValue(ConfigElementKey.AuthLocalAdminPasswordHash.Key, out string storedHash);
|
||||
config.TryGetValue(ConfigElementKey.AuthSecurityStamp.Key, out string stamp);
|
||||
|
||||
// Always run exactly one PBKDF2 verify — against a dummy hash when unconfigured/unknown — so response
|
||||
// timing does not reveal whether the account exists (no user enumeration).
|
||||
string candidateHash = storedHash ?? passwordHasher.DummyHash;
|
||||
LocalPasswordVerification result = passwordHasher.Verify(candidateHash, request.Password ?? string.Empty);
|
||||
|
||||
bool userMatches = storedUser is not null
|
||||
&& string.Equals(storedUser, username, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (storedHash is null || !userMatches || result == LocalPasswordVerification.Failed)
|
||||
{
|
||||
return InvalidCredentials;
|
||||
}
|
||||
|
||||
return new LocalAdminPrincipal(storedUser, stamp ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
internal static class ChannelTemplateDefault
|
||||
{
|
||||
public static async Task<int?> GetDefaultTemplateId(
|
||||
IConfigElementRepository configElementRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> maybeDefault =
|
||||
await configElementRepository.GetValue<int>(
|
||||
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
|
||||
cancellationToken);
|
||||
int? result = null;
|
||||
foreach (int id in maybeDefault)
|
||||
{
|
||||
result = id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public static class ChannelTemplateMapper
|
||||
{
|
||||
public static ChannelTemplateResponseModel ProjectToResponseModel(ChannelTemplate template, int? defaultTemplateId) =>
|
||||
new(
|
||||
template.Id,
|
||||
template.Name,
|
||||
template.Description,
|
||||
template.IsSystem,
|
||||
defaultTemplateId == template.Id,
|
||||
template.FFmpegProfileId,
|
||||
template.WatermarkId,
|
||||
template.FallbackFillerId,
|
||||
template.PreRollFillerId,
|
||||
template.MidRollFillerId,
|
||||
template.PostRollFillerId,
|
||||
template.StreamSelectorMode,
|
||||
template.StreamSelector,
|
||||
template.PreferredAudioLanguageCode,
|
||||
template.PreferredAudioTitle,
|
||||
template.PlayoutSource,
|
||||
template.PlayoutMode,
|
||||
template.StreamingMode,
|
||||
template.PreferredSubtitleLanguageCode,
|
||||
template.SubtitleMode,
|
||||
template.MusicVideoCreditsMode,
|
||||
template.MusicVideoCreditsTemplate,
|
||||
template.SongVideoMode,
|
||||
template.TranscodeMode,
|
||||
template.IdleBehavior,
|
||||
template.ShuffleScheduleItems,
|
||||
template.RandomStartPoint,
|
||||
template.FixedStartTimeBehavior);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public abstract record ChannelTemplateCommandBase(
|
||||
string Name,
|
||||
string Description,
|
||||
int FFmpegProfileId,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
int? PreRollFillerId,
|
||||
int? MidRollFillerId,
|
||||
int? PostRollFillerId,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
StreamingMode StreamingMode,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior)
|
||||
{
|
||||
internal static async Task<Option<BaseError>> ValidateCommon(
|
||||
TvContext dbContext,
|
||||
ChannelTemplateCommandBase request,
|
||||
int? existingTemplateId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string name = NormalizeName(request.Name);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return BaseError.New("Name is required.");
|
||||
}
|
||||
|
||||
if (name.Length > 50)
|
||||
{
|
||||
return BaseError.New("Name must be 50 characters or less.");
|
||||
}
|
||||
|
||||
if (request.Description?.Length > 500)
|
||||
{
|
||||
return BaseError.New("Description must be 500 characters or less.");
|
||||
}
|
||||
|
||||
bool duplicateName = await dbContext.ChannelTemplates
|
||||
.AnyAsync(t => t.Id != existingTemplateId && t.Name == name, cancellationToken);
|
||||
if (duplicateName)
|
||||
{
|
||||
return BaseError.New("Channel template name must be unique.");
|
||||
}
|
||||
|
||||
bool ffmpegProfileExists = await dbContext.FFmpegProfiles
|
||||
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
|
||||
if (!ffmpegProfileExists)
|
||||
{
|
||||
return new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
|
||||
}
|
||||
|
||||
foreach (int watermarkId in Optional(request.WatermarkId))
|
||||
{
|
||||
bool watermarkExists = await dbContext.ChannelWatermarks
|
||||
.AnyAsync(w => w.Id == watermarkId, cancellationToken);
|
||||
if (!watermarkExists)
|
||||
{
|
||||
return new NotFoundError($"Watermark {watermarkId} does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
Option<BaseError> maybeFillerError =
|
||||
await FillerMustExist(dbContext, request.FallbackFillerId, FillerKind.Fallback, cancellationToken);
|
||||
if (maybeFillerError.IsSome)
|
||||
{
|
||||
return maybeFillerError;
|
||||
}
|
||||
|
||||
maybeFillerError = await FillerMustExist(dbContext, request.PreRollFillerId, FillerKind.PreRoll, cancellationToken);
|
||||
if (maybeFillerError.IsSome)
|
||||
{
|
||||
return maybeFillerError;
|
||||
}
|
||||
|
||||
maybeFillerError = await FillerMustExist(dbContext, request.MidRollFillerId, FillerKind.MidRoll, cancellationToken);
|
||||
if (maybeFillerError.IsSome)
|
||||
{
|
||||
return maybeFillerError;
|
||||
}
|
||||
|
||||
return await FillerMustExist(dbContext, request.PostRollFillerId, FillerKind.PostRoll, cancellationToken);
|
||||
}
|
||||
|
||||
internal void ApplyTo(ChannelTemplate template)
|
||||
{
|
||||
template.Name = NormalizeName(Name);
|
||||
template.Description = Description ?? string.Empty;
|
||||
template.FFmpegProfileId = FFmpegProfileId;
|
||||
template.WatermarkId = WatermarkId;
|
||||
template.FallbackFillerId = FallbackFillerId;
|
||||
template.PreRollFillerId = PreRollFillerId;
|
||||
template.MidRollFillerId = MidRollFillerId;
|
||||
template.PostRollFillerId = PostRollFillerId;
|
||||
template.StreamSelectorMode = StreamSelectorMode;
|
||||
template.StreamSelector = StreamSelector ?? string.Empty;
|
||||
template.PreferredAudioLanguageCode = PreferredAudioLanguageCode ?? string.Empty;
|
||||
template.PreferredAudioTitle = PreferredAudioTitle ?? string.Empty;
|
||||
template.PlayoutSource = PlayoutSource;
|
||||
template.PlayoutMode = PlayoutMode;
|
||||
template.StreamingMode = StreamingMode;
|
||||
template.PreferredSubtitleLanguageCode = PreferredSubtitleLanguageCode ?? string.Empty;
|
||||
template.SubtitleMode = SubtitleMode;
|
||||
template.MusicVideoCreditsMode = MusicVideoCreditsMode;
|
||||
template.MusicVideoCreditsTemplate = MusicVideoCreditsTemplate ?? string.Empty;
|
||||
template.SongVideoMode = SongVideoMode;
|
||||
template.TranscodeMode = TranscodeMode;
|
||||
template.IdleBehavior = IdleBehavior;
|
||||
template.ShuffleScheduleItems = ShuffleScheduleItems;
|
||||
template.RandomStartPoint = RandomStartPoint;
|
||||
template.FixedStartTimeBehavior = FixedStartTimeBehavior;
|
||||
}
|
||||
|
||||
internal static string NormalizeName(string name) => (name ?? string.Empty).Trim();
|
||||
|
||||
private static async Task<Option<BaseError>> FillerMustExist(
|
||||
TvContext dbContext,
|
||||
int? fillerPresetId,
|
||||
FillerKind fillerKind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (int id in Optional(fillerPresetId))
|
||||
{
|
||||
bool exists = await dbContext.FillerPresets
|
||||
.AnyAsync(f => f.Id == id && f.FillerKind == fillerKind, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
return new NotFoundError($"{fillerKind} filler {id} does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public record CreateChannelTemplate(
|
||||
string Name,
|
||||
string Description,
|
||||
int FFmpegProfileId,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
int? PreRollFillerId,
|
||||
int? MidRollFillerId,
|
||||
int? PostRollFillerId,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
StreamingMode StreamingMode,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior)
|
||||
: ChannelTemplateCommandBase(
|
||||
Name,
|
||||
Description,
|
||||
FFmpegProfileId,
|
||||
WatermarkId,
|
||||
FallbackFillerId,
|
||||
PreRollFillerId,
|
||||
MidRollFillerId,
|
||||
PostRollFillerId,
|
||||
StreamSelectorMode,
|
||||
StreamSelector,
|
||||
PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle,
|
||||
PlayoutSource,
|
||||
PlayoutMode,
|
||||
StreamingMode,
|
||||
PreferredSubtitleLanguageCode,
|
||||
SubtitleMode,
|
||||
MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate,
|
||||
SongVideoMode,
|
||||
TranscodeMode,
|
||||
IdleBehavior,
|
||||
ShuffleScheduleItems,
|
||||
RandomStartPoint,
|
||||
FixedStartTimeBehavior),
|
||||
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
|
||||
@@ -1,36 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public class CreateChannelTemplateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<CreateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
|
||||
CreateChannelTemplate request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<BaseError> maybeError =
|
||||
await ChannelTemplateCommandBase.ValidateCommon(dbContext, request, null, cancellationToken);
|
||||
foreach (BaseError error in maybeError)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
var template = new ChannelTemplate();
|
||||
request.ApplyTo(template);
|
||||
await dbContext.ChannelTemplates.AddAsync(template, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
int? defaultTemplateId =
|
||||
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
|
||||
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public record DeleteChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,42 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public class DeleteChannelTemplateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<DeleteChannelTemplate, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(DeleteChannelTemplate request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
|
||||
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
|
||||
foreach (ChannelTemplate template in maybeTemplate)
|
||||
{
|
||||
if (template.IsSystem)
|
||||
{
|
||||
return BaseError.New("System templates cannot be deleted.");
|
||||
}
|
||||
|
||||
int? defaultTemplateId =
|
||||
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
|
||||
if (defaultTemplateId == template.Id)
|
||||
{
|
||||
return BaseError.New("Default channel template cannot be deleted.");
|
||||
}
|
||||
|
||||
dbContext.ChannelTemplates.Remove(template);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public record SetDefaultChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
|
||||
@@ -1,36 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public class SetDefaultChannelTemplateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<SetDefaultChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
|
||||
SetDefaultChannelTemplate request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
|
||||
foreach (ChannelTemplate template in maybeTemplate)
|
||||
{
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
|
||||
template.Id,
|
||||
cancellationToken);
|
||||
return ChannelTemplateMapper.ProjectToResponseModel(template, template.Id);
|
||||
}
|
||||
|
||||
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public record UpdateChannelTemplate(
|
||||
int ChannelTemplateId,
|
||||
string Name,
|
||||
string Description,
|
||||
int FFmpegProfileId,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
int? PreRollFillerId,
|
||||
int? MidRollFillerId,
|
||||
int? PostRollFillerId,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
StreamingMode StreamingMode,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior)
|
||||
: ChannelTemplateCommandBase(
|
||||
Name,
|
||||
Description,
|
||||
FFmpegProfileId,
|
||||
WatermarkId,
|
||||
FallbackFillerId,
|
||||
PreRollFillerId,
|
||||
MidRollFillerId,
|
||||
PostRollFillerId,
|
||||
StreamSelectorMode,
|
||||
StreamSelector,
|
||||
PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle,
|
||||
PlayoutSource,
|
||||
PlayoutMode,
|
||||
StreamingMode,
|
||||
PreferredSubtitleLanguageCode,
|
||||
SubtitleMode,
|
||||
MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate,
|
||||
SongVideoMode,
|
||||
TranscodeMode,
|
||||
IdleBehavior,
|
||||
ShuffleScheduleItems,
|
||||
RandomStartPoint,
|
||||
FixedStartTimeBehavior),
|
||||
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
|
||||
@@ -1,51 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public class UpdateChannelTemplateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
|
||||
UpdateChannelTemplate request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
|
||||
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
|
||||
foreach (ChannelTemplate template in maybeTemplate)
|
||||
{
|
||||
if (template.IsSystem)
|
||||
{
|
||||
return BaseError.New("System templates cannot be updated.");
|
||||
}
|
||||
|
||||
Option<BaseError> maybeError =
|
||||
await ChannelTemplateCommandBase.ValidateCommon(
|
||||
dbContext,
|
||||
request,
|
||||
request.ChannelTemplateId,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in maybeError)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
request.ApplyTo(template);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
int? defaultTemplateId =
|
||||
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
|
||||
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
|
||||
}
|
||||
|
||||
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public record GetAllChannelTemplates : IRequest<List<ChannelTemplateResponseModel>>;
|
||||
@@ -1,28 +0,0 @@
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public class GetAllChannelTemplatesHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetAllChannelTemplates, List<ChannelTemplateResponseModel>>
|
||||
{
|
||||
public async Task<List<ChannelTemplateResponseModel>> Handle(
|
||||
GetAllChannelTemplates request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int? defaultTemplateId =
|
||||
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
|
||||
return await dbContext.ChannelTemplates
|
||||
.AsNoTracking()
|
||||
.OrderBy(t => t.IsSystem ? 0 : 1)
|
||||
.ThenBy(t => t.Name)
|
||||
.Select(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public record GetChannelTemplateById(int ChannelTemplateId) : IRequest<Option<ChannelTemplateResponseModel>>;
|
||||
@@ -1,27 +0,0 @@
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public class GetChannelTemplateByIdHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetChannelTemplateById, Option<ChannelTemplateResponseModel>>
|
||||
{
|
||||
public async Task<Option<ChannelTemplateResponseModel>> Handle(
|
||||
GetChannelTemplateById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int? defaultTemplateId =
|
||||
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
|
||||
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
|
||||
return maybeTemplate.Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public record GetDefaultChannelTemplate : IRequest<Option<ChannelTemplateResponseModel>>;
|
||||
@@ -1,40 +0,0 @@
|
||||
using ErsatzTV.Core.Api.ChannelTemplates;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ChannelTemplates;
|
||||
|
||||
public class GetDefaultChannelTemplateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetDefaultChannelTemplate, Option<ChannelTemplateResponseModel>>
|
||||
{
|
||||
public async Task<Option<ChannelTemplateResponseModel>> Handle(
|
||||
GetDefaultChannelTemplate request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int? defaultTemplateId =
|
||||
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
|
||||
foreach (int id in Optional(defaultTemplateId))
|
||||
{
|
||||
Option<ChannelTemplate> maybeConfigured = await dbContext.ChannelTemplates
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(t => t.Id, t => t.Id == id, cancellationToken);
|
||||
foreach (ChannelTemplate template in maybeConfigured)
|
||||
{
|
||||
return ChannelTemplateMapper.ProjectToResponseModel(template, id);
|
||||
}
|
||||
}
|
||||
|
||||
ChannelTemplate fallback = await dbContext.ChannelTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.IsSystem)
|
||||
.OrderBy(t => t.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return Optional(fallback).Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, t.Id));
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public static class AutoTuneAxisMap
|
||||
{
|
||||
// Server-owned Lucene smart-collection query for an axis value.
|
||||
public static string GenerateQuery(AutoTuneAxis axis, string value)
|
||||
{
|
||||
string escaped = EscapeLuceneValue(value);
|
||||
return axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => $"type:episode AND show_title:\"{escaped}\"",
|
||||
AutoTuneAxis.TvGenre => $"type:episode AND genre:\"{escaped}\"",
|
||||
AutoTuneAxis.MovieGenre => $"type:movie AND genre:\"{escaped}\"",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
}
|
||||
|
||||
// Human-facing channel name. Movie-genre channels are suffixed so a genre that exists for
|
||||
// both TV and movies ("Comedy" vs "Comedy Movies") does not produce two identically-named channels.
|
||||
public static string GenerateName(AutoTuneAxis axis, string value) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => value,
|
||||
AutoTuneAxis.TvGenre => value,
|
||||
AutoTuneAxis.MovieGenre => $"{value} Movies",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// PseudoTV per-type defaults: single-show channels play in episode order; genre channels shuffle.
|
||||
public static PlaybackOrder PlaybackOrderFor(AutoTuneAxis axis) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => PlaybackOrder.SeasonEpisode,
|
||||
AutoTuneAxis.TvGenre => PlaybackOrder.Shuffle,
|
||||
AutoTuneAxis.MovieGenre => PlaybackOrder.Shuffle,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// Per-source member query for a weighted auto-tune channel (#425). The discriminator identifies ONE
|
||||
// content source within the channel's axis:
|
||||
// * TV axes -> the show title. Episodes carry no parent-show id in the search index (only show_title
|
||||
// is denormalized onto them), so show_title is the only field that selects a show's episodes. It is
|
||||
// the same discriminator the TvShow axis already uses, so this introduces no new fragility class;
|
||||
// a post-create show rename empties the member (items fall through to the remainder) until re-tuned.
|
||||
// * MovieGenre -> the movie's media-item id (the stable, rename-proof `id` field; a movie IS the
|
||||
// played item, so its own id selects it exactly).
|
||||
// Deliberately discriminator-ONLY (no genre clause): membership is decided when the channel is tuned,
|
||||
// so a materialized show airs all its episodes and the remainder subtracts the whole source (below).
|
||||
public static string GenerateSourceQuery(AutoTuneAxis axis, string discriminator) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
|
||||
$"type:episode AND show_title:\"{EscapeLuceneValue(discriminator)}\"",
|
||||
AutoTuneAxis.MovieGenre => $"type:movie AND id:{discriminator}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// The bare clause used to subtract a materialized/excluded source from the remainder query (below).
|
||||
// Mirrors GenerateSourceQuery's discriminator field, minus the type prefix.
|
||||
public static string SourceDiscriminatorClause(AutoTuneAxis axis, string discriminator) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
|
||||
$"show_title:\"{EscapeLuceneValue(discriminator)}\"",
|
||||
AutoTuneAxis.MovieGenre => $"id:{discriminator}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// The catch-all remainder query: the base axis query minus every materialized/excluded source, so the
|
||||
// base set is partitioned across (member sources + remainder) with no item counted twice and none
|
||||
// dropped. Returns the plain base query when there is nothing to subtract. Emitted as valid classic
|
||||
// Lucene — `(base) AND NOT (d1 OR d2 ...)` — because a ParseException silently escapes the whole query
|
||||
// into a literal (SearchQueryParser.ParseQuery fallback).
|
||||
public static string GenerateRemainderQuery(
|
||||
AutoTuneAxis axis,
|
||||
string value,
|
||||
IReadOnlyCollection<string> subtractedDiscriminators)
|
||||
{
|
||||
string baseQuery = GenerateQuery(axis, value);
|
||||
if (subtractedDiscriminators is null || subtractedDiscriminators.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
string negated = string.Join(
|
||||
" OR ",
|
||||
subtractedDiscriminators.Select(d => SourceDiscriminatorClause(axis, d)));
|
||||
return $"({baseQuery}) AND NOT ({negated})";
|
||||
}
|
||||
|
||||
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
|
||||
public static string EscapeLuceneValue(string value) =>
|
||||
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public static class AutoTuneNumberAllocator
|
||||
{
|
||||
// Allocate `count` sequential integer channel numbers starting at `startingNumber`,
|
||||
// skipping any number already present in `existingNumbers`. Channel.Number is a string,
|
||||
// so numbers are returned as invariant-culture strings.
|
||||
public static List<string> Allocate(int startingNumber, int count, ISet<string> existingNumbers)
|
||||
{
|
||||
var result = new List<string>(count);
|
||||
int next = startingNumber;
|
||||
while (result.Count < count)
|
||||
{
|
||||
string candidate = next.ToString(CultureInfo.InvariantCulture);
|
||||
if (!existingNumbers.Contains(candidate))
|
||||
{
|
||||
result.Add(candidate);
|
||||
}
|
||||
|
||||
next++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
|
||||
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
||||
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
|
||||
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
|
||||
/// </summary>
|
||||
public static class ChannelGuideMetadata
|
||||
{
|
||||
public static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return playoutItem.CustomTitle;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown remote stream]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetSubtitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The primary guide category, mirroring the fixed <c><category></c> the XMLTV templates
|
||||
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
|
||||
/// </summary>
|
||||
public static string GetCategory(PlayoutItem playoutItem) =>
|
||||
playoutItem.MediaItem switch
|
||||
{
|
||||
Movie => "Movie",
|
||||
Episode => "Series",
|
||||
MusicVideo => "Music",
|
||||
Song => "Music",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
|
||||
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
|
||||
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
|
||||
/// </summary>
|
||||
public readonly record struct ChannelGuideEntry(
|
||||
PlayoutItem DisplayItem,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Stop,
|
||||
bool HasCustomTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
|
||||
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
|
||||
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
||||
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
|
||||
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
|
||||
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
|
||||
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
|
||||
/// </summary>
|
||||
public static class ChannelGuideProjector
|
||||
{
|
||||
public static IEnumerable<ChannelGuideEntry> Project(
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone,
|
||||
XmltvBlockBehavior blockBehavior) =>
|
||||
scheduleKind switch
|
||||
{
|
||||
PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior),
|
||||
_ => ProjectFlood(sorted, timeZone)
|
||||
};
|
||||
|
||||
// Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce
|
||||
// each guide group (following filler) into a single programme using the display item's GuideFinish
|
||||
// override when present.
|
||||
private static IEnumerable<ChannelGuideEntry> ProjectFlood(
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone)
|
||||
{
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.PostRoll or FillerKind.Tail
|
||||
or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
DateTimeOffset startTime = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
|
||||
_ => startItem.StartOffset
|
||||
};
|
||||
|
||||
DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch
|
||||
{
|
||||
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
|
||||
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
|
||||
(_, true) => displayItem.GuideFinishOffset!.Value,
|
||||
(_, false) => finishItem.FinishOffset
|
||||
};
|
||||
|
||||
yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle);
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Block: group by guide window, drop filler entirely, then either use the items' actual times or
|
||||
// split the group window evenly across the non-filler items.
|
||||
private static IEnumerable<ChannelGuideEntry> ProjectBlock(
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone,
|
||||
XmltvBlockBehavior blockBehavior)
|
||||
{
|
||||
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (blockBehavior)
|
||||
{
|
||||
case XmltvBlockBehavior.UseActualTimes:
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
DateTimeOffset actualStart = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset actualFinish = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
yield return new ChannelGuideEntry(item, actualStart, actualFinish, false);
|
||||
}
|
||||
|
||||
break;
|
||||
case XmltvBlockBehavior.SplitTimeEvenly:
|
||||
default:
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
yield return new ChannelGuideEntry(item, currentStart, currentFinish, false);
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class ChannelSortViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Number { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string OriginalNumber { get; set; }
|
||||
public bool HasChanged => OriginalNumber != Number;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user