Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda0318868 | ||
|
|
ac413f731a |
@@ -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
|
||||
@@ -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,69 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Husky pre-push backstop for ersatztv#303 H6 — the fast-forward-to-main path the Claude merge
|
||||
# hook (pretooluse-merge-consent.sh) can't see. Reads git's pre-push ref lines on stdin; for a push
|
||||
# to main it scans the pushed commits for a Gitea close-keyword (`fixes #N`), and if the linked
|
||||
# issue's "## Done-when" checklist still has unticked boxes it BLOCKS the push.
|
||||
#
|
||||
# A git hook has no interactive "ask", so this is deliberately fail-OPEN: it only blocks when it can
|
||||
# positively prove an unticked box (creds present, issue fetched, non-docs change). No creds, Gitea
|
||||
# unreachable, docs-only diff, or no linked issue -> allow (a loud warning at most). The authoritative
|
||||
# gate is the merge hook; this just catches a direct `git push origin main`.
|
||||
#
|
||||
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
|
||||
set -euo pipefail
|
||||
|
||||
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
|
||||
refs=$(cat || true)
|
||||
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
|
||||
|
||||
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
|
||||
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
|
||||
exit 0 # can't verify -> fail-open (the merge hook is the real gate)
|
||||
fi
|
||||
gq() {
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$1" 2>/dev/null || true
|
||||
else
|
||||
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$1" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
zero=0000000000000000000000000000000000000000
|
||||
blocked=""
|
||||
while read -r localref localsha remoteref remotesha; do
|
||||
[ "$remoteref" = "refs/heads/main" ] || continue
|
||||
[ "$localsha" = "$zero" ] && continue # branch deletion
|
||||
# Commit range being pushed. New branch (remotesha all-zero) -> just the tip, don't rescan history.
|
||||
if [ "$remotesha" = "$zero" ]; then range="$localsha -1"; else range="$remotesha..$localsha"; fi
|
||||
msgs=$(git log --format='%B' $range 2>/dev/null || true)
|
||||
issues=$(printf '%s' "$msgs" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
||||
[ -n "$issues" ] || continue
|
||||
|
||||
# Docs-only exemption over the pushed range.
|
||||
changed=$(git diff --name-only $range 2>/dev/null || true)
|
||||
if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
|
||||
continue
|
||||
fi
|
||||
|
||||
for n in $issues; do
|
||||
ibody=$(gq "repos/timothy/ersatztv/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
|
||||
[ -n "$ibody" ] || continue # can't fetch -> fail-open
|
||||
unchecked=$(printf '%s\n' "$ibody" | awk '
|
||||
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
|
||||
grab && /^##[[:space:]]/ {grab=0}
|
||||
grab {print}' | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
|
||||
if [ "${unchecked:-0}" -gt 0 ]; then
|
||||
blocked="${blocked} - issue #$n has $unchecked unticked ## Done-when box(es)\n"
|
||||
fi
|
||||
done
|
||||
done <<EOF
|
||||
$refs
|
||||
EOF
|
||||
|
||||
if [ -n "$blocked" ]; then
|
||||
printf 'husky - H6 merge-consent (ersatztv#303): push to main BLOCKED\n' >&2
|
||||
printf '%b' "$blocked" >&2
|
||||
printf 'Finish/tick every Done-when criterion (incl. adversarial review) first, or push a docs-only change.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# H11 (ersatztv#311) — refuse to push a branch that is BEHIND origin/main: rebase first, do NOT
|
||||
# merge main in. A merge commit drags in files you never touched (e.g. the ~2500 legacy-BOM .cs),
|
||||
# which then trips the pre-commit `dotnet format` hook on code that isn't yours (the #309 session).
|
||||
# Rebasing keeps your diff to exactly what you changed.
|
||||
#
|
||||
# Fail-OPEN on anything we can't decide (a git pre-push hook has no "ask"): not a git repo,
|
||||
# offline / fetch fails, no origin/main, HEAD unresolved -> allow the push. The only hard block is
|
||||
# a positively-proven "behind origin/main". Deliberate exception: ETV_SKIP_REBASE_CHECK=1.
|
||||
set -uo pipefail
|
||||
|
||||
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Best-effort fetch of the latest main; offline / no network -> don't block.
|
||||
git fetch origin main --quiet 2>/dev/null || exit 0
|
||||
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Pushing main itself, or a branch already rebased on top of it, means origin/main is an ANCESTOR
|
||||
# of HEAD -> nothing to rebase, allow.
|
||||
if git merge-base --is-ancestor origin/main HEAD 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
behind=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo '?')
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
|
||||
echo "husky - push blocked (H11): '$branch' is behind origin/main by $behind commit(s)."
|
||||
echo " Rebase before pushing — do NOT merge main in (a merge drags in files you didn't touch,"
|
||||
echo " e.g. legacy-BOM .cs, and trips the format hook on code that isn't yours):"
|
||||
echo " git fetch origin main && git rebase origin/main"
|
||||
echo " Deliberate exception: ETV_SKIP_REBASE_CHECK=1 git push"
|
||||
exit 1
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Agent (subagent spawn) — RAM-gate the fan-out.
|
||||
# The historic 8-9-way crash was RAM starvation, not CPU load; gate on FREE RAM.
|
||||
# Fail-open: if memory_pressure is unavailable/unparsable → allow.
|
||||
set -euo pipefail
|
||||
free=$(memory_pressure -Q 2>/dev/null | grep -oE 'free percentage: [0-9]+' | grep -oE '[0-9]+' || true)
|
||||
[ -z "${free:-}" ] && exit 0
|
||||
|
||||
if [ "$free" -lt 10 ]; then
|
||||
jq -n --arg f "$free" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("Free RAM \($f)% (<10%): do NOT spawn more agents — the historic crash was RAM starvation from an 8-9-way fan-out. Wait for memory_pressure -Q to recover, then retry.")}}'
|
||||
elif [ "$free" -lt 20 ]; then
|
||||
jq -n --arg f "$free" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("Free RAM \($f)% (<20%): near the fan-out ceiling. Confirm before adding another build/implementer agent (read-only recon agents are cheap).")}}'
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Bash — deny commands that violate a HARD RULE.
|
||||
# Fail-open: any parse trouble → allow (exit 0 with no output).
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
|
||||
# Match an actual env ASSIGNMENT in COMMAND POSITION — line start or right after a shell
|
||||
# separator (; && || | ( ), optionally `export`. This deliberately does NOT match the name
|
||||
# when it sits inside a quoted string (echo, git commit -m, jq test payloads), where the
|
||||
# preceding char is a quote/word, not a separator — so mentions of the rule never false-trip.
|
||||
if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(export[[:space:]]+)?ETV_UPDATE_GOLDENS='; then
|
||||
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Blocked: ETV_UPDATE_GOLDENS regenerates golden-test baselines — HARD RULE (docs/handoffs lore); never set it in a session. Update a golden deliberately and reviewed, not via a guarded run."}}'
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,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."
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / browser-navigate — deny opening download/stream endpoints in a tab
|
||||
# (they hang the MCP session; curl them instead). Fail-open on parse trouble.
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
url=$(printf '%s' "$input" | jq -r '.tool_input.url // ""' 2>/dev/null || true)
|
||||
|
||||
if printf '%s' "$url" | grep -qE '/iptv/|\.m3u8|/artwork/|playback\.m3u8'; then
|
||||
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Blocked: do not open download/stream endpoints (/iptv, .m3u8, /artwork, playback.m3u8) in a browser tab — they stall the MCP session. curl them instead (docs/handoffs lore)."}}'
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Bash — deny `git commit`/`git merge` inside a sibling worktree that
|
||||
# a DIFFERENT session created (burned us twice — #289 path-leak, the plumbing-merge
|
||||
# workaround exists precisely because of this). Ownership is a `.claude-worktree-owner`
|
||||
# marker (session id) written at `git worktree add` time by posttooluse-worktree-marker.sh.
|
||||
#
|
||||
# Fail-open by design: no marker, unparsable input, or marker == this session → allow.
|
||||
# So the main tree (never marked) and pre-convention worktrees (no marker) are unaffected;
|
||||
# only a commit/merge into another session's marked worktree is blocked.
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
me=$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)
|
||||
|
||||
# Only guard the state-mutating ops. Match `git commit`/`git merge` in command position
|
||||
# (line start or after a shell separator) so a quoted mention never false-trips.
|
||||
printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git[[:space:]]+(-C[[:space:]]+[^[:space:]]+[[:space:]]+)?(commit|merge)\b' || exit 0
|
||||
|
||||
[ -z "$cwd" ] && cwd="$PWD"
|
||||
|
||||
# Determine the effective directory the git op runs in. Two common redirections in the
|
||||
# lore's usage move it off the session cwd: `git -C <path>` and a leading `cd <path> &&`.
|
||||
effdir="$cwd"
|
||||
cpath=$(printf '%s' "$cmd" | grep -oE 'git[[:space:]]+-C[[:space:]]+[^[:space:]&|;]+' | head -1 | sed -E 's/^git[[:space:]]+-C[[:space:]]+//' | tr -d '"'"'"'' || true)
|
||||
cdpath=$(printf '%s' "$cmd" | grep -oE '^[[:space:]]*cd[[:space:]]+[^[:space:]&|;]+' | head -1 | sed -E 's/^[[:space:]]*cd[[:space:]]+//' | tr -d '"'"'"'' || true)
|
||||
if [ -n "${cpath:-}" ]; then
|
||||
effdir="$cpath"
|
||||
elif [ -n "${cdpath:-}" ]; then
|
||||
effdir="$cdpath"
|
||||
fi
|
||||
# Resolve a relative effective dir against the session cwd.
|
||||
case "$effdir" in /*) : ;; *) effdir="$cwd/$effdir" ;; esac
|
||||
|
||||
root=$(git -C "$effdir" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
[ -z "$root" ] && exit 0
|
||||
marker="$root/.claude-worktree-owner"
|
||||
[ -f "$marker" ] || exit 0
|
||||
owner=$(tr -d '[:space:]' < "$marker" 2>/dev/null || true)
|
||||
[ -z "$owner" ] && exit 0
|
||||
[ "$owner" = "$me" ] && exit 0
|
||||
|
||||
# Marker names a DIFFERENT session → deny.
|
||||
jq -n --arg o "$owner" --arg r "$root" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("Blocked: worktree \($r) is owned by session \($o), not this one. Never commit/merge inside a sibling worktree another session created (#289 path-leak, plumbing-merge workaround). Commit from your own tree; if you genuinely own this worktree now, overwrite its .claude-worktree-owner marker with your session id.")}}'
|
||||
exit 0
|
||||
@@ -1,64 +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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse-worktree-marker.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,11 +3,10 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.3.0.2",
|
||||
"version": "2024.1.1",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
"rollForward": false
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-36
@@ -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,38 +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) ---
|
||||
# Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are referenced centrally
|
||||
# (Directory.Build.targets). Default every analyzer diagnostic to `suggestion` so the new
|
||||
# packs don't fail the TreatWarningsAsErrors build; high-value rules get promoted to
|
||||
# warning/error one at a time (see ersatztv#15 / docs/contributing.md). Explicit per-rule
|
||||
# severities (e.g. ca1848 above) still take precedence over this bulk default.
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,58 +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
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: NuGet vulnerable packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ErsatzTV.sln
|
||||
|
||||
- name: Scan for vulnerable packages (direct + transitive)
|
||||
# bash + `set -euo pipefail` so a failing `dotnet list` (e.g. the audit source
|
||||
# is unreachable while restore served from cache) fails the job instead of
|
||||
# falling through to a false "no vulnerable packages" green.
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Running: dotnet list package --vulnerable --include-transitive"
|
||||
dotnet list ErsatzTV.sln package --vulnerable --include-transitive 2>&1 | tee depscan.txt
|
||||
# `dotnet list package --vulnerable` exits 0 even when advisories exist, so detect
|
||||
# findings by the report marker and fail the run if any are present. Expect this to
|
||||
# be RED until ersatztv#8 clears the current NCalcSync / SQLitePCLRaw advisories;
|
||||
# after that, a red run means a NEW advisory has appeared.
|
||||
if grep -q "has the following vulnerable packages" depscan.txt; then
|
||||
echo "::error::Vulnerable NuGet packages detected — see report above (tracked: ersatztv#8)."
|
||||
exit 1
|
||||
fi
|
||||
echo "No vulnerable packages found."
|
||||
@@ -1,541 +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).
|
||||
|
||||
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' }}
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
IMAGE: 192.168.1.95:3000/timothy/ersatztv
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# only the test job's steps below need the working tree; git history/tags
|
||||
# are only needed by the `build` job's `git describe` (ersatztv#190)
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
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
|
||||
run: dotnet restore
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install SPA dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
# 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.
|
||||
options: >-
|
||||
--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
|
||||
# default fetch-depth: 1 -- this job never runs git describe/log, only
|
||||
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
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
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Install dotnet-ef
|
||||
run: dotnet tool install --global dotnet-ef --version 9.0.12
|
||||
|
||||
# SQLite is the prod provider; both checks validated locally.
|
||||
- name: SQLite — model drift + apply all migrations to a fresh DB
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
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
|
||||
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
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
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::"
|
||||
|
||||
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
|
||||
|
||||
- name: Compute version and tags
|
||||
id: meta
|
||||
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
|
||||
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/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') }}
|
||||
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"
|
||||
docker run -d --name "$NAME" --memory 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
|
||||
|
||||
# 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)
|
||||
runs-on: ubuntu-latest
|
||||
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: Setup .NET
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- 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: Setup Node
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- 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)
|
||||
runs-on: ubuntu-latest
|
||||
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: Setup .NET
|
||||
if: steps.detect.outputs.cs_changed == 'true'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- 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."
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
github: jasongdove
|
||||
custom: "https://www.paypal.me/jasongdove"
|
||||
@@ -1,14 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://features.ersatztv.org
|
||||
about: Features
|
||||
- name: Contact
|
||||
url: https://ersatztv.org/contact
|
||||
about: Chat Options
|
||||
- name: Community
|
||||
url: https://discuss.ersatztv.org
|
||||
about: Forum
|
||||
- name: Discussions
|
||||
url: https://github.com/ErsatzTV/ErsatzTV/discussions
|
||||
about: Discuss
|
||||
@@ -1,77 +0,0 @@
|
||||
name: Issue Report
|
||||
description: Report an issue
|
||||
type: Bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this form! Please make sure to fill all fields, including the Title above.
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
|
||||
options:
|
||||
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://ersatztv.org/contact) first to troubleshoot with volunteers before creating a report.
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
|
||||
required: true
|
||||
- label: I'm using an up to date version of ErsatzTV (full release or develop release); We generally do not support previous older versions. If possible, please update to the latest version before opening an issue.
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Description of the problem or issue here.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro-steps
|
||||
attributes:
|
||||
label: Steps to reproduce the problem.
|
||||
description: |
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
3. Step 3
|
||||
|
||||
If this is a playback issue, follow these steps and post the resulting zip:
|
||||
1. Search for the required content using the search bar.
|
||||
2. Use the overflow/three dots menu on the content and select Troubleshoot Playback.
|
||||
3. Select the appropriate Playback Settings that trigger the undesired behavior.
|
||||
4. Click Play to start playback.
|
||||
5. Repeat steps 3 and 4 until the undesired behavior is reproduced.
|
||||
6. Click Download Results to have ErsatzTV collect relevant troubleshooting logs (ffmpeg log, ffmpeg profile, hardware capabilities, media info, etc) and compress them in a zip file.
|
||||
7. Attach the zip to this field.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: What is the current _bug_ behavior?
|
||||
description: Write down the incorrect behavior that currently happens after following the reproduction steps.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: What is the expected _correct_ behavior?
|
||||
description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Specify full version
|
||||
description: Provide the full version of ErsatzTV, which can be found below the left menu.
|
||||
placeholder: |
|
||||
25.5.0-bd695412-docker-amd64
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any additional information that might be useful to this issue.
|
||||
@@ -0,0 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/nvidia"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/vaapi"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
@@ -0,0 +1,246 @@
|
||||
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-14
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-14
|
||||
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
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- 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 net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -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 net9.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -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
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- 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/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/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 net9.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -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 net9.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
mkdir "$release_name"
|
||||
mv scanner/* "$release_name/"
|
||||
mv main/* "$release_name/"
|
||||
|
||||
# Build Windows launcher
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||
ls -l ErsatzTV-Windows/target/release
|
||||
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
fi
|
||||
|
||||
# Download ffmpeg
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
fi
|
||||
|
||||
# Pack files
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
else
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
fi
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: |
|
||||
*${{ matrix.target }}.zip
|
||||
*${{ matrix.target }}.tar.gz
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: |
|
||||
${{ env.RELEASE_NAME }}.zip
|
||||
${{ env.RELEASE_NAME }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
calculate_version:
|
||||
name: Calculate version information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
tag2="${tag:1}"
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag2}-${short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag}-${short}"
|
||||
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
git_tag: ${{ env.GIT_TAG }}
|
||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||
info_version: ${{ env.INFO_VERSION }}
|
||||
build_and_upload:
|
||||
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
release_tag: develop
|
||||
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
info_version: ${{ needs.calculate_version.outputs.info_version }}
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
|
||||
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_and_push:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
base_version: develop
|
||||
info_version: ${{ needs.calculate_version.outputs.git_tag }}
|
||||
tag_version: ${{ github.sha }}
|
||||
secrets:
|
||||
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
@@ -0,0 +1,117 @@
|
||||
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: 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
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}
|
||||
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
platforms: 'linux/arm64'
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm64' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
builder: ${{ steps.docker-buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
platforms: 'linux/arm/v7'
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
tags: |
|
||||
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
|
||||
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
@@ -0,0 +1,27 @@
|
||||
name: 'Close stale issues'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
ascending: true
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: 'regression,security,roadmap,future,feature,enhancement,confirmed'
|
||||
stale-issue-label: 'stale'
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||
|
||||
If you have any questions you can use one of several ways to [contact us](https://ersatztv.org).
|
||||
close-issue-message: |-
|
||||
This issue was closed due to inactivity.
|
||||
@@ -0,0 +1,87 @@
|
||||
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
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- 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
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -p:RestoreEnablePackagePruning=true -r linux-x64
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build ErsatzTV/ErsatzTV.csproj --runtime linux-x64 --configuration Release --no-restore && 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-14
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.203
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Release
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
jobs:
|
||||
calculate_version:
|
||||
name: Calculate version information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "ARTIFACTS_VERSION=${tag}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
git_tag: ${{ env.GIT_TAG }}
|
||||
docker_tag: ${{ env.DOCKER_TAG }}
|
||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||
info_version: ${{ env.INFO_VERSION }}
|
||||
build_and_upload:
|
||||
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
info_version: ${{ needs.calculate_version.outputs.info_version }}
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
|
||||
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_and_push:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
base_version: latest
|
||||
info_version: ${{ needs.calculate_version.outputs.git_tag }}
|
||||
tag_version: ${{ needs.calculate_version.outputs.docker_tag }}
|
||||
secrets:
|
||||
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
-18
@@ -3,13 +3,6 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
.mcp.json
|
||||
.agents/
|
||||
plugins/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
@@ -47,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,22 +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
|
||||
|
||||
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
|
||||
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
|
||||
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
|
||||
# only sees staged files).
|
||||
cd web && npm run check:api && npm run lint && npm run typecheck && npm run build
|
||||
@@ -1,15 +0,0 @@
|
||||
# Codex Instructions
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution.
|
||||
|
||||
Preferred verification commands:
|
||||
|
||||
```bash
|
||||
TZ=UTC dotnet restore ErsatzTV.sln -v minimal
|
||||
TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal
|
||||
TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal
|
||||
```
|
||||
|
||||
Use scoped escalated execution for these commands rather than first trying a sandboxed run.
|
||||
+2
-881
@@ -4,787 +4,6 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
- Remove BugSnag error reporting integration
|
||||
- Remove developer's personal Trakt API key
|
||||
- Users who want to continue to use Trakt must create an API app and set the `Client ID` as the environment variable `TRAKT__CLIENTID`
|
||||
|
||||
### Fixed
|
||||
- Support adding trakt lists using `app.trakt.tv` domain (instead of just `trakt.tv`)
|
||||
|
||||
## [26.3.0] - 2026-02-24
|
||||
### Added
|
||||
- Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback
|
||||
- Log messages will include media item id, channel number and transcoding speed
|
||||
- Add UI language setting to **Settings** > **UI**
|
||||
- A small number of translations have been added for `Português (Brasil)` and `Polski`
|
||||
- Translation contributions are always welcome!
|
||||
- Add `Troubleshoot` button to playout details table to show info that may be helpful in determining the source of a playout item
|
||||
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
|
||||
- Block schedule info includes block, block item, playback order, random seed, collection index
|
||||
- E.g. items with the same random seed are part of the same shuffle
|
||||
- Add channel setting `Slug Seconds`
|
||||
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
|
||||
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
|
||||
- If this feature turns out to be popular, methods to correct the drift may be investigated
|
||||
- Add `ETV_INSTANCE_ID` environment variable to disambiguate EPG data from multiple ErsatzTV instances
|
||||
- When set, the value will be used in channel identifiers before the final `.ersatztv.org`
|
||||
- Show warning message when selecting audio format `aac (latm)` for general streaming use when it is only intended for DVB-C
|
||||
|
||||
### Changed
|
||||
- Move dark/light mode toggle to **Settings** > **UI**
|
||||
- Use latest (non-deprecated) authorization method with Jellyfin API
|
||||
- Replace direct Discord links with new contact page https://ersatztv.org/contact which also includes other options like Matrix
|
||||
- Lower GOP size and keyframe interval from four seconds to two seconds in accordance with HLS2 draft spec recommendations
|
||||
|
||||
### Fixed
|
||||
- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time
|
||||
- Fix Trakt list sync
|
||||
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
|
||||
- This only applies to content that *might* be problematic (using a heuristic)
|
||||
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
|
||||
- Graphics engine: fix stream seek value used throughout graphics engine
|
||||
- This should fix loading EPG data when used with chapters/mid-roll
|
||||
- This should also fix graphics element visibility when using start_seconds on content with chapters/mid-roll
|
||||
- This bug was caused by stream seek including the playout item in-point (the chapter start time)
|
||||
- Stream seek should only be non-zero when first joining a channel (i.e. in the middle of a playout item or chapter)
|
||||
|
||||
## [26.2.0] - 2026-02-02
|
||||
### Added
|
||||
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
|
||||
- en-US can match sunday using `day_of_week = 0`
|
||||
- fr-FR can match sunday using `day_of_week = 6`
|
||||
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
|
||||
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
|
||||
- Add `Pad Mode` to ffmpeg profile. Options are:
|
||||
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
|
||||
- `Software` - force software padding
|
||||
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
|
||||
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
|
||||
- Add API endpoint to clean artwork cache folder (on demand)
|
||||
- POST `/api/maintenance/clean_artwork`
|
||||
- Add health check to warn about unsupported empty (classic) schedules
|
||||
- Add health check to warn about incompatible ffmpeg due to missing filters
|
||||
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
|
||||
- `ffmpeg@7` or `ffmpeg-full` should be used instead
|
||||
- Add `Marathon Group By` option `Director`
|
||||
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
|
||||
- This is supported in classic schedules and sequential schedules
|
||||
- Add FFmpeg Profile options:
|
||||
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
|
||||
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
|
||||
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
|
||||
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
|
||||
- Add chapter `title` to filler expression
|
||||
- This can be used to include or exclude chapters with specific (case-insensitive) titles
|
||||
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
|
||||
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
|
||||
|
||||
### Changed
|
||||
- Disable automatic artwork database cleanup
|
||||
- This will be re-enabled at some point in the future (after more testing)
|
||||
- For now, the API should be used to clean as needed
|
||||
- Classic Schedules: make multiple `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the collection
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the collection
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
|
||||
### Fixed
|
||||
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
|
||||
- Graphics engine:
|
||||
- Respect `z_index` (draw order) on all graphics element types
|
||||
- Fix bug with `z_index` sorting
|
||||
- Restore default UI font that was erroneously removed in v26.1.1
|
||||
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
|
||||
- Use configured searching log level on startup, instead of the default log level of `Information`
|
||||
- MySql: fix searching for shows and seasons in schedule items editor
|
||||
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
|
||||
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
|
||||
- Use other video artwork in XMLTV template
|
||||
- Properly update (add or remove) artwork for all local media libraries when files have changed
|
||||
- Sync Plex library name changes
|
||||
- Sync Plex episode title, plot, year, date added, release date, episode number changes
|
||||
- Sync Jellyfin and Emby library name and type changes
|
||||
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
|
||||
- Fix some sequential and scripted playout build failures when using playlists or marathons
|
||||
- Fix erasing playout items and history so all related data is also erased
|
||||
- This includes rerun history, unscheduled gaps, build status
|
||||
- Fix indexing collections when using Elasticsearch backend
|
||||
|
||||
## [26.1.1] - 2026-01-08
|
||||
### Fixed
|
||||
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
|
||||
|
||||
### Changed
|
||||
- Optimize database check for orphaned artwork
|
||||
- Include web resources (CSS, JS) locally instead of relying on CDNs
|
||||
|
||||
## [26.1.0] - 2026-01-06
|
||||
### Added
|
||||
- Graphics Engine:
|
||||
- Add `script` graphics element type
|
||||
- Supported in playback troubleshooting and all scheduling types
|
||||
- Supports arbitrary scripts or executables that output graphics to ETV via stdout
|
||||
- Supports EPG and Media Item replacement in entire template
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- All template data will also be passed as JSON to the stdin stream of the command
|
||||
- Template supports:
|
||||
- Script and arguments (`command` and `args`)
|
||||
- Draw order (`z_index`)
|
||||
- Timing (`start_seconds` and `duration_seconds`)
|
||||
- Data format (`format`)
|
||||
- `raw` format means full frames of BGRA data to stdout
|
||||
- `packet` format means ETV graphics packets to stdout
|
||||
- Add framerate template data
|
||||
- `RFrameRate` - the real content framerate (or channel normalized framerate) as reported by ffmpeg, e.g. `30000/1001`
|
||||
- `FrameRate` - the decimal representation of `RFrameRate`, e.g. `29.97002997`
|
||||
- Add `Channel_StartTime` template data
|
||||
- This indicates the time that the transcode session started for the current channel
|
||||
- Add remote stream metadata
|
||||
- Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields
|
||||
- Remote streams can now have thumbnails (same name as yaml file but with image extension)
|
||||
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
|
||||
- Add `Download Media Sample` button to playback troubleshooting
|
||||
- This button will extract up to 30 seconds of the media item and zip it
|
||||
- Add `Target Loudness` (LUFS/LKFS) to ffmpeg profile when loudness normalization is enabled
|
||||
- Default value is `-16`; some sources normalize to a quieter value, e.g. `-24`
|
||||
- Add environment variables to help troubleshoot performance
|
||||
- `ETV_SLOW_DB_MS` - milliseconds threshold for logging slow database queries (at DEBUG level)
|
||||
- e.g. if this is set to `1000`, queries taking longer than 1 second will be logged
|
||||
- `ETV_SLOW_API_MS` - milliseconds threshold for logging slow API calls (at DEBUG level)
|
||||
- This is currently limited to *Jellyfin*
|
||||
- `ETV_JF_PAGE_SIZE` - page size for library scan API calls to Jellyfin; default value is 10
|
||||
- `ETV_JF_ENABLE_STATS` - enables logging timing information related to Jellyfin show library scans
|
||||
- Add `Select All` button to media pages by @Erotemic
|
||||
|
||||
### Fixed
|
||||
- Fix startup on systems unsupported by NvEncSharp
|
||||
- Fix detection of Plex Other Video libraries using `Plex Personal Media` agent
|
||||
- If the library is already detected as a Movies library in ETV, synchronization must be disabled for the library to change it to an Other Videos library
|
||||
- A warning will be logged when this scenario is detected
|
||||
- Graphics Engine:
|
||||
- Optimize graphics engine to generate element frames in parallel and to eliminate redundant frame copies
|
||||
- Match graphics engine framerate with source content (or channel normalized) framerate
|
||||
- Fix loading requested number of epg entries for motion graphics elements
|
||||
- Fix bug with mirror channels where seemingly random content would be played every ~40 seconds
|
||||
- Fix chronological sorting for Other Videos that have release date metadata
|
||||
- Fix playout sorting after using channel number editor
|
||||
- VAAPI: Only include `-sei a53_cc` flags when misc packed headers are supported by the encoder
|
||||
- This should fix playback in some cases, e.g. AMD VAAPI h264 encoder
|
||||
- AMD VAAPI:
|
||||
- work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080
|
||||
- fix green padding when encoding h264 using main profile
|
||||
- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes
|
||||
- Fix playback of certain BT.2020 content
|
||||
- Use playlist item count when using a playlist as filler (instead of a fixed count of 1 for each playlist item)
|
||||
- NVIDIA:
|
||||
- Fix stream failure with certain content that should decode in hardware but falls back to software
|
||||
- Fix stream failure with content that changes color metadata mid-stream
|
||||
- Fix stream failure when configured fallback filler collection is empty
|
||||
- Fix high CPU when errors are displayed; errors will now work ahead before throttling to realtime, similar to primary content
|
||||
- Fix startup error caused by duplicate smart collection names (and no longer allow duplicate smart collection names)
|
||||
- Fix erroneous downgrade health check failure with some installations that use MariaDB
|
||||
- Sequential schedules: fix `count` instruction validation to accept integer (constant) or string (expression)
|
||||
- Fix multi-part episode grouping logic so that it does NOT require release date metadata for episodes within a single show
|
||||
- When **Treat Collections As Shows** is enabled (i.e. for crossover episodes) release date metadata is required for proper grouping
|
||||
- Fix *many* cases of duplicate names; enforce case-insensitive unique names at the db schema level
|
||||
- Fix playback when using `ETV_BASE_URL` by @JamesDearlove
|
||||
|
||||
### Changed
|
||||
- No longer round framerate to nearest integer when normalizing framerate
|
||||
- Allow playlists to have no items included in EPG
|
||||
- Change how fallback filler works
|
||||
- Items will no longer loop; instead, a sequence of random items will be selected from the collection
|
||||
- Items may still be cut as needed
|
||||
- Hardware acceleration will now be used
|
||||
- Items can "work ahead" (transcode faster than realtime) when less than 3 minutes in duration
|
||||
- Optimize Jellyfin database fields and indexes
|
||||
- Optimize Jellyfin show library scans by only requesting `People` (actors, directors, writers) when etags don't match
|
||||
- This should significantly speed up periodic library scans, particularly against Jellyfin 10.11.x
|
||||
- Lazy load media item images in UI
|
||||
- Align alternate schedule and template handling (between classic schedules and block schedules)
|
||||
- Both systems now support limiting to a date range
|
||||
- This date range can be repeating (when year is not specified for start or end dates)
|
||||
- This date range can be exact (when year is specified for start and end dates)
|
||||
|
||||
## [25.9.0] - 2025-11-29
|
||||
### Added
|
||||
- Show playout warnings count badge in left menu
|
||||
- Graphics Engine:
|
||||
- Add `MediaItem_Resolution` template data (the current `Resolution` variable is the FFmpeg Profile resolution)
|
||||
- Add `MediaItem_Start` template data (DateTimeOffset)
|
||||
- Add `MediaItem_Stop` template data (DateTimeOffset)
|
||||
- Add `ScaledResolution` template data (the final size of the frame before padding)
|
||||
- Add `place_within_source_content` (true/false) field to image graphics element
|
||||
- Add `name` field to all graphics elements to display in the UI
|
||||
- Classic and block schedules: add collection type `Search Query`
|
||||
- This allows defining search queries directly on schedule items without creating smart collections beforehand
|
||||
- As an example, this can be used to filter or combine existing smart collections
|
||||
- Filter: `smart_collection:"sd movies" AND plot:"christmas"`
|
||||
- Combine: `smart_collection:"old commercials" OR smart_collection:"nick promos"`
|
||||
- Scripted schedules: add `custom_title` to `start_epg_group`
|
||||
- Add MPEG-TS Script system
|
||||
- This allows using something other than ffmpeg (e.g. streamlink) to concatenate segments back together when using MPEG-TS streaming mode
|
||||
- Scripts live in config / scripts / mpegts
|
||||
- Each script gets its own subfolder which contains an `mpegts.yml` definition and corresponding windows (batch) and linux (bash) scripts
|
||||
- The global MPEG-TS script can be configured in **Settings** > **FFmpeg** > **Default MPEG-TS Script**
|
||||
- Add `.avs` AviSynth Script support to all local libraries
|
||||
- `.avs` was added as a valid extension, so they should behave the same any other video file
|
||||
- There are two requirements for AviSynth Scripts to work:
|
||||
- FFmpeg needs to be compiled with AviSynth support (not currently available in Docker)
|
||||
- AviSynth itself needs to be installed
|
||||
- Add `Troubleshoot` button to classic schedule list
|
||||
- This generates JSON representing the entire schedule which can be shared when requested for troubleshooting
|
||||
- Add **Settings** > **FFmpeg** > **Probe For Interlaced Frames**
|
||||
- When enabled, this will probe *local content* for interlaced frames on demand (immediately before playback)
|
||||
- This will be used as a more accurate check for interlaced content
|
||||
- The result will be cached (only probed once and stored) in the database along with all other media item statistics (e.g. duration)
|
||||
- This feature will currently ignore content that is not streamed from disk
|
||||
- Add error/offline background customization
|
||||
- Default error background is now named `_background.png`
|
||||
- Error streams will prioritize using `background.png` if it exists
|
||||
- Replacing this `background.png` file will allow custom error/offline backgrounds
|
||||
- Add `Troubleshoot Playback` buttons on movie and episode detail pages
|
||||
- Add song background and missing album art customization
|
||||
- Default files start with an underscore; custom versions must remove the underscore
|
||||
- Expose arbitrary EPG data to graphics engine via channel guide templates
|
||||
- XML nodes using the `etv:` namespace will be passed to the graphics engine EPG template data
|
||||
- For example, adding `<etv:episode_number_key>{{ episode_number }}</etv:episode_number_key>` to `episode.sbntxt` will also add the `episode_number_key` field to all EPG items in the graphics engine
|
||||
- All values parsed from XMLTV will be available as strings in the graphics engine (not numbers)
|
||||
- All `etv:` nodes will be stripped from the XMLTV data when requested by a client
|
||||
- Add channel troubleshooting button to channels list
|
||||
- This will open the playback troubleshooting tool in "channel" mode
|
||||
- This mode requires entering a date and time, and will play up to 30 seconds of *one item from that channel's playout* starting at the entered date and time
|
||||
- Block schedules: add copy template button to templates table
|
||||
|
||||
### Fixed
|
||||
- Fix HLS Direct playback with Jellyfin 10.11
|
||||
- Fix remote stream scripts (parsing issue with spaces and quotes)
|
||||
- Fix block history being removed when it is still needed for mirror channel
|
||||
- This caused playout build errors like "Unable to locate history for playout item"
|
||||
- Fix crashes due to invalid smart collection searches, e.g. `smart_collection:"this collection does not exist"`
|
||||
- Fix UI crash when editing block playout that has default deco
|
||||
- Fix playback failure when seeking content with certain DTS audio (e.g. DTS-HD MA)
|
||||
- Properly set explicit audio decoder on combined audio and video input file
|
||||
- Fix building sequential schedules across a UTC offset change
|
||||
- Fix block start time calculation across a UTC offset change
|
||||
- Fix classic schedule start time calculation across a UTC offset change
|
||||
- Fix XMLTV generation for channels using on-demand playout mode
|
||||
- Fix some file not found songs missing from trash view
|
||||
- Fix error/offline screen generation
|
||||
- Fix subtitle title sync from Jellyfin libraries
|
||||
- Deep scans will be required to update subtitle titles on existing media items
|
||||
- Fix saving subtitle title changes to the database
|
||||
- This fixes e.g. where stream selection would continue to use the original title
|
||||
- This fix applies to all libraries (local and media server)
|
||||
- Fix (3 year old) bug removing tags from local libraries when they are removed from NFO files (all content types)
|
||||
- New scans will properly remove old tags; NFO files may need to be touched to force updating during a scan
|
||||
- Fix bug where looping motion graphics wouldn't be displayed when seeking into second half of content
|
||||
- Fix `content_total_duration` value in graphics engine opacity expressions
|
||||
- This bug caused some graphics elements to display too early after first joining a channel
|
||||
- Optimize database calls made for search index rebuilds and updates
|
||||
- This should improve performance of library scans
|
||||
- Add toggle to hide/show disabled channels in channel list
|
||||
- Add disabled text color and `(D)` and `(H)` labels for disabled and hidden channels in channel list
|
||||
- Graphics engine: fix subtitle path escaping and font loading
|
||||
- Fix corrupt output (green artifacts) when decoding certain 10-bit content using AMD Polaris GPUs
|
||||
- Work around sequential schedule validation limit (1000/hr by Newtonsoft.Json.Schema library)
|
||||
- Playout builds now use JsonSchema.Net library which has no validation limit
|
||||
- Validation tool in the UI still uses Newtonsoft.Json.Schema (with 1000/hr limit) as the error output is easier to understand
|
||||
- Fix editing scripted and sequential playouts when using MySql
|
||||
- Fix HLS Direct streams remaining open after client disconnect
|
||||
- Always log scanner exit code when it is non-zero
|
||||
|
||||
### Changed
|
||||
- Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them
|
||||
- This mode maintains progress; progress can be reset by editing the playout and clicking `Erase Items and History`
|
||||
- Use smaller batch size for search index updates (100, down from 1000)
|
||||
- This should help newly scanned items appear in the UI more quickly
|
||||
- Replace favicon and logo in background image used for error streams
|
||||
- Block schedules:
|
||||
- Auto scroll day view to block item time when adding and removing block items from template
|
||||
- Allow keyboard selection of
|
||||
- Block groups in block list
|
||||
- Template groups in template list
|
||||
- Block groups and blocks in template editor
|
||||
- Replace template tree view with searchable table (like blocks)
|
||||
- Upgrade to dotnet 10
|
||||
|
||||
## [25.8.0] - 2025-10-26
|
||||
### Added
|
||||
- Graphics engine:
|
||||
- Add template data (like `MediaItem_Title`) for other video files
|
||||
- Add `MediaItem_Path` for movies, episodes, music videos and other videos
|
||||
- Add `get_directory_name` and `get_filename_without_extension` functions for path processing
|
||||
- Add `text_align` property to text graphics elements (values: `left`, `right` and `center`)
|
||||
- Add `MiddleCenter` value to `location` property on all graphics elements
|
||||
- Positive and negative margins can be used to offset from center as desired
|
||||
- Add `line_height` property to text element style definition
|
||||
- This is a multiplier that defaults to 1.0 when unspecified
|
||||
- Add `halo_color`, `halo_width` and `halo_blur` properties to text element style definition
|
||||
- These can be used to "outline" text with the configured color (e.g. `#000000`), width (e.g. `10`) and amount of blur (e.g. `2`)
|
||||
- Add `Block Playout Troubleshooting` tool to help investigate block playout history
|
||||
- Add sequential schedule file and scripted schedule file names to playouts table
|
||||
- Add empty (but already up-to-date) sqlite3 database to greatly speed up initial startup for fresh installs
|
||||
- Add button to copy/clone block from blocks table
|
||||
- Add playback speed to playback troubleshooting output
|
||||
- Speed is relative to realtime (1.0x is realtime)
|
||||
- Speeds < 0.9x will be colored red, between 0.9x and 1.1x colored yellow, and > 1.1x colored green
|
||||
- Add episode thumbnail artwork URL to XMLTV template
|
||||
- By default, poster will be added as image with type "poster" and thumbnail will be added as image with type "still"
|
||||
- Poster will continue to be added as icon by default
|
||||
- Add buttons to edit Jellyfin and Emby connection information in **Media Sources** > **Jellyfin** and **Media Sources** > **Emby**
|
||||
- Add audio format `aac (latm)` for DVB-C compatibility; `aac` uses ADTS by default which is required in most cases
|
||||
- Add deep scan option for external collections (Plex, Jellyfin, Emby)
|
||||
- Jellyfin and Emby collection scans have always been deep scans
|
||||
- Now, by default, they will be quick scans that trust Jellyfin and Emby's etags for detecting changes
|
||||
- If a quick scan misses updating a collection, deep scans can be triggered manually
|
||||
|
||||
### Fixed
|
||||
- Fix NVIDIA startup errors on arm64
|
||||
- Fix remote stream durations in playouts created using block, sequential or scripted schedules
|
||||
- Fix playback troubleshooting selecting a subtitle even with no subtitle stream selected in the UI
|
||||
- Fix intermittent watermark opacity
|
||||
- Improve reliability of live remote streams; they should transcode closer to realtime in most cases
|
||||
- Dramatically improve stream startup time
|
||||
- VAAPI: fix scaling image-based subtitles (e.g. dvdsub)
|
||||
- VAAPI: fix overlaying picture subtitles with scaling behavior crop
|
||||
- Fix HLS Segmenter (fmp4) on Windows
|
||||
- Playback troubleshooting: wait for at least 2 initial segments (up to configured initial segment count) to reduce stalls
|
||||
- Fix Trakt List sync
|
||||
- Fix QSV audio sync
|
||||
- Fix QSV capability detection on Linux using non-drm displays (e.g. wayland)
|
||||
- Fix playlist filtering bug that made HLS Segmenter more likely to fail when streaming for multiple hours
|
||||
- Fix NVIDIA overlaying text subtitles and permanent watermark on 10-bit content
|
||||
- Fix UI error adding deco
|
||||
- Fix UI error editing watermarks and graphics elements on blocks
|
||||
- Fix showing playout build failure details when resetting a playout
|
||||
- Fix scheduling auto-generated trakt list playlists that contain shows
|
||||
- Fix playout builder getting stuck (forever) on block item with an empty collection
|
||||
- Fix HLS Direct playback when using custom stream selector or preferred audio language/title
|
||||
- Fix selecting embedded subtitles (text and picture) with HLS Direct
|
||||
- Fix building scripted schedules across a UTC offset change
|
||||
|
||||
### Changed
|
||||
- Do not use graphics engine for single, permanent watermark
|
||||
- Rename `YAML Validation` tool to `Sequential Schedule Validation`
|
||||
- Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end
|
||||
- Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode
|
||||
- Remove *experimental* `HLS Segmenter (fmp4)` streaming mode; this mode only worked properly in a browser, many clients did not like it
|
||||
- Change how scanner process and main process communicate, which should improve reliability of search index updates when scanning
|
||||
|
||||
## [25.7.1] - 2025-10-09
|
||||
### Added
|
||||
- Add search field to filter blocks table
|
||||
- Show full error/exception details in playback troubleshooting logs
|
||||
- Add basic free space validation on startup
|
||||
- ETV will now fail to start with less than 128 MB free space in config or transcode folders
|
||||
- Add downgrade health check to inform users when they are doing something that WILL impact stability
|
||||
|
||||
### Fixed
|
||||
- Do not allow deleting ffmpeg profiles that are used by channels
|
||||
- Do not allow deleting default ffmpeg profile
|
||||
- Allow ffmpeg profiles using VAAPI accel to set h264 video profile
|
||||
- Fix HLS Direct playback, and make it accessible on separate streaming port
|
||||
- Fix playback troubleshooting when using multiple watermarks or multiple graphics elements
|
||||
|
||||
### Changed
|
||||
- Use table instead of tree view on blocks page
|
||||
- Use different release packaging system to workaround false positive from Windows Defender
|
||||
|
||||
## [25.7.0] - 2025-10-03
|
||||
### Added
|
||||
- Add new collection type `Rerun Collection`
|
||||
- This collection type will show up as *two* collection types in classic schedules
|
||||
- `Rerun (First Run)`
|
||||
- `Rerun (Rerun)`
|
||||
- The playback order for each of these collection types can be set on the rerun collection itself
|
||||
- e.g. `Season, Episode` order for first run, `Shuffle` for rerun
|
||||
- When a first run item is added to a playout, it will immediately be made available in the rerun collection
|
||||
- Rerun history is currently scoped to the playout, and only supported in classic schedules
|
||||
- This means resetting the playout will reset the rerun history
|
||||
- Items will still be scheduled from the rerun collection if it is used before the first run collection
|
||||
- Otherwise, the rerun collection would be considered "empty" which prevents the playout build altogether
|
||||
- Add `Rkmpp` hardware acceleration by @peterdey
|
||||
- This is supported using jellyfin-ffmpeg7 on devices like Orange Pi 5 Plus and NanoPi R6S
|
||||
- Block schedules: allow selecting multiple watermarks on block items
|
||||
- Block schedules: allow selecting multiple graphics elements on block items
|
||||
- Add `motion` graphics element type
|
||||
- Supported in playback troubleshooting and all scheduling types
|
||||
- Supports video files with alpha channel (e.g. vp8/vp9 webm, apple prores 4444)
|
||||
- Supports EPG and Media Item replacement in entire template
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- Template supports:
|
||||
- Content (`video_path`)
|
||||
- Placement (`location`, `horizontal_margin_percent`, `vertical_margin_percent`)
|
||||
- Scaling (`scale`, `scale_width_percent`)
|
||||
- Timing (`start_seconds`)
|
||||
- End behavior (`end_behavior`)
|
||||
- `disappear` (default) - disappear after playing once
|
||||
- `loop` - loop forever
|
||||
- `hold` - hold last frame forever, or `hold_seconds`
|
||||
- Draw order (`z_index`)
|
||||
- Add search fields to filter collections, schedules and playouts tables
|
||||
- Add selected row background color to schedules and playouts tables
|
||||
- Graphics engine text element: add `width_percent` and `text_fit` to support wrapping and scaling text
|
||||
- `text_fit: none` or unspecified will keep existing behavior (render text exactly as configured)
|
||||
- `text_fit: wrap` will wrap text to the given `width_percent`
|
||||
- `text_fit: scale` will scale text *smaller* to fit the given `width_percent`
|
||||
- Text that already fits with the configured style will not be adjusted
|
||||
- Block schedules: add **experimental** `Break Content` to decos
|
||||
- Break content is similar to filler from classic schedules
|
||||
- Break content is currently limited to placement `Block Start` (play before anything else in the block)
|
||||
- Future work will add other placement options
|
||||
- Break content is currently limited to playlists (which do *not* pad - they simply play through the playlist one time)
|
||||
- Future work will add other collection options which will pad to the full block duration
|
||||
- Add page to reorder channels (edit channel numbers) using drag and drop
|
||||
- New page is at **Channels** > **Edit Channel Numbers**
|
||||
- Scripted schedules: add setting to configure timeout of scripted playout build
|
||||
- New setting is at **Settings** > **Playout** > **Scripted Schedule Timeout**
|
||||
- Add *experimental* streaming mode `HLS Segmenter (fmp4)`
|
||||
- This mode is required for better compliance with HLS spec, and to support new output codecs
|
||||
- This mode *will replace* `HLS Segmenter` when it has received more testing
|
||||
- Allow HEVC playback in channel preview
|
||||
- This is restricted to compatible browsers
|
||||
- Preview button will be red when preview is disabled due to browser incompatibility
|
||||
- Add AV1 encoding support with NVIDIA, VAAPI and QSV acceleration
|
||||
- This also requires `HLS Segmenter (fmp4)`
|
||||
- Add `Stream Selector` option to playback troubleshooting tool
|
||||
- This can be helpful for validating stream selector behavior with specific content
|
||||
- Manual subtitle selection will be disabled when using a stream selector
|
||||
- Add basic log viewer to playback troubleshooting tool
|
||||
- Streaming log level will be forced to `Debug` during troubleshooting
|
||||
- Streaming log level will be restored to its previous value after troubleshooting completes
|
||||
- Add playout build status to UI
|
||||
- Playouts that fail to build will be highlighted yellow in the playouts table
|
||||
- Clicking on the failed playout will display the warning or error that caused the playout build to fail
|
||||
|
||||
### Fixed
|
||||
- Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile
|
||||
- Fix playback when invalid video preset has been saved in FFmpegProfile
|
||||
- This can happen when NVIDIA accel falls back to libx264 software encoder for 10-bit h264 output
|
||||
- Fix 10-bit output when using NVIDIA and graphics engine (watermark or other overlays)
|
||||
- Fix playback of Jellyfin content with unknown color range
|
||||
- Block schedules: skip collections (block items) that will never fit in block duration
|
||||
- Block schedules: skip media items that will never fit in block duration
|
||||
- Fix HLS playlist generation for clients that actually care about discontinuities (like hls.js)
|
||||
- This should resolve most playback issues with built-in channel preview
|
||||
- Fix deco dead air fallback selection and duration on mirror channels
|
||||
- Fix fallback filler duration on mirror channels
|
||||
- Fix slow startup caused by check for overlapping playout items
|
||||
- Fix green line in *most* cases when overlaying content using NVIDIA acceleration and H264 output
|
||||
- Fix non-SRT (e.g. SSA/ASS) external subtitle playback from media servers
|
||||
- Fix extracted text subtitle playback from media servers
|
||||
- Fix extracted text subtitles getting into invalid state after media server deep scans
|
||||
- Targeted deep scans will now extract text subtitles for the scanned show
|
||||
- Fix playlist preview
|
||||
- Use NVIDIA NvEnc API to detect encoder capability instead of heuristic based on GPU model/architecture
|
||||
- Use NVIDIA Cuvid API to detect decoder capability instead of heuristic based on GPU model/architecture
|
||||
- Fix filler expression not being respected when using a playlist as filler
|
||||
- Use "repeat count" metadata from animated GIFs in graphics engine (i.e. watermarks)
|
||||
- GIFs flagged to loop forever will loop forever
|
||||
- GIFs with a specific loop count will loop the specified number of times and then hold the final frame
|
||||
- Note that looping is relative to the start of the content, so this works best with permanent watermarks
|
||||
- Fix some more hls.js warnings by adding codec information to multi-variant playlists
|
||||
- Fix hardware decode of h264 constrained baseline content using VAAPI accel
|
||||
- Custom stream selector: ignore embedded text subtitles that have not been extracted
|
||||
- Fix cropping Jellyfin and Emby content that is smaller than the crop resolution
|
||||
- Sync movies with non-file media sources (e.g. http/nfs) from Emby movie libraries by @jasonarends
|
||||
|
||||
### Changed
|
||||
- Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration
|
||||
- Use autocomplete fields for collection searching in deco editor
|
||||
- This greatly improves the editor performance
|
||||
|
||||
## [25.6.0] - 2025-09-14
|
||||
### Added
|
||||
- Classic schedules: allow selecting multiple graphics elements on schedule items
|
||||
- Block schedules: allow selecting multiple graphics elements on decos
|
||||
- Add channel `Playout Source` setting
|
||||
- `Generated`: default/existing behavior where channel must have its own playout
|
||||
- `Mirror`: channel will play content from the specified `Mirror Source Channel`'s playout
|
||||
- This allows the exact same content on different channels with different channel settings
|
||||
- `Playout Offset` can be used to offset the times of scheduled playout items from the mirror source channel
|
||||
- e.g. -2 hours will cause the mirror channel to play content 2 hours before the mirror source channel
|
||||
- Add support for `.aif`, `.aifc`, `.aiff` song files
|
||||
- Classic schedules: add playback order `Marathon`
|
||||
- This can be used with collections and smart collections
|
||||
- Items from the collection will be grouped by the `Marathon Group By` setting: `Artist`, `Album`, `Season` or `Show`
|
||||
- The order of groups can optionally be shuffled
|
||||
- The order of items in each group can optionally be shuffled (otherwise `Season, Episode` or `Chronological` as appropriate)
|
||||
- A batch size can be set to limit the number of items to schedule from each group at a time
|
||||
- Empty or zero batch size means play all items from each group before advancing
|
||||
- Any other value means play the specified number of items before advancing to the next group
|
||||
- Log API requests when `Request Logging Minimum Log Level` is set to `Debug`
|
||||
- Add `Count` setting to each playlist item
|
||||
- Previously, when `Play All` was unchecked, this was implicitly 1
|
||||
- Now, the playlist can play a specific number of items from the collection before moving to the next playlist item
|
||||
- Classic schedules: add `Shuffle Playlist Items` setting to shuffle the order of playlist items
|
||||
- Shuffling happens initially (on playout reset), and after all items from the *entire playlist* have been played
|
||||
- Add playout detail row coloring by @peterdey
|
||||
- Filler has unique row colors
|
||||
- Unscheduled gaps are now displayed and have a unique row color
|
||||
- Process entire graphics element YAML files using scriban
|
||||
- This allows things like different images based on `MediaItem_ContentRating` (movie) or `MediaItem_ShowContentRating` (episode)
|
||||
- Playlists: add playback order `Shuffle In Order` for collections and smart collections
|
||||
|
||||
### Fixed
|
||||
- Fix transcoding content with bt709/pc color metadata
|
||||
- Fix scripted schedule validation (file exists) when creating or editing playout
|
||||
- Fix adding single episode, movie, season, show to empty playlists
|
||||
- Fix startup with MySql as non-superuser
|
||||
- `local_infile=ON` is required when using MySQL (for bulk inserts when building playouts)
|
||||
- ETV will set this automatically when it has permission
|
||||
- When ETV does not have permission, startup will fail with logged instructions on how to configure MySql
|
||||
- Fix scaling anamorphic content in locales that don't use period as a decimal separator (e.g. `,`)
|
||||
- Block schedules: fix playout build crash when empty collection uses random playback order
|
||||
- Fix watermarks and graphics elements on primary content split by mid-roll filler
|
||||
- Fix watermarks and graphics elements when `Scaling Behavior` is `Crop`
|
||||
- Fix hardware acceleration health check message on mobile
|
||||
- Fix deco selection logic
|
||||
- Fix inefficient database migration that would cause database initialization to get stuck
|
||||
- Classic schedules: fix scheduling behavior when a flood item is before a flexible fixed start item
|
||||
- Sometimes the flood item wouldn't schedule anything
|
||||
- Fix troubleshooting certain text graphics elements by generating fake EPG data
|
||||
|
||||
### Changed
|
||||
- **BREAKING CHANGE**: change how `Scripted Schedule` system works
|
||||
- No longer uses embedded python (IronPython); instead uses HTTP API
|
||||
- OpenAPI Description has been added at `/openapi/scripted-schedule.json`
|
||||
- This allows scripted scheduling from *many* languages
|
||||
- The scripted schedule file must now be directly executable (though a wrapper can be used to load a venv)
|
||||
- The scripted schedule file will be passed the following arguments (in order):
|
||||
- The API host (e.g. `http://localhost:8409`)
|
||||
- The build id (a UUID string that is required on all API calls)
|
||||
- The playout build mode (e.g. `reset` or `continue`, normally only used for specific logic when resetting a playout)
|
||||
- Custom arguments can be included in the `Scripted Schedule` field in the playout editor
|
||||
- Custom arguments will be passed *after* required arguments
|
||||
- For example, a `Scripted Schedule` of `/home/jason/schedule.sh "party central" 23` will be executed like
|
||||
- `/home/jason/schedule.sh http://localhost:8409 00000000-0000...0000 reset "party central" 23`
|
||||
- This enables wrapper script re-use across multiple scripted schedules
|
||||
- API reference is available at `/docs`
|
||||
- Docker images contain pre-generated python api client and entrypoint script
|
||||
- Entrypoint is at `/app/scripted-schedules/entrypoint.py`
|
||||
- Scripts folder should be mounted to `/app/scripted-schedules/scripts`
|
||||
- Playouts should be created with scripted schedule `/app/scripted-schedules/entrypoint.py script-name` (no trailing `.py`)
|
||||
- Automatically ignore Specials/Season 0 when using `Season, Episode` playback order
|
||||
|
||||
## [25.5.0] - 2025-09-01
|
||||
### Added
|
||||
- Add *experimental* graphics engine
|
||||
- All watermarks will use new graphics engine
|
||||
- Add `Opacity Expression` watermark mode
|
||||
- This allows specifying an expression that returns an opacity between 0.0 and 1.0
|
||||
- The expression can use:
|
||||
- `content_seconds` - the total number of seconds the frame is into the content
|
||||
- `content_total_seconds` - the total number of seconds in the content
|
||||
- `channel_seconds` - the total number of seconds the frame is from when the channel started/activated
|
||||
- `time_of_day_seconds` - the total number of seconds the frame is since midnight
|
||||
- The expression can also use functions:
|
||||
- `LinearFadeDuration(time, start, fadeSeconds, peakSeconds)`
|
||||
- `LinearFadePoints(time, start, peakStart, peakEnd, end)`
|
||||
- Add `Z-Index` to watermark editor
|
||||
- The graphics engine will order by z-index when overlaying watermarks
|
||||
- Add *experimental* `Graphics Element` template system
|
||||
- Graphics elements are defined in YAML files inside ETV config folder / templates / graphics-elements subfolder
|
||||
- Add `text` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Displays multi-line text in a specified font, color, location, z-index
|
||||
- Supports constant opacity and opacity expression
|
||||
- Supports EPG and Media Item variable replacement
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- Add `image` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Displays an image, similar to a watermark
|
||||
- Supports constant opacity and opacity expression
|
||||
- Add `subtitle` graphics element type
|
||||
- Supported in playback troubleshooting and YAML playouts
|
||||
- Supports SRT and SSA/ASS subtitle formats
|
||||
- Supports EPG and Media Item variable replacement
|
||||
- EPG data is sourced from XMLTV for the current time
|
||||
- EPG data can also load a configurable number of subsequent (up next) entries
|
||||
- Media Item data is sourced from the currently playing media item
|
||||
- YAML playout: add `graphics_on` and `graphics_off` instructions to control graphics elements
|
||||
- `graphics_on` requires the name of a graphics element template, e.g. `text/cool_element.yml`
|
||||
- The `variables` property can be used to dynamically replace text from the template
|
||||
- `graphics_off` will turn off a specific element, or all elements if none are specified
|
||||
- Add `Seek Seconds` to playback troubleshooting to support capturing timing-related issues
|
||||
- Custom stream selector: add `content_condition` to allow channel and time-of-day based decisions
|
||||
- `content_condition` expression can use
|
||||
- `channel_number`
|
||||
- `channel_name`
|
||||
- `time_of_day_seconds` - the start time for the current item, represented in seconds since midnight
|
||||
- Add support for external chapter files next to video files
|
||||
- Currently supports Matroska Chapter XML format
|
||||
- Chapter files have .xml or .chapters extension
|
||||
- Add targeted (single-show) library scanning
|
||||
- Supports quick and deep scans
|
||||
- Can be triggered from the `Scan` button on show pages
|
||||
- Can be triggered by API call to `/api/libraries/{library-id}/scan-show`
|
||||
- Add XMLTV setting `XMLTV Block Behavior` to control how block schedules appear in the EPG
|
||||
- `Split Time Evenly` - default (existing) behavior; block time is split among all items that are visible in the EPG
|
||||
- `Use Actual Times` - actual times are used for all items that are visible in the EPG
|
||||
- This will introduce EPG gaps when filler is used, or when items are hidden from the EPG
|
||||
- Add *experimental* `Scripted Schedule` playout system
|
||||
- This system uses python scripts to support the highest degree of customization
|
||||
- The goal is to expose methods equivalent to all sequential schedule (YAML) instructions
|
||||
- YAML and Scripted schedules: add `offline_tail` and `stop_before_end` to `pad_to_next` instruction
|
||||
- Both parameters default to `true`
|
||||
|
||||
### Fix
|
||||
- Fix database operations that were slowing down playout builds
|
||||
- YAML playouts in particular should build significantly faster
|
||||
- Fix channel playout mode `On Demand` for Block and YAML schedules
|
||||
- Fix QSV transitions when remote streaming from a media server
|
||||
- Fix green output when padding with VAAPI accel and i965 driver
|
||||
- Fix watermark custom image validation
|
||||
- Fix playback when using any watermarks that were saved with invalid state (no image)
|
||||
- Fix overlapping block playout items caused by `Stop scheduling block items` value `After Duration End`
|
||||
- Existing overlapping items will not be removed, but no new overlapping items will be created
|
||||
- Until these existing items age out, there will be warnings logged after each playout build/extension
|
||||
- Fix playback of anamorphic content from Jellyfin
|
||||
- This fix requires a manual deep scan of any affected Jellyfin library
|
||||
- Fix bug where multiple Plex servers would mix their episodes
|
||||
- Fix incorrect media item counts after removing paths from local libraries
|
||||
- Fix song playback in playback troubleshooting
|
||||
- Fix seeking into extracted text subtitles
|
||||
- Fix error when changing default (lowest priority) alternate schedule
|
||||
- Fix remote library editing, tv shows, artists with MySql/MariaDB
|
||||
- Classic schedules: fix alternate schedule transitions (some edge cases would cause days to be skipped completely)
|
||||
- Classic schedules: always start new alternate schedules with the first schedule item
|
||||
- Classic Schedules: log offline gaps longer than 1 hour due to strict fixed start times
|
||||
- Fix `HLS Segmenter V2` streaming mode with AMF acceleration
|
||||
- Fix `HLS Segmenter V2` streaming mode with VideoToolbox acceleration
|
||||
- Fix startup process for database and search index initialization
|
||||
- Redirect all pages to home page when initializing to prevent errors
|
||||
- Clear stale sqlite migration lock on startup to prevent getting stuck on database initialization
|
||||
- Fix display of long season placeholder text (when season posters are unavailable)
|
||||
|
||||
### Changed
|
||||
- Rename some schedule and playout terms for clarity
|
||||
- Schedules are used to build playouts and are what actually differs
|
||||
- The playout is the end result, and is the same no matter what schedule kind is used
|
||||
- Supported schedule kinds:
|
||||
- `Classic Schedules`
|
||||
- `Block Schedules`
|
||||
- `Sequential Schedules` (formerly `YAML Schedules` or `YAML Playouts`)
|
||||
- `Scripted Schedules`
|
||||
- `JSON (dizqueTV) Schedules` (formerly `External JSON Playouts`)
|
||||
- Allow multiple watermarks in playback troubleshooting
|
||||
- Classic schedules: allow selecting multiple watermarks on schedule items
|
||||
- Block schedules: allow selecting multiple watermarks on decos
|
||||
- Block schedules: change available watermark modes on decos. For reference, the levels from highest to lowest with block schedules are `Global` > `Channel` > `Playout Default Deco` > `Template Deco`.
|
||||
- `Inherit` - Use watermarks configured at a higher level
|
||||
- `Disable` - Disable watermarks at this level and above
|
||||
- `Replace` - Replace all watermarks configured at a higher level with those on this deco
|
||||
- This was renamed from `Override`
|
||||
- `Merge` - Merge all watermarks configured at a higher level with those on this deco
|
||||
- YAML playout: `watermark` instruction changes:
|
||||
- When value is `true`, will add named watermark to list of active watermarks
|
||||
- When value is `false` and `name` is specified, will remove named watermark from list of active watermarks
|
||||
- When value is `false` and `name` is not specified, will clear all active watermarks
|
||||
- Use consistent UI sorting and validation, and fix renaming errors for
|
||||
- Block groups, blocks
|
||||
- Template groups, templates
|
||||
- Deco groups, decos
|
||||
- Deco template groups, deco templates
|
||||
|
||||
## [25.4.0] - 2025-08-05
|
||||
### Added
|
||||
- Add `Troubleshoot Playback` to overflow menu on all media cards
|
||||
- This should eliminate the need to lookup media ids for content
|
||||
- Add subtitle selection to playback troubleshooting. This is limited to:
|
||||
- Sidecar text subtitles (e.g. `srt` files)
|
||||
- Embedded image subtitles
|
||||
- Embedded text subtitles that have already been extracted by ETV
|
||||
- Add light mode and light/dark mode toggle to app bar
|
||||
- YAML playout: add `pre_roll` instruction to enable and disable a pre-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic pre-roll for all content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic pre-roll in the playout
|
||||
- YAML playout: add `post_roll` instruction to enable and disable a post-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic post-roll for all content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic post-roll in the playout
|
||||
- YAML playout: add `mid_roll` instruction to enable and disable a mid-roll sequence
|
||||
- With value of `true` and `sequence` property, will enable automatic mid-roll for (`count` and `all`) content in the playout to the sequence with the provided key
|
||||
- With value of `false`, will disable automatic post-roll in the playout
|
||||
- `expression` can be used to influence which chapters are selected for mid roll (same as in filler preset)
|
||||
- YAML playout: add `rewind` instruction to set start of playout relative to the current time
|
||||
- Value should be formatted as `HH:MM:SS` e.g. `00:05:30` for 5 minutes 30 seconds (before now)
|
||||
- This is instruction is mostly useful for debugging transitions, and can only be used as a reset instruction
|
||||
- YAML playout: add `import` section to allow importing partial YAML definitions that include `content` and `sequence` entries
|
||||
- Add YAML playout validation (using JSON Schema)
|
||||
- Invalid YAML playout definitions will fail to build and will log validation failures as warnings
|
||||
- `content` is fully validated
|
||||
- `sequence` is fully validated
|
||||
- `reset` is fully validated
|
||||
- `playout` is fully validated
|
||||
- Add `Playlist` collection type to filler presets
|
||||
- This will force filler mode `Count`
|
||||
- Whenever the filler is used, it will schedule `Count` times full time through the playlist
|
||||
- If the playlist has 3 items and none set to play all, it will schedule 3 items when `Count = 1`
|
||||
- If the playlist has 3 items and none set to play all, it will schedule 6 items when `Count = 2`
|
||||
- Using the same playlist in the same schedule for anything other than filler may cause undesired behavior
|
||||
- Detect supported VideoToolbox hardware decoders and encoders
|
||||
- Software decoders/encoders will automatically be used when hardware versions are unavailable
|
||||
- Add VideoToolbox Capabilities to Troubleshooting page
|
||||
- Add `Use Chapters As Media Items` option to filler preset
|
||||
- This option allows scheduling individual chapters as filler
|
||||
- The chapters are shuffled or otherwise sorted together just like normal filler would be
|
||||
- Add smart collection edit page to allow renaming smart collections
|
||||
- Previous edit link behavior (performing search using smart collection query) now uses magnifying glass icon
|
||||
- Add channel `Transcode Mode` setting
|
||||
- This setting is currently disabled and only has the value `On Demand`
|
||||
- Add channel `Idle Behavior` setting to control the transcoding behavior after all clients have disconnected
|
||||
- `Stop On Disconnect` - stops the transcoder after all clients have disconnected + the global idle timeout
|
||||
- `Keep Running` - transcoder will run until manually stopped
|
||||
- Add support for music video thumbnails that end in `-thumb`
|
||||
- For example `Music Video.mkv` could have a corresponding thumbnail `Music Video-thumb.jpg`
|
||||
- Reorganize troubleshooting page
|
||||
- Add `YAML Validation` tool in `Troubleshooting` > `Tools`
|
||||
|
||||
### Fixed
|
||||
- Fix app startup with MySql/MariaDB
|
||||
- YAML playout: fix `pad_to_next` always running over time
|
||||
- Fix playback with text subtitles when seeking into content, i.e. when first joining a channel
|
||||
- Fix playback with `.ass` and `.ssa` text subtitles
|
||||
- Fix green padding with 10-bit source content and i965 VAAPI driver
|
||||
- Fix building playouts with empty schedules
|
||||
- Fix schedule start time calculation when daily playout build goes beyond midnight and into a different alternate schedule
|
||||
- Fix compatibility with older NVIDIA devices (compute capability 3.0+) in unified docker image
|
||||
- Fix transitions when using NVIDIA, QSV and VAAPI acceleration
|
||||
- Fix playback of remote streams on channels where framerate normalization is enabled
|
||||
|
||||
### Changed
|
||||
- Always tell ffmpeg to stop encoding with a specific duration
|
||||
- This was removed to try to improve transitions with ffmpeg 7.x, but has been causing issues with other content
|
||||
- Move search debug logging to its own log category; add `Searching Minimum Log Level` to `Settings` > `Logging`
|
||||
- Classic schedules: always schedule the full `Duration` amount instead of stopping mid-duration
|
||||
- This allows duration items to be scheduled beyond midnight
|
||||
- e.g. fixed start time 22:00 with 4 hour duration will schedule until 02:00 instead of stopping at midnight
|
||||
- Rename channel setting `Progress Mode` to `Playout Mode`
|
||||
- This controls the progression of the channel's playout, and has nothing to do with transcoding
|
||||
- `Always` is now called `Continuous` (playout progresses with wall clock)
|
||||
- `On Demand` is unchanged (playout only progresses while a client is watching the channel)
|
||||
- Replace channel `Active Mode` setting with new `Is Enabled` and `Show In EPG` settings
|
||||
- `Active` channels will be converted to `Is Enabled` = true and `Show In EPG` = true
|
||||
- `Hidden` channels will be converted to `Is Enabled` = true and `Show In EPG` = false
|
||||
- `Inactive` channels will be converted to `Is Enabled` = false and `Show In EPG` = false
|
||||
|
||||
## [25.3.1] - 2025-07-24
|
||||
### Fixed
|
||||
- Fix fallback filler playback
|
||||
|
||||
## [25.3.0] - 2025-07-24
|
||||
### Added
|
||||
- Add new channel stream (audio and subtitle) selector system
|
||||
- Channel editor has a new field `Stream Selector Mode`
|
||||
@@ -843,83 +62,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- YAML playout: add `disable_watermarks` property to all content instructions
|
||||
- This property defaults to `false` (meaning watermarks are allowed by default)
|
||||
- Setting to `true` will prevent watermarks from ever appearing over the content
|
||||
- YAML playout: add `watermark` instruction
|
||||
- With value of `true` and `name` property, will override the watermark in the playout to the watermark with the provided name
|
||||
- With value of `false`, will restore default watermark value (channel watermark, global watermark)
|
||||
- Show health check warning and error badges in nav menu
|
||||
- Add `Expression` for mid-roll filler to allow custom logic for using or skipping chapter markers
|
||||
- The following parameters can be used:
|
||||
- `total_points`: total number of potential mid-roll points
|
||||
- `matched_points`: number of mid-roll points that have already matched the expression
|
||||
- `total_duration`: total duration of the content, in seconds
|
||||
- `total_progress`: normalized position from 0 to 1
|
||||
- `last_mid_filler`: seconds since last mid-roll filler
|
||||
- `remaining_duration`: duration of the content after this mid-roll point, in seconds
|
||||
- `point`: the position of the mid-roll point, in seconds
|
||||
- `num`: the mid-roll point number, starting with 1
|
||||
- Add `Disable Watermarks` checkbox to block items
|
||||
- Block items that have this checked will never display a watermark, even with Deco set to override watermark
|
||||
- Add `ETV_MAXIMUM_UPLOAD_MB` environment variable to allow uploading large watermarks
|
||||
- Default value is 10
|
||||
- Update ffmpeg health check to link to ErsatzTV-FFmpeg release that contains binaries for win64, linux64, linuxarm64
|
||||
- Add `Playback Troubleshooting` page
|
||||
- This tool lets you play specific content without needing a test channel or schedule
|
||||
- You can specify
|
||||
- The media item id (found in ETV media info, and ETV movie URLs)
|
||||
- The ffmpeg profile to use
|
||||
- The watermark to use (if any)
|
||||
- Clicking `Play` will play up to 30 seconds of the specified content using the desired settings
|
||||
- Clicking `Download Results` will generate a zip archive containing:
|
||||
- The FFmpeg report of the playback attempt
|
||||
- The media info for the content
|
||||
- The `Troubleshooting` > `General` output
|
||||
- Support `(Part [english number])` name suffixes for multi-part episode grouping, for example:
|
||||
- `Awesome Episode (Part One)`
|
||||
- `Better Episode (Part Two)`
|
||||
- `Not So Great (Part Three)`
|
||||
- Add Trakt List option `Auto Refresh` to automatically update list from trakt.tv once each day
|
||||
- Add Trakt List option `Generate Playlist` to automatically generate ETV Playlist from matched Trakt List items
|
||||
- Read `country` field from movie NFO files and include in search index as `country`
|
||||
- Add *experimental* and *incomplete* `Remote Stream` library kind
|
||||
- Remote Stream libraries have fallback metadata added like Other Video libraries (every folder is a tag)
|
||||
- Remote Stream library items consist of YAML (`.yml`) files with the following fields
|
||||
- `url`: the URL of the content that can be played directly by ffmpeg
|
||||
- `script`: the process name and arguments for a command that will output content to stdout
|
||||
- `is_live`: *required* property that indicates whether the remote stream contains live content
|
||||
- When this is set to `true`, ETV cannot work ahead on transcoding this item, which is a necessary tradeoff for supporting live content
|
||||
- When this is set to `false`, ETV will treat the stream as VOD and attempt to work ahead on transcoding like any other local item
|
||||
- This *will* cause errors when the content is actually live, so it's important to configure this correctly
|
||||
- `duration`: when the content is live and does not have duration metadata, this must be provided to allow scheduling
|
||||
- The remote stream definition (YAML file) may provide either a `url` or a `script`
|
||||
- If both are provided, `url` will be used
|
||||
- Include number of chapters in search index as `chapters`
|
||||
|
||||
### Changed
|
||||
- Allow `Other Video` libraries and `Image` libraries to use the same folders
|
||||
- Try to mitigate inotify limit error by disabling automatic reloading of `appsettings.json` config files
|
||||
- Support `movie`, `musicvideo` and `episodedetails` top-level tags in other video NFO files
|
||||
- Note that no change has been made to the metadata tags that are actually parsed, but this should help with various types of content
|
||||
- Remove some limits on multithreading that are no longer needed with latest ffmpeg
|
||||
- Mixed transcoding (software decode, hardware filters/encode) can now use multiple decode threads
|
||||
- Split main `Settings` page into multiple pages
|
||||
- Update UI layout on all pages to be less cramped and to work better on mobile
|
||||
- Add CPU and Video Controller info to `Troubleshooting` > `General` output
|
||||
- Enable write-ahead logging (WAL) mode on SQLite databases
|
||||
- Add `Multiple Mode` option to schedule items editor and remove support for count values of zero
|
||||
- `Count`: same behavior as before, requires a number of media items to play and will always schedule the same number
|
||||
- `Collection Size`: similar to count of zero before, will play all media items from the collection before continuing to the next schedule item
|
||||
- `Playlist Item Size`: will play all media items from the current playlist item before continuing to the next schedule item
|
||||
- `Multi-Episode Group Size`: will play all media items from the current multi-part episode group, or one ungrouped media item
|
||||
- Change watermark width and margins to allow decimals
|
||||
- Move `Add To Collection` button to overflow menu on all media cards, and add `Show Media Info` to overflow menu
|
||||
- This allows showing media info for all media kinds
|
||||
- Unify on a multi-platform base docker tag (`latest` and `develop`)
|
||||
- `amd64`, `arm64`, `arm/v7` platforms are now all supported in the base docker tag
|
||||
- Other docker platform tags are deprecated and will receive no new updates after the next release
|
||||
- A health check has been added to notify users (on `-arm` or `-arm64` tags) of this change
|
||||
|
||||
### Fixed
|
||||
- Fix QSV acceleration in docker with older Intel devices
|
||||
@@ -936,20 +84,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Classify HDHR endpoints as streaming endpoints
|
||||
- This allows these endpoints to be accessed through port `ETV_STREAMING_PORT` (default `8409`)
|
||||
- This only matters if you configured `ETV_UI_PORT` to be a different value, which makes UI endpoints inaccessible on the streaming port
|
||||
- Update Plex movie/other video plot ("summary") during library deep scan
|
||||
- Fix compatibility with ffmpeg 7.2+ when using NVIDIA accel and 10-bit source content
|
||||
- Fix some NVIDIA edge cases when media servers don't provide video bit depth information
|
||||
- Fix VAAPI tonemap failure
|
||||
- Fix green bars after VAAPI tonemap
|
||||
- Fix bug where playout mode `Multiple` would ignore fixed start time
|
||||
- Fix block playout EPG generation to use `XMLTV Time Zone` setting
|
||||
- Fix adding "official" Trakt lists
|
||||
- Fix searching for `collection` names with spaces or other special characters, e.g. `collection:"Movies - Action"`
|
||||
- Fix QSV transcoding errors when scaling
|
||||
- Fix QSV frame freezing in browser
|
||||
- Fix some stream continuity issues, and some cases where audio sync is lost at transition
|
||||
- Fix HDR transcoding with AMD VAAPI accel
|
||||
- Allow paths longer than 255 characters in MySql databases
|
||||
|
||||
## [25.2.0] - 2025-06-24
|
||||
### Added
|
||||
@@ -2644,7 +1778,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Allow `Shuffle In Order` with Collections and Smart Collections
|
||||
- Episodes will be grouped by show, and music videos will be grouped by artist
|
||||
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
|
||||
- All groups will be ordered chronologically (custom ordering is only supported in multi-collections)
|
||||
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
|
||||
|
||||
### Fixed
|
||||
- Generate XMLTV that validates successfully
|
||||
@@ -3199,20 +2333,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.3.0...HEAD
|
||||
[26.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...v26.3.0
|
||||
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
|
||||
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
|
||||
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
|
||||
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
|
||||
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
|
||||
[25.7.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.0...v25.7.1
|
||||
[25.7.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.6.0...v25.7.0
|
||||
[25.6.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.5.0...v25.6.0
|
||||
[25.5.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.4.0...v25.5.0
|
||||
[25.4.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.1...v25.4.0
|
||||
[25.3.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.3.0...v25.3.1
|
||||
[25.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.2.0...v25.3.0
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.2.0...HEAD
|
||||
[25.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.1.0...v25.2.0
|
||||
[25.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.8-beta...v25.1.0
|
||||
[0.8.8-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.7-beta...v0.8.8-beta
|
||||
|
||||
@@ -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).
|
||||
@@ -2,16 +2,5 @@
|
||||
<PropertyGroup>
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<!-- 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. -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,41 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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 project. Versions are
|
||||
central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored .mcp tool
|
||||
(which opts out of CPM) doesn't pull versionless references. They start at `suggestion`
|
||||
severity in .editorconfig so they don't fail the TreatWarningsAsErrors build; high-value
|
||||
rules are promoted to warning/error incrementally. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference Include="Roslynator.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="SonarAnalyzer.CSharp">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Meziantou.Analyzer">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<!-- StyleCop.Analyzers intentionally omitted: its latest stable (1.1.118) crashes
|
||||
(AD0001) on C# records and its rules overlap the existing .editorconfig/Roslynator.
|
||||
Revisit via the record-compatible 1.2.0-beta if StyleCop is specifically wanted. (#15) -->
|
||||
<PackageReference Include="AsyncFixer">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,109 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
|
||||
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.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.1" />
|
||||
<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>
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
|
||||
Generated
+1035
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "ersatztv_windows"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tray-item = { git = "https://github.com/olback/tray-item-rs" }
|
||||
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
|
||||
process_path = "0.1.4"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.43.0"
|
||||
features = [
|
||||
"Win32_System_Console",
|
||||
"Win32_Foundation"
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
windres = "*"
|
||||
static_vcruntime = "2.0"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,6 @@
|
||||
use windres::Build;
|
||||
|
||||
fn main() {
|
||||
static_vcruntime::metabuild();
|
||||
Build::new().compile("ersatztv_windows.rc").unwrap();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
id ICON "ersatztv.ico"
|
||||
ersatztv-icon ICON "ersatztv.ico"
|
||||
@@ -0,0 +1,115 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use special_folder::SpecialFolder;
|
||||
use std::env;
|
||||
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 ui_port = env::var("ETV_UI_PORT")
|
||||
.ok()
|
||||
.and_then(|val| val.parse::<u16>().ok())
|
||||
.unwrap_or(8409);
|
||||
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg(format!("http://localhost:{}", ui_port))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.add_menu_item("Show Logs", || {
|
||||
let path = SpecialFolder::LocalApplicationData
|
||||
.get()
|
||||
.unwrap()
|
||||
.join("ersatztv")
|
||||
.join("logs");
|
||||
match path.to_str() {
|
||||
None => {}
|
||||
Some(folder) => {
|
||||
fs::create_dir_all(folder).unwrap();
|
||||
let _ = Command::new("explorer.exe")
|
||||
.arg(folder)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.inner_mut().add_separator().unwrap();
|
||||
|
||||
tray.add_menu_item("Exit", move || {
|
||||
tx.send(Message::Exit).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = process_path::get_executable_path();
|
||||
let mut child: Option<Child> = None;
|
||||
match path {
|
||||
None => {}
|
||||
Some(path) => {
|
||||
let etv = path.parent().unwrap().join("ErsatzTV.exe");
|
||||
if etv.exists() {
|
||||
match etv.to_str() {
|
||||
None => {}
|
||||
Some(etv) => {
|
||||
child = Some(
|
||||
Command::new(etv)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Message::Exit) => {
|
||||
match child {
|
||||
None => {}
|
||||
Some(mut child) => {
|
||||
unsafe {
|
||||
if Console::AttachConsole(child.id()) == true
|
||||
{
|
||||
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
|
||||
}
|
||||
}
|
||||
child.wait().unwrap();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule ErsatzTV-macOS updated: 8dbe1e22f2...d4dd985fd6
@@ -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,5 +1,5 @@
|
||||
using System.Net;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
@@ -11,14 +11,7 @@ public record ArtworkContentTypeModel(string Path, string ContentType)
|
||||
|
||||
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 };
|
||||
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
|
||||
? Path
|
||||
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
|
||||
}
|
||||
|
||||
@@ -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,8 +6,7 @@ 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;
|
||||
|
||||
@@ -15,16 +14,16 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
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,71 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
|
||||
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
||||
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
|
||||
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
|
||||
/// </summary>
|
||||
public static class ChannelGuideMetadata
|
||||
{
|
||||
public static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return playoutItem.CustomTitle;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown remote stream]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetSubtitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The primary guide category, mirroring the fixed <c><category></c> the XMLTV templates
|
||||
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
|
||||
/// </summary>
|
||||
public static string GetCategory(PlayoutItem playoutItem) =>
|
||||
playoutItem.MediaItem switch
|
||||
{
|
||||
Movie => "Movie",
|
||||
Episode => "Series",
|
||||
MusicVideo => "Music",
|
||||
Song => "Music",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
|
||||
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
|
||||
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
|
||||
/// </summary>
|
||||
public readonly record struct ChannelGuideEntry(
|
||||
PlayoutItem DisplayItem,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Stop,
|
||||
bool HasCustomTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
|
||||
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
|
||||
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
||||
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
|
||||
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
|
||||
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
|
||||
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
|
||||
/// </summary>
|
||||
public static class ChannelGuideProjector
|
||||
{
|
||||
public static IEnumerable<ChannelGuideEntry> Project(
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone,
|
||||
XmltvBlockBehavior blockBehavior) =>
|
||||
scheduleKind switch
|
||||
{
|
||||
PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior),
|
||||
_ => ProjectFlood(sorted, timeZone)
|
||||
};
|
||||
|
||||
// Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce
|
||||
// each guide group (following filler) into a single programme using the display item's GuideFinish
|
||||
// override when present.
|
||||
private static IEnumerable<ChannelGuideEntry> ProjectFlood(
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone)
|
||||
{
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.PostRoll or FillerKind.Tail
|
||||
or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
DateTimeOffset startTime = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
|
||||
_ => startItem.StartOffset
|
||||
};
|
||||
|
||||
DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch
|
||||
{
|
||||
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
|
||||
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
|
||||
(_, true) => displayItem.GuideFinishOffset!.Value,
|
||||
(_, false) => finishItem.FinishOffset
|
||||
};
|
||||
|
||||
yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle);
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Block: group by guide window, drop filler entirely, then either use the items' actual times or
|
||||
// split the group window evenly across the non-filler items.
|
||||
private static IEnumerable<ChannelGuideEntry> ProjectBlock(
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone,
|
||||
XmltvBlockBehavior blockBehavior)
|
||||
{
|
||||
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (blockBehavior)
|
||||
{
|
||||
case XmltvBlockBehavior.UseActualTimes:
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
DateTimeOffset actualStart = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset actualFinish = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
yield return new ChannelGuideEntry(item, actualStart, actualFinish, false);
|
||||
}
|
||||
|
||||
break;
|
||||
case XmltvBlockBehavior.SplitTimeEvenly:
|
||||
default:
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
yield return new ChannelGuideEntry(item, currentStart, currentFinish, false);
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class ChannelSortViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Number { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string OriginalNumber { get; set; }
|
||||
public bool HasChanged => OriginalNumber != Number;
|
||||
}
|
||||
@@ -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,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System.Net;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
@@ -11,16 +11,12 @@ public record ChannelViewModel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
ChannelProgressMode ProgressMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -30,10 +26,7 @@ public record ChannelViewModel(
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
ChannelActiveMode ActiveMode)
|
||||
{
|
||||
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,64 +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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,64 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkMoveChannelsToGroupHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkMoveChannelsToGroup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
|
||||
if (groupValidation.IsFail)
|
||||
{
|
||||
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
|
||||
}
|
||||
|
||||
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."));
|
||||
}
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
channel.Group = request.Group;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules shared by <see cref="CreateChannelHandler" /> and
|
||||
/// <see cref="UpdateChannelHandler" />. These were previously enforced only by the Blazor page
|
||||
/// (<c>ChannelEditViewModelValidator</c>); porting them into the handlers makes them apply to
|
||||
/// the REST API as well. Failures map to HTTP 422.
|
||||
/// </summary>
|
||||
internal static class ChannelValidations
|
||||
{
|
||||
/// <summary>
|
||||
/// A channel must belong to a non-empty group; the value is used as the M3U
|
||||
/// <c>group-title</c>. Mirrors the Blazor rule <c>RuleFor(x => x.Group).NotEmpty()</c>.
|
||||
/// </summary>
|
||||
internal static Validation<BaseError, string> ValidateGroup(string group)
|
||||
{
|
||||
// Use explicit returns (not a ternary): BaseError has an implicit string conversion, so a
|
||||
// ternary would collapse both branches to BaseError and always produce a Fail.
|
||||
if (string.IsNullOrWhiteSpace(group))
|
||||
{
|
||||
return BaseError.New("Channel group is required");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>A disabled channel may not be shown in the EPG.</summary>
|
||||
internal static Validation<BaseError, bool> ValidateShowInEpg(bool isEnabled, bool showInEpg)
|
||||
{
|
||||
if (!isEnabled && showInEpg)
|
||||
{
|
||||
return BaseError.New("Disabled channels cannot be shown in EPG");
|
||||
}
|
||||
|
||||
return showInEpg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A logo path that is an absolute URI must be a valid external (http/https) url.
|
||||
/// Relative/local logo paths and empty values are allowed.
|
||||
/// </summary>
|
||||
internal static Validation<BaseError, string> ValidateLogo(string logoPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(logoPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
bool isAbsoluteUri = Uri.TryCreate(logoPath, UriKind.Absolute, out _);
|
||||
if (isAbsoluteUri && !Artwork.IsExternalUrl(logoPath))
|
||||
{
|
||||
return BaseError.New("External logo url is invalid");
|
||||
}
|
||||
|
||||
return logoPath;
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,12 @@ public record CreateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
ChannelProgressMode ProgressMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -28,7 +24,4 @@ public record CreateChannel(
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record CreateChannelFromLineup(
|
||||
string Name,
|
||||
string Number,
|
||||
string Group,
|
||||
string Categories,
|
||||
ArtworkContentTypeModel Logo,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg,
|
||||
int TemplateId,
|
||||
CreateChannelFromLineupAdvancedOptions Advanced,
|
||||
List<CreateChannelFromLineupItem> Lineup) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>>;
|
||||
|
||||
public record CreateChannelFromLineupAdvancedOptions(
|
||||
PlaybackOrder? PlaybackOrder = null,
|
||||
int? FFmpegProfileId = null,
|
||||
int? WatermarkId = null,
|
||||
int? FallbackFillerId = null,
|
||||
int? PreRollFillerId = null,
|
||||
int? MidRollFillerId = null,
|
||||
int? PostRollFillerId = null,
|
||||
ChannelStreamSelectorMode? StreamSelectorMode = null,
|
||||
string StreamSelector = null,
|
||||
string PreferredAudioLanguageCode = null,
|
||||
string PreferredAudioTitle = null,
|
||||
ChannelPlayoutSource? PlayoutSource = null,
|
||||
ChannelPlayoutMode? PlayoutMode = null,
|
||||
StreamingMode? StreamingMode = null,
|
||||
string PreferredSubtitleLanguageCode = null,
|
||||
ChannelSubtitleMode? SubtitleMode = null,
|
||||
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
|
||||
string MusicVideoCreditsTemplate = null,
|
||||
ChannelSongVideoMode? SongVideoMode = null,
|
||||
ChannelTranscodeMode? TranscodeMode = null,
|
||||
ChannelIdleBehavior? IdleBehavior = null,
|
||||
bool? ShuffleScheduleItems = null,
|
||||
bool? RandomStartPoint = null,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
|
||||
|
||||
public record CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType MediaType,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? RerunCollectionId,
|
||||
int? MediaItemId,
|
||||
int? PlaylistId);
|
||||
@@ -1,757 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class CreateChannelFromLineupHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
ILogger<CreateChannelFromLineupHandler> logger)
|
||||
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
|
||||
{
|
||||
// The single system playlist group that holds every generated channel-lineup playlist.
|
||||
// Matches the Trakt "Trakt Lists" precedent (DbInitializer + delete guards on IsSystem).
|
||||
private const string SystemPlaylistGroupName = "Channel Lineups";
|
||||
|
||||
public async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> Handle(
|
||||
CreateChannelFromLineup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Match(
|
||||
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
|
||||
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
|
||||
TvContext dbContext,
|
||||
PreparedCreate prepared,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
dbContext.Channels.Add(prepared.Channel);
|
||||
if (prepared.Playlist is not null)
|
||||
{
|
||||
dbContext.Playlists.Add(prepared.Playlist);
|
||||
}
|
||||
|
||||
dbContext.ProgramSchedules.Add(prepared.ProgramSchedule);
|
||||
dbContext.Playouts.Add(prepared.Playout);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
logger.LogError(ex, "Failed to persist channel created from lineup");
|
||||
return BaseError.New("Unable to create channel from lineup");
|
||||
}
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
// 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 BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
|
||||
CancellationToken.None);
|
||||
|
||||
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
|
||||
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
await workerChannel.WriteAsync(
|
||||
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
|
||||
return new CreateChannelFromLineupResponseModel(
|
||||
prepared.Channel.Id,
|
||||
prepared.Playlist?.Id,
|
||||
prepared.ProgramSchedule.Id,
|
||||
prepared.Playout.Id);
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, PreparedCreate>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateChannelFromLineup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string name = (request.Name ?? string.Empty).Trim();
|
||||
string number = (request.Number ?? string.Empty).Trim();
|
||||
string group = (request.Group ?? string.Empty).Trim();
|
||||
string categories = (request.Categories ?? string.Empty).Trim();
|
||||
CreateChannelFromLineupAdvancedOptions advanced = request.Advanced ?? new CreateChannelFromLineupAdvancedOptions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return BaseError.New("Channel name is required");
|
||||
}
|
||||
|
||||
if (name.Length > 50)
|
||||
{
|
||||
return BaseError.New("Channel name must be 50 characters or fewer");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(group))
|
||||
{
|
||||
return BaseError.New("Channel group is required");
|
||||
}
|
||||
|
||||
if (!Regex.IsMatch(number, Channel.NumberValidator))
|
||||
{
|
||||
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
|
||||
}
|
||||
|
||||
if (await dbContext.Channels.AnyAsync(c => c.Number == number, cancellationToken))
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
if (!request.IsEnabled && request.ShowInEpg)
|
||||
{
|
||||
return BaseError.New("Disabled channels cannot be shown in EPG");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Logo?.Path) &&
|
||||
Uri.TryCreate(request.Logo.Path, UriKind.Absolute, out _) &&
|
||||
!Artwork.IsExternalUrl(request.Logo.Path))
|
||||
{
|
||||
return BaseError.New("External logo url is invalid");
|
||||
}
|
||||
|
||||
if (request.Lineup is null || request.Lineup.Count == 0)
|
||||
{
|
||||
return BaseError.New("Lineup must contain at least one item");
|
||||
}
|
||||
|
||||
ChannelTemplate template = await dbContext.ChannelTemplates
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(t => t.Id == request.TemplateId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
|
||||
}
|
||||
|
||||
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
|
||||
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
|
||||
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
|
||||
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
|
||||
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
|
||||
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
|
||||
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
|
||||
|
||||
// Mirror channels need special MirrorSourceChannelId plumbing (see CreateChannelHandler);
|
||||
// this endpoint only builds generated playouts.
|
||||
if (playoutSource is ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
return BaseError.New("Mirror playout source is not supported by this endpoint");
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
|
||||
dbContext,
|
||||
advanced,
|
||||
template,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in referenceValidation.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
// Normalize + validate every lineup entry once, so validation and build see the same data.
|
||||
var normalized = new List<NormalizedLineupItem>();
|
||||
for (int i = 0; i < request.Lineup.Count; i++)
|
||||
{
|
||||
Either<BaseError, NormalizedLineupItem> itemValidation =
|
||||
await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken);
|
||||
foreach (BaseError error in itemValidation.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (NormalizedLineupItem item in itemValidation.RightToSeq())
|
||||
{
|
||||
normalized.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
bool multiItem = normalized.Count >= 2;
|
||||
|
||||
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
|
||||
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
|
||||
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
|
||||
{
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
|
||||
}
|
||||
|
||||
if (multiItem)
|
||||
{
|
||||
// The generated playlist cannot express rerun collections or nested playlists
|
||||
// (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields).
|
||||
for (int i = 0; i < normalized.Count; i++)
|
||||
{
|
||||
if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"lineup[{normalized[i].Index}]: rerun collections and playlists are only " +
|
||||
"supported as a single-item lineup");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Channel channel = BuildChannel(
|
||||
request,
|
||||
template,
|
||||
advanced,
|
||||
name,
|
||||
number,
|
||||
group,
|
||||
categories,
|
||||
ffmpegProfileId,
|
||||
fallbackFillerId);
|
||||
|
||||
string scheduleName = await DeCollideName(
|
||||
GeneratedName(number, name, "Schedule"),
|
||||
(candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct),
|
||||
cancellationToken);
|
||||
|
||||
ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced);
|
||||
|
||||
Playlist playlist = null;
|
||||
ProgramScheduleItemFlood floodItem = BuildFloodBase(
|
||||
playbackOrder,
|
||||
advanced,
|
||||
template,
|
||||
fallbackFillerId,
|
||||
preRollFillerId,
|
||||
midRollFillerId,
|
||||
postRollFillerId);
|
||||
|
||||
if (multiItem)
|
||||
{
|
||||
playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken);
|
||||
floodItem.CollectionType = CollectionType.Playlist;
|
||||
floodItem.Playlist = playlist;
|
||||
}
|
||||
else
|
||||
{
|
||||
NormalizedLineupItem only = normalized[0];
|
||||
floodItem.CollectionType = only.CollectionType;
|
||||
floodItem.CollectionId = only.CollectionId;
|
||||
floodItem.MultiCollectionId = only.MultiCollectionId;
|
||||
floodItem.SmartCollectionId = only.SmartCollectionId;
|
||||
floodItem.RerunCollectionId = only.RerunCollectionId;
|
||||
floodItem.MediaItemId = only.MediaItemId;
|
||||
floodItem.PlaylistId = only.PlaylistId;
|
||||
}
|
||||
|
||||
schedule.Items = [floodItem];
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ProgramSchedule = schedule,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
};
|
||||
|
||||
return new PreparedCreate(channel, playlist, schedule, playout);
|
||||
}
|
||||
|
||||
private static async Task<Playlist> BuildPlaylist(
|
||||
TvContext dbContext,
|
||||
string channelNumber,
|
||||
string channelName,
|
||||
List<NormalizedLineupItem> lineup,
|
||||
PlaybackOrder playbackOrder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Reuse the single system playlist group, creating it on first use.
|
||||
PlaylistGroup playlistGroup = await dbContext.PlaylistGroups
|
||||
.FirstOrDefaultAsync(pg => pg.IsSystem && pg.Name == SystemPlaylistGroupName, cancellationToken);
|
||||
playlistGroup ??= new PlaylistGroup { Name = SystemPlaylistGroupName, IsSystem = true };
|
||||
|
||||
string playlistName = await DeCollideName(
|
||||
GeneratedName(channelNumber, channelName, "Lineup"),
|
||||
(candidate, ct) => dbContext.Playlists.AnyAsync(
|
||||
p => p.PlaylistGroupId == playlistGroup.Id && p.Name == candidate,
|
||||
ct),
|
||||
cancellationToken);
|
||||
|
||||
var playlist = new Playlist
|
||||
{
|
||||
Name = playlistName,
|
||||
IsSystem = true,
|
||||
PlaylistGroup = playlistGroup,
|
||||
PlaylistGroupId = playlistGroup.Id,
|
||||
Items = []
|
||||
};
|
||||
|
||||
int index = 1;
|
||||
foreach (NormalizedLineupItem item in lineup)
|
||||
{
|
||||
playlist.Items.Add(new PlaylistItem
|
||||
{
|
||||
Playlist = playlist,
|
||||
Index = index++,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
SmartCollectionId = item.SmartCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaybackOrder = playbackOrder,
|
||||
|
||||
// Play every item in each entry before advancing so lineup order is honored
|
||||
// (PlaylistEnumerator round-robins one-per-entry unless PlayAll/Count).
|
||||
PlayAll = true,
|
||||
IncludeInProgramGuide = true
|
||||
});
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
private static Channel BuildChannel(
|
||||
CreateChannelFromLineup request,
|
||||
ChannelTemplate template,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
string name,
|
||||
string number,
|
||||
string group,
|
||||
string categories,
|
||||
int ffmpegProfileId,
|
||||
int? fallbackFillerId)
|
||||
{
|
||||
var artwork = new List<Artwork>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
|
||||
{
|
||||
string logo = request.Logo.Path;
|
||||
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
|
||||
{
|
||||
logo = logo.Replace("iptv/logos/", string.Empty);
|
||||
}
|
||||
|
||||
artwork.Add(new Artwork
|
||||
{
|
||||
Path = logo,
|
||||
ArtworkKind = ArtworkKind.Logo,
|
||||
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return new Channel(Guid.NewGuid())
|
||||
{
|
||||
Name = name,
|
||||
Number = number,
|
||||
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
|
||||
Group = group,
|
||||
Categories = categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
SlugSeconds = null,
|
||||
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
|
||||
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
|
||||
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
|
||||
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
|
||||
FallbackFillerId = fallbackFillerId,
|
||||
Artwork = artwork,
|
||||
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
|
||||
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
|
||||
PreferredAudioLanguageCode =
|
||||
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
|
||||
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
|
||||
PreferredSubtitleLanguageCode =
|
||||
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
|
||||
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
|
||||
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate =
|
||||
advanced.MusicVideoCreditsTemplate ?? template.MusicVideoCreditsTemplate ?? string.Empty,
|
||||
SongVideoMode = advanced.SongVideoMode ?? template.SongVideoMode,
|
||||
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
|
||||
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
|
||||
IsEnabled = request.IsEnabled,
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg
|
||||
};
|
||||
}
|
||||
|
||||
private static ProgramSchedule BuildProgramSchedule(
|
||||
string scheduleName,
|
||||
ChannelTemplate template,
|
||||
CreateChannelFromLineupAdvancedOptions advanced) =>
|
||||
new()
|
||||
{
|
||||
Name = scheduleName,
|
||||
KeepMultiPartEpisodesTogether = true,
|
||||
TreatCollectionsAsShows = true,
|
||||
ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems,
|
||||
RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint,
|
||||
FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior,
|
||||
Items = []
|
||||
};
|
||||
|
||||
private static ProgramScheduleItemFlood BuildFloodBase(
|
||||
PlaybackOrder playbackOrder,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template,
|
||||
int? fallbackFillerId,
|
||||
int? preRollFillerId,
|
||||
int? midRollFillerId,
|
||||
int? postRollFillerId) =>
|
||||
new()
|
||||
{
|
||||
Index = 1,
|
||||
PlaybackOrder = playbackOrder,
|
||||
GuideMode = GuideMode.Normal,
|
||||
CustomTitle = string.Empty,
|
||||
SearchTitle = string.Empty,
|
||||
SearchQuery = string.Empty,
|
||||
PreRollFillerId = preRollFillerId,
|
||||
MidRollFillerId = midRollFillerId,
|
||||
PostRollFillerId = postRollFillerId,
|
||||
FallbackFillerId = fallbackFillerId,
|
||||
PreferredAudioLanguageCode =
|
||||
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
|
||||
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
|
||||
PreferredSubtitleLanguageCode =
|
||||
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
|
||||
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
|
||||
};
|
||||
|
||||
private static string GeneratedName(string channelNumber, string channelName, string suffix)
|
||||
{
|
||||
string prefix = $"{channelNumber} {channelName}".Trim();
|
||||
int maxPrefixLength = Math.Max(0, 50 - suffix.Length - 1);
|
||||
if (prefix.Length > maxPrefixLength)
|
||||
{
|
||||
prefix = prefix[..maxPrefixLength].TrimEnd();
|
||||
}
|
||||
|
||||
return $"{prefix} {suffix}".Trim();
|
||||
}
|
||||
|
||||
// De-collide a generated (already <= 50 char) name against a unique index by appending " 2", " 3", ...
|
||||
// rather than leaking a raw UNIQUE-constraint failure.
|
||||
private static async Task<string> DeCollideName(
|
||||
string baseName,
|
||||
Func<string, CancellationToken, Task<bool>> exists,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await exists(baseName, cancellationToken))
|
||||
{
|
||||
return baseName;
|
||||
}
|
||||
|
||||
for (int n = 2; ; n++)
|
||||
{
|
||||
string suffix = $" {n}";
|
||||
string candidate = baseName.Length + suffix.Length > 50
|
||||
? baseName[..(50 - suffix.Length)].TrimEnd() + suffix
|
||||
: baseName + suffix;
|
||||
|
||||
if (!await exists(candidate, cancellationToken))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, Unit>> ValidateReferences(
|
||||
TvContext dbContext,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
|
||||
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
|
||||
{
|
||||
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
|
||||
dbContext,
|
||||
advanced.WatermarkId ?? template.WatermarkId,
|
||||
advanced.FallbackFillerId ?? template.FallbackFillerId,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in channelReferences.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
|
||||
dbContext,
|
||||
advanced.PreRollFillerId ?? template.PreRollFillerId,
|
||||
advanced.MidRollFillerId ?? template.MidRollFillerId,
|
||||
advanced.PostRollFillerId ?? template.PostRollFillerId,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in itemFillers.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
|
||||
TvContext dbContext,
|
||||
int? watermarkId,
|
||||
int? fallbackFillerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (watermarkId.HasValue &&
|
||||
!await dbContext.ChannelWatermarks.AnyAsync(w => w.Id == watermarkId.Value, cancellationToken))
|
||||
{
|
||||
return new NotFoundError($"Watermark {watermarkId.Value} does not exist.");
|
||||
}
|
||||
|
||||
if (fallbackFillerId.HasValue && !await dbContext.FillerPresets.AnyAsync(
|
||||
fp => fp.Id == fallbackFillerId.Value && fp.FillerKind == FillerKind.Fallback,
|
||||
cancellationToken))
|
||||
{
|
||||
return new NotFoundError($"Fallback filler {fallbackFillerId.Value} does not exist.");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, Unit>> ValidateItemFillers(
|
||||
TvContext dbContext,
|
||||
int? preRollFillerId,
|
||||
int? midRollFillerId,
|
||||
int? postRollFillerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (preRollFillerId.HasValue && !await FillerExists(dbContext, preRollFillerId.Value, FillerKind.PreRoll, cancellationToken))
|
||||
{
|
||||
return new NotFoundError($"Pre-roll filler {preRollFillerId.Value} does not exist.");
|
||||
}
|
||||
|
||||
if (midRollFillerId.HasValue && !await FillerExists(dbContext, midRollFillerId.Value, FillerKind.MidRoll, cancellationToken))
|
||||
{
|
||||
return new NotFoundError($"Mid-roll filler {midRollFillerId.Value} does not exist.");
|
||||
}
|
||||
|
||||
if (postRollFillerId.HasValue && !await FillerExists(dbContext, postRollFillerId.Value, FillerKind.PostRoll, cancellationToken))
|
||||
{
|
||||
return new NotFoundError($"Post-roll filler {postRollFillerId.Value} does not exist.");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<bool> FillerExists(
|
||||
TvContext dbContext,
|
||||
int id,
|
||||
FillerKind fillerKind,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken);
|
||||
|
||||
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeLineupItem(
|
||||
TvContext dbContext,
|
||||
CreateChannelFromLineupItem item,
|
||||
int index,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int providedIds = new int?[]
|
||||
{
|
||||
item.CollectionId,
|
||||
item.MultiCollectionId,
|
||||
item.SmartCollectionId,
|
||||
item.RerunCollectionId,
|
||||
item.MediaItemId,
|
||||
item.PlaylistId
|
||||
}.Count(id => id.HasValue);
|
||||
|
||||
if (providedIds != 1)
|
||||
{
|
||||
return BaseError.New($"lineup[{index}] must provide exactly one typed id.");
|
||||
}
|
||||
|
||||
switch (item.MediaType)
|
||||
{
|
||||
case LibraryBrowseMediaType.Movie:
|
||||
return await NormalizeMediaItem(
|
||||
dbContext.Movies, item, CollectionType.Movie, index, "Movie", cancellationToken);
|
||||
case LibraryBrowseMediaType.TelevisionShow:
|
||||
return await NormalizeMediaItem(
|
||||
dbContext.Shows, item, CollectionType.TelevisionShow, index, "TelevisionShow", cancellationToken);
|
||||
case LibraryBrowseMediaType.TelevisionSeason:
|
||||
return await NormalizeMediaItem(
|
||||
dbContext.Seasons, item, CollectionType.TelevisionSeason, index, "TelevisionSeason", cancellationToken);
|
||||
case LibraryBrowseMediaType.Artist:
|
||||
return await NormalizeMediaItem(
|
||||
dbContext.Artists, item, CollectionType.Artist, index, "Artist", cancellationToken);
|
||||
case LibraryBrowseMediaType.Collection:
|
||||
if (item.CollectionType != CollectionType.Collection)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.CollectionId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "collectionId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.Collections,
|
||||
item.CollectionId.Value,
|
||||
$"lineup[{index}] Collection",
|
||||
new NormalizedLineupItem(index, CollectionType.Collection, CollectionId: item.CollectionId),
|
||||
cancellationToken);
|
||||
case LibraryBrowseMediaType.SmartCollection:
|
||||
if (item.CollectionType != CollectionType.SmartCollection)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.SmartCollectionId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "smartCollectionId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.SmartCollections,
|
||||
item.SmartCollectionId.Value,
|
||||
$"lineup[{index}] SmartCollection",
|
||||
new NormalizedLineupItem(index, CollectionType.SmartCollection, SmartCollectionId: item.SmartCollectionId),
|
||||
cancellationToken);
|
||||
case LibraryBrowseMediaType.MultiCollection:
|
||||
if (item.CollectionType != CollectionType.MultiCollection)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.MultiCollectionId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "multiCollectionId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.MultiCollections,
|
||||
item.MultiCollectionId.Value,
|
||||
$"lineup[{index}] MultiCollection",
|
||||
new NormalizedLineupItem(index, CollectionType.MultiCollection, MultiCollectionId: item.MultiCollectionId),
|
||||
cancellationToken);
|
||||
case LibraryBrowseMediaType.RerunCollection:
|
||||
if (item.CollectionType != CollectionType.RerunFirstRun)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.RerunCollectionId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "rerunCollectionId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.RerunCollections,
|
||||
item.RerunCollectionId.Value,
|
||||
$"lineup[{index}] RerunCollection",
|
||||
new NormalizedLineupItem(index, CollectionType.RerunFirstRun, RerunCollectionId: item.RerunCollectionId),
|
||||
cancellationToken);
|
||||
case LibraryBrowseMediaType.Playlist:
|
||||
if (item.CollectionType != CollectionType.Playlist)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.PlaylistId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "playlistId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
dbContext.Playlists,
|
||||
item.PlaylistId.Value,
|
||||
$"lineup[{index}] Playlist",
|
||||
new NormalizedLineupItem(index, CollectionType.Playlist, PlaylistId: item.PlaylistId),
|
||||
cancellationToken);
|
||||
default:
|
||||
return BaseError.New($"lineup[{index}] has an unsupported media type '{item.MediaType}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeMediaItem<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
CreateChannelFromLineupItem item,
|
||||
CollectionType expectedType,
|
||||
int index,
|
||||
string label,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (item.CollectionType != expectedType)
|
||||
{
|
||||
return Mismatch(index, item);
|
||||
}
|
||||
|
||||
if (!item.MediaItemId.HasValue)
|
||||
{
|
||||
return WrongId(index, item.MediaType, "mediaItemId");
|
||||
}
|
||||
|
||||
return await ExistsThen(
|
||||
set,
|
||||
item.MediaItemId.Value,
|
||||
$"lineup[{index}] {label}",
|
||||
new NormalizedLineupItem(index, expectedType, MediaItemId: item.MediaItemId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static BaseError Mismatch(int index, CreateChannelFromLineupItem item) =>
|
||||
BaseError.New(
|
||||
$"lineup[{index}]: media type '{item.MediaType}' does not match collection type '{item.CollectionType}'.");
|
||||
|
||||
private static BaseError WrongId(int index, LibraryBrowseMediaType mediaType, string expectedField) =>
|
||||
BaseError.New($"lineup[{index}]: media type '{mediaType}' requires a {expectedField}.");
|
||||
|
||||
private static async Task<Either<BaseError, NormalizedLineupItem>> ExistsThen<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
int id,
|
||||
string label,
|
||||
NormalizedLineupItem normalized,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
bool exists = await set.AsNoTracking()
|
||||
.AnyAsync(e => EF.Property<int>(e, "Id") == id, cancellationToken);
|
||||
return exists
|
||||
? normalized
|
||||
: new NotFoundError($"{label} {id} does not exist.");
|
||||
}
|
||||
|
||||
private sealed record NormalizedLineupItem(
|
||||
int Index,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId = null,
|
||||
int? MultiCollectionId = null,
|
||||
int? SmartCollectionId = null,
|
||||
int? RerunCollectionId = null,
|
||||
int? MediaItemId = null,
|
||||
int? PlaylistId = null);
|
||||
|
||||
private sealed record PreparedCreate(
|
||||
Channel Channel,
|
||||
Playlist Playlist,
|
||||
ProgramSchedule ProgramSchedule,
|
||||
Playout Playout);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user