Compare commits

..
Author SHA1 Message Date
Jason Dove 97725ac92d add small hdr test file 2025-06-14 10:04:03 -05:00
3126 changed files with 46588 additions and 1531199 deletions
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
# ersatztv#303 H9 — docs/decisions.md is append-only. This blocks a commit / PR that DELETES or
# MODIFIES an existing line of that file; pure INSERTIONS anywhere are always allowed (adding a new
# entry inserts a TOC line near the top AND appends a block at the bottom — both are insertions, so
# numstat reports 0 deleted lines). A genuine factual fix to a past entry is the one legitimate edit:
# put the literal token [decisions-edit] in the commit message to override.
#
# Fail-open: any tooling trouble (unknown mode, non-numeric numstat, missing refs) -> allow. The point
# is to catch the accidental rewrite-history case, never to wedge a legitimate commit.
#
# Assumes decisions.md ends with a trailing newline (it does; .editorconfig enforces it). If that final
# newline were ever dropped, git would render the next append as a modify of the last line (deleted=1)
# and this would false-block the append until the author adds [decisions-edit] — cheap and self-correcting.
#
# Modes:
# staged <msgfile> pre-commit/commit-msg — staged diff vs HEAD; trailer read from <msgfile>
# range <base> <head> CI (PR) — merge-base diff base...head; trailer scanned across base..head msgs
set -euo pipefail
FILE="docs/decisions.md"
mode="${1:-}"
case "$mode" in
staged)
deleted=$(git diff --cached --numstat -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(cat "${2:-/dev/null}" 2>/dev/null || true)
;;
range)
base="${2:-}"; head="${3:-}"
[ -n "$base" ] && [ -n "$head" ] || exit 0 # missing refs -> fail-open
deleted=$(git diff --numstat "$base...$head" -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(git log --format='%B' "$base..$head" 2>/dev/null || true)
;;
*)
exit 0 # unknown mode -> fail-open
;;
esac
# Empty (no change to the file) or '-' (binary) -> treat as 0 (fail-open / nothing to guard).
deleted="${deleted:-0}"
case "$deleted" in ''|*[!0-9]*) deleted=0 ;; esac
[ "$deleted" -gt 0 ] || exit 0 # pure insertion / no change -> allow
# Explicit override for a documented factual fix.
if printf '%s' "$msg" | grep -qiF '[decisions-edit]'; then
exit 0
fi
{
echo "decisions-guard (ersatztv#303 H9): docs/decisions.md is append-only — this change deletes/modifies ${deleted} existing line(s)."
echo " Append new entries at the bottom (plus a TOC line in the Index); do not rewrite settled entries."
echo " To fix a genuine factual error in a past entry, add the token [decisions-edit] to the commit message."
} >&2
exit 1
-58
View File
@@ -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
-69
View File
@@ -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
-32
View File
@@ -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
-14
View File
@@ -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
-15
View File
@@ -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
-99
View File
@@ -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
-183
View File
@@ -1,183 +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").
# 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. ---
files=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=100" | jq -r '.[].filename // empty' 2>/dev/null || true)
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
# Docs/process-only PR: the Done-when + review-verdict gate doesn't apply — but this exemption is a
# file-TYPE bypass, NOT the a+b+c "provably reviewed & ready" proof, so it does NOT auto-grant. It
# passes through to normal permissioning (one prompt). This deliberately keeps a human in the loop for
# 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
# --- 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."
state=$(gq "repos/$owner/$repo/commits/$sha/status" | jq -r '.state // ""' 2>/dev/null || true)
case "$state" in
success) : ;;
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
*) decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success'. Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging." ;;
esac
fi
# --- (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
# Verdict lines across all comment bodies: a real verdict line STARTS with the marker (after optional
# leading whitespace). Anchoring to line-start is deliberate — it rejects a comment that merely QUOTES
# the positive template mid-sentence (an instruction "please post: Review-verdict: MERGEABLE @ <sha>",
# or the gate's own suggestion text echoed back), which would otherwise self-approve the merge.
verdicts=$(printf '%s' "$comments" | jq -r '.[].body // empty' 2>/dev/null | grep -iE '^[[:space:]]*review-verdict:' || true)
if [ -z "$verdicts" ]; then
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve."
fi
# Classify each verdict line by the sha it references (its "@ <sha>" field) and its verdict word.
# A line references the CURRENT head iff head BEGINS WITH that sha token AND the token is >=7 chars
# (git short-sha prefix semantics) — NOT a loose substring test: an older sha that merely contains
# the head prefix, or the head prefix appearing in an unrelated URL on the line, must NOT count
# (adversarial false-opens). The verdict token must sit right after the marker on the same line.
head_pos=0; head_neg=0; stale=0
while IFS= read -r line; do
[ -n "$line" ] || continue
# The sha the line references: the hex token in its "@ <sha>" field (>=7 chars), lowercased.
ref=$(printf '%s' "$line" | grep -ioE '@[[:space:]]*[0-9a-f]{7,40}' | head -1 \
| grep -oiE '[0-9a-f]{7,40}' | tr 'A-F' 'a-f' || true)
is_pos=0
# Positive iff the line's OWN leading verdict word (right after the line-start marker) is positive —
# anchored so a second, later `review-verdict: mergeable` substring on a BLOCKED line can't flip it.
if printf '%s' "$line" | grep -iqE '^[[:space:]]*review-verdict:[[:space:]]*(mergeable|approved|lgtm)'; then is_pos=1; fi
[ -z "$ref" ] && continue # marker present but no @<sha> -> falls through to the final ask
case "$sha" in
"$ref"*) if [ "$is_pos" = 1 ]; then head_pos=1; else head_neg=1; fi ;;
*) stale=1 ;;
esac
done <<VERDICTS
$verdicts
VERDICTS
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier MERGEABLE
# on the SAME head; and if the head were fixed the sha would change, so this can't wrongly block).
if [ "$head_neg" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr."
fi
if [ "$head_pos" = 1 ]; then
# (a) CI green + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
fi
if [ "$stale" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'."
fi
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve."
# All derivable and satisfied -> auto-grant (defensive: the head_pos branch above already exits here).
decide grant "H6/H10 merge gate: satisfied — auto-granted."
-11
View File
@@ -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
-90
View File
@@ -1,90 +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
}
]
},
{
"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
}
]
}
]
}
}
-167
View File
@@ -1,167 +0,0 @@
---
name: ersatztv
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
---
# ErsatzTV Channel Management
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
Web UI: internal only (`http://localhost:8409` via SSH)
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
## Architecture
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
- **POST endpoints**: library scan, playout reset, show scan
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
## REST API
```bash
# Via docker exec
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
```
### Read Endpoints (GET)
```
/api/channels # List channels
/api/collections # List collections
/api/schedules # List schedules
/api/playouts # List playouts
/api/shows # List shows
/api/movies # List movies
/api/artists # List artists
/api/search # Search items
/api/ffmpeg/profiles # FFmpeg profiles
/api/watermarks # Watermarks
/iptv/channels.m3u # M3U playlist (for Jellyfin)
/iptv/xmltv.xml # XMLTV guide data
```
### Mutation Endpoints (POST)
```bash
# Library scan
POST /api/libraries/{id}/scan
# Scan single show
POST /api/libraries/{id}/scan-show \
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
# Reset channel playout (rebuilds schedule)
POST /api/channels/{channelNumber}/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
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
-- List schedules
SELECT Id, Name FROM ProgramSchedule;
-- Playout (channel-schedule links)
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
-- Media counts
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
-- 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;
```
### 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
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
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
```
**Collection-based channel** (multiple shows, shuffled):
```sql
-- 1. Collection + items (MediaItemId = Show.Id)
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
-- 3-4. Channel + Playout same as show-specific
```
After creating: `POST /api/channels/{number}/playout/reset`
## 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 (Intel Quick Sync) hardware acceleration
- Resolution: 1920x1080, H264, AAC stereo
- Device: `/dev/dri` passed through
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
## Jellyfin Integration
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
## Gotchas
- DB owned by root — always use `sudo sqlite3`
- WAL mode: reads OK while running, stop container for writes
- No REST API for channel/collection/schedule CRUD — DB scripting only
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
-105
View File
@@ -1,105 +0,0 @@
---
name: jellyfin
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
---
# Jellyfin Management
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
API Token: `978033be716d46678a5d3c54ae0e0ff9`
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
## Access Pattern
```bash
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
```
## Volume Mounts
| Host Path | Container Path | Content |
|-----------|---------------|---------|
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
| `/mnt/episodes` | `/data/episodes` | More episodes |
| `/mnt/media/movies` | `/data/movies` | Movies |
| `/mnt/media/standup` | `/data/standup` | Standup |
| `/mnt/media/music_videos` | `/data/music` | Music videos |
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
## API Endpoints
### System
```
GET /System/Info # Server info, version
GET /System/Info/Public # Public info (no auth needed)
POST /System/Restart # Restart server
```
### Items (Search & Browse)
```bash
# Search items
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
# Get item details
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
# Get all movies
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
# Get series
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
# Get episodes for a series
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
# Filter by library (parentId)
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
```
### Libraries
```
GET /Library/VirtualFolders # List all libraries
POST /Library/Refresh # Trigger full library scan
POST /Items/{id}/Refresh # Refresh single item metadata
```
### Streaming
```bash
# Test stream URL
GET /Videos/{itemId}/stream?static=true
# Get playback info
GET /Items/{itemId}/PlaybackInfo
```
### Users
```
GET /Users # List users
GET /Users/{userId} # User details
```
## Library IDs
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
## Live TV
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
- Configured in Jellyfin Admin > Live TV
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
## Gotchas
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
- Music videos are typed as "Movie" in Jellyfin
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
- Items return 404 on stream if source volume is unmounted
- Jellyfin preserves item IDs across restarts unless files are renamed
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
+3 -4
View File
@@ -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
View File
@@ -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
-136
View File
@@ -1,136 +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
# `small` = the small-jobs runner lane. This is a docker-only job (no toolchain needed —
# it *builds* the toolchain), same as docker-build.yml's `build` job.
runs-on: small
steps:
- name: Checkout
uses: actions/checkout@v4
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}"
-69
View File
@@ -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."
-949
View File
@@ -1,949 +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
#
# 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:07048b8
#
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
# `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:07048b8
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
- 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, server-management#604).
# #604 sizes the runners' per-job caps on these numbers, and until now they were inherited
# rather than measured: the 10g cap traces back to server-management#570 observing the image
# build peg 5.999/6 GiB, which is a different job entirely.
#
# ⚠️ READ THE BREAKDOWN, NOT JUST THE PEAK. `memory.peak` is the high-water mark of
# `memory.current`, which charges **page cache** to the cgroup as well as anonymous memory —
# it is NOT "peak RSS", and for a build job (NuGet/npm/obj/bin/coverage I/O) the cache
# dominates. Demonstrated on bumblebee: a container with anon=0 that merely reads an 800 MB
# file reports memory.peak=826 MiB, of which file=800 MiB. This matters because the naive
# reading inverts the decision: page cache is **reclaimed** under a tighter cap, not
# OOM-killed, so a large peak that is mostly `file` is NOT evidence that the cap must stay
# high. `anon` (+ a little kernel/sock) is the part that actually forces an OOM.
#
# The split below is read at end-of-job, so it is the *current* composition rather than the
# composition at the peak instant — indicative, not exact. Sizing a cap off one run is still
# wrong; take a few runs, and treat anon as the floor and peak as the (cache-inflated)
# ceiling. Refining this into a true peak-anon sample is ersatztv#412.
#
# Runs LAST on purpose: memory.peak read at step N reports the peak only up to N, so this
# sits after Coverage summary to include reportgenerator, the job's last real workload.
# cgroup v2 first, v1 fallback.
#
# Skipped on docs-only runs (ersatztv#416): nothing ran, so there is nothing to measure.
- name: Report peak container memory
# `always()` controls whether this step RUNS, not whether its failure fails the job — and
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
# the same guarantee the Coverage summary step above uses.
if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }}
continue-on-error: true
run: |
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
peak=""; src=""
for f in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory/memory.max_usage_in_bytes; do
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
done
if [ -z "$peak" ]; then
echo "No cgroup peak-memory file readable in this container -- skipping."
exit 0
fi
anon=""; file=""
if [ -r /sys/fs/cgroup/memory.stat ]; then
anon=$(awk '/^anon /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
file=$(awk '/^file /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
fi
echo "::group::Container memory (ersatztv#406 / server-management#604)"
printf 'peak (incl. page cache): %s MiB [%s bytes, %s]\n' "$(mib "$peak")" "$peak" "$src"
if [ -n "$anon" ]; then
printf 'end-of-job anon (the part that OOMs): %s MiB\n' "$(mib "$anon")"
printf 'end-of-job file (page cache, reclaimable): %s MiB\n' "$(mib "${file:-0}")"
echo 'NOTE: peak counts reclaimable page cache. Size caps on anon, not on peak.'
else
echo 'NOTE: no memory.stat breakdown available; peak includes reclaimable page cache.'
fi
echo "::endgroup::"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
{
printf '**Container memory (test job):** peak %s MiB *(incl. reclaimable page cache)*' \
"$(mib "$peak")"
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' \
"$(mib "$anon")" "$(mib "${file:-0}")"
printf '\n'
} >> "$GITHUB_STEP_SUMMARY" || true
fi
migrations:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
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::"
functional-e2e:
name: Functional E2E (curl contracts)
runs-on: ubuntu-latest
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
# flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
# 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:07048b8
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"
build:
name: Build & push image (amd64)
# `small` = the dedicated small-jobs runner lane (server-management#574).
# On PR runs this job only resolves its skip, but Gitea still dispatches it
# as a task — on the ubuntu-latest runners that skip queued behind long
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
# capped at 4 CPUs / 10g.
runs-on: small
needs: [test, migrations]
if: github.event_name != 'pull_request'
steps:
- 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 (ersatztv#390): the CI toolchain image pin in this file must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
# BLOCKING (ersatztv#303 H9): docs/decisions.md is an append-only log. Fails a PR that deletes or
# rewrites a settled entry (numstat reports >0 deleted lines) unless a commit in the range carries
# the [decisions-edit] override token for a documented factual fix. Same script the Husky commit-msg
# hook calls, so local and CI enforcement can't drift. Seconds-long git diff -> keep it off the build runners.
decisions-guard:
name: decisions.md append-only
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Enforce append-only
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=200 origin "$base_ref" || true
./.claude/hooks/decisions-guard.sh range "origin/${base_ref}" HEAD
- name: Consolidation-floor reminder (non-blocking)
run: |
# Consolidation is primarily a release step; this is the between-releases floor. The metric is
# the file's LINE COUNT — the context an agent actually burns reading the log — not entry count.
# Floor 1800 keeps the whole log inside one default 2000-line Read (headroom for the reader's
# own overhead). Nudge (never fail) past it so append-only can't grow past what agents can read.
n=$(wc -l < docs/decisions.md | tr -d ' ')
echo "docs/decisions.md is ${n} lines (consolidation floor: 1800; one Read caps at 2000)."
if [ "${n:-0}" -gt 1800 ]; then
echo "::warning::docs/decisions.md is ${n} lines (>1800) — larger than agents can comfortably read in one pass. Do a consolidation pass (prune/merge superseded entries with [decisions-edit]); don't wait for the next release. See the decisions.md header."
fi
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
# surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated
# 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:07048b8
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 (style + charset=utf-8, i.e. no UTF-8 BOM). Scoped to changed files so it enforces
# "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500 pre-existing
# BOM files. A PR that touches no .cs skips the expensive steps and passes trivially (always reports
# a status, so it is safe as a required check).
format:
name: Formatting (changed .cs conform to .editorconfig)
# Was on the `small` lane (ersatztv#390) to dodge a ~29 min queue; reverted to `ubuntu-latest`
# in ersatztv#406 — `dotnet format` needs the .NET SDK and real memory, so it does not belong
# in a lane sized for seconds-long shell jobs. See the api-docs job above for the full
# rationale; server-management#604 grew this lane so the queue it was dodging is gone.
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
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: Cache NuGet packages
if: steps.detect.outputs.cs_changed == 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.cs_changed == 'true'
run: dotnet restore
- name: Verify formatting of changed .cs files
if: steps.detect.outputs.cs_changed == 'true'
shell: bash
run: |
mapfile -t files < /tmp/changed-cs.txt
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig..."
if ! dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"; then
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (formatting or a UTF-8 BOM). Run 'dotnet format ErsatzTV.sln --include <files>' and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
exit 1
fi
echo "All changed .cs files conform to .editorconfig."
-70
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
github: jasongdove
custom: "https://www.paypal.me/jasongdove"
-14
View File
@@ -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
-77
View File
@@ -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.
+26
View File
@@ -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
+242
View File
@@ -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 }}
+58
View File
@@ -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 }}
+125
View File
@@ -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' }}
+27
View File
@@ -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.
+81
View File
@@ -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
+53
View File
@@ -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 }}
-20
View File
@@ -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
@@ -49,18 +40,7 @@ msbuild.wrn
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/
# Per-session worktree-ownership marker (H7, ersatztv#303) — local, never committed
.claude-worktree-owner
-15
View File
@@ -1,15 +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
}
# H9 (ersatztv#303) — docs/decisions.md is append-only. Block a commit that rewrites a settled
# entry unless the message carries [decisions-edit]. commit-msg runs after the index is final, so
# the staged diff is what's being committed; the message file ($1) supplies the override token.
./.claude/hooks/decisions-guard.sh staged "$1" || exit 1
-25
View File
@@ -1,25 +0,0 @@
cd web && npx lint-staged || exit 1
cd ..
# 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). Scoped to the staged files so we
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
# ~20-40s sln load for web-only commits).
cs_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.cs')
if [ -n "$cs_files" ]; then
echo "husky - dotnet format (verify) on staged .cs files"
# shellcheck disable=SC2086
dotnet format ErsatzTV.sln --verify-no-changes --include $cs_files || {
echo "husky - dotnet format found issues in staged .cs files; run 'dotnet format ErsatzTV.sln --include <files>' to fix"
exit 1
}
fi
-28
View File
@@ -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
-15
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-116
View File
@@ -1,116 +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
- **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root `/` and every legacy route now 302 to `/app`, either via an explicit redirect in `ErsatzTV/LegacyUiRedirects.cs` or the Startup catch-all fallback (any unmatched non-`/api`/`/artwork`/`/docs`/`/openapi` path → `/app`). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE.
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
- **Functional C#**: Language Ext (Option, Either monads throughout)
### Project Layout
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, DI setup |
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
| `ErsatzTV.Scanner/` | Media library scanning |
### Key Files
- **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**: bumblebee (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on bumblebee → `/config` in container
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** `media-servers` stack follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually deploy the stack (Global Auto Update is the daily fallback). Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
## Development
```bash
# Build
dotnet build ErsatzTV.sln
# Run locally (needs FFmpeg in PATH)
dotnet run --project ErsatzTV
# Docker build
docker build -f docker/Dockerfile -t ersatztv:dev .
```
## 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 `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
| Change | Update in the same PR |
|---|---|
| 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 | `docs/decisions.md` (append-only) + the affected doc |
| Add / remove / retitle a doc | `docs/README.md` index |
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
- 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. 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)
## 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), post a PR comment with a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED> @ <head-sha>` — this proves the *latest* commit was reviewed, not a stale earlier diff (ersatztv#242).
- `.husky/pre-push``prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
## Project Boundaries
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
**ersatztv does NOT own**:
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
- NFS mounts, Ansible, DNS, networking → server-management
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
- Jellyfin skill → server-management (symlinked)
**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
View File
@@ -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>
-39
View File
@@ -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>
-109
View File
@@ -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.2" />
<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.115" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
<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.3 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
<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>
+2
View File
@@ -0,0 +1,2 @@
target/
+1035
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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

+6
View File
@@ -0,0 +1,6 @@
use windres::Build;
fn main() {
static_vcruntime::metabuild();
Build::new().compile("ersatztv_windows.rc").unwrap();
}
+2
View File
@@ -0,0 +1,2 @@
id ICON "ersatztv.ico"
ersatztv-icon ICON "ersatztv.ico"
+109
View File
@@ -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;
}
_ => {}
}
}
}
+3 -4
View File
@@ -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,60 +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;
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
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);
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>&lt;category&gt;</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;
}
@@ -1,11 +0,0 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
public record ChannelStreamingSpecsViewModel(
int Height,
int Width,
int Bitrate,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
FFmpegProfileAudioFormat AudioFormat);
@@ -1,6 +1,5 @@
using System.Net;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
using System.Net;
namespace ErsatzTV.Application.Channels;
@@ -11,16 +10,10 @@ public record ChannelViewModel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
@@ -29,11 +22,7 @@ public record ChannelViewModel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg)
ChannelSongVideoMode SongVideoMode)
{
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
@@ -1,74 +0,0 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkDeleteChannelsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IFileSystem fileSystem,
ISearchTargets searchTargets)
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkDeleteChannels request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
dbContext.Channels.RemoveRange(channels);
await dbContext.SaveChangesAsync(cancellationToken);
// Clean up the system-owned weighted-auto-tune artifacts these channels created (#425), inside the
// same transaction — see DeleteChannelHandler for the cascade rationale.
await dbContext.MultiCollections
.Where(mc => mc.OwnedByChannelId != null && channelIds.Contains(mc.OwnedByChannelId.Value))
.ExecuteDeleteAsync(cancellationToken);
await dbContext.SmartCollections
.Where(sc => sc.OwnedByChannelId != null && channelIds.Contains(sc.OwnedByChannelId.Value))
.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
foreach (Channel channel in channels)
{
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (fileSystem.File.Exists(cacheFile))
{
fileSystem.File.Delete(cacheFile);
}
}
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return Right<BaseError, Unit>(Unit.Default);
}
}

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