Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d88b5167 | ||
|
|
4f68805d9a | ||
|
|
55fc210385 | ||
|
|
d1c04030af | ||
|
|
945d108334 |
@@ -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,58 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# design-sync-reminder — single hook, both directions (#388). Keeps the Claude Design project
|
||||
# (`ChicoryTV Design System`, eb3b6122 / local mirror `design-system/`) in step with the shipped
|
||||
# SPA. Trigger is PURELY MECHANICAL: "touching the UI" == a file matching UI_RE below. No prompt
|
||||
# keyword guessing. Wired to two boundaries:
|
||||
#
|
||||
# start (PreToolUse / Write|Edit) — the FIRST time this session edits a UI file, remind to PULL
|
||||
# the current design from Claude Design first.
|
||||
# finish (Stop) — if the working tree actually changed a UI file, remind to
|
||||
# MIRROR/PUSH the change back before wrapping up.
|
||||
#
|
||||
# UI_RE is the one place the "what counts as UI" fileset is defined: SPA .tsx/.css under web/src
|
||||
# (test files excluded). Widen it here if the design surface grows.
|
||||
#
|
||||
# Fail-open: any parse trouble / non-match → emit nothing, exit 0. Throttled once per session per
|
||||
# phase so it informs without nagging. DesignSync runs only from the main session (docs/design-sync.md).
|
||||
# This is a reminder, never a hard gate — `start` only injects context; `finish` is a one-shot Stop nudge.
|
||||
set -euo pipefail
|
||||
|
||||
UI_RE='(^|/)web/src/.*\.(tsx|css)$'
|
||||
TEST_RE='\.test\.(tsx|ts)$'
|
||||
|
||||
phase="${1:-}"
|
||||
input=$(cat)
|
||||
me=$(printf '%s' "$input" | jq -r '.session_id // "nosess"' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
[ -z "$cwd" ] && cwd="$PWD"
|
||||
marker="${TMPDIR:-/tmp}/ctv-designsync-${phase}-${me}"
|
||||
|
||||
case "$phase" in
|
||||
start)
|
||||
fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null || true)
|
||||
[ -z "$fp" ] && exit 0
|
||||
printf '%s' "$fp" | grep -qE "$TEST_RE" && exit 0 # skip test files
|
||||
printf '%s' "$fp" | grep -qE "$UI_RE" || exit 0 # not a UI file → nothing
|
||||
[ -f "$marker" ] && exit 0
|
||||
: > "$marker" 2>/dev/null || true
|
||||
read -r -d '' MSG <<'EOF' || true
|
||||
[design-sync #388] About to edit a ChicoryTV SPA UI file. The `design-system/` prototypes mirror the Claude Design project (eb3b6122). If you're changing how a screen LOOKS, first PULL its current prototype from Claude Design so you start from the live design (docs/design-sync.md, pull = DesignSync list_files/get_file → design-system/, incremental). You'll be reminded to MIRROR the change back when the task finishes. DesignSync runs only from the main session.
|
||||
EOF
|
||||
jq -n --arg m "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$m}}'
|
||||
exit 0
|
||||
;;
|
||||
finish)
|
||||
# Did this turn actually change a UI file? (tracked diff vs HEAD + untracked, minus tests)
|
||||
changed=$( { git -C "$cwd" diff --name-only HEAD 2>/dev/null; git -C "$cwd" ls-files --others --exclude-standard 2>/dev/null; } | grep -vE "$TEST_RE" | grep -E "$UI_RE" || true )
|
||||
[ -z "$changed" ] && exit 0
|
||||
[ -f "$marker" ] && exit 0
|
||||
: > "$marker" 2>/dev/null || true
|
||||
n=$(printf '%s\n' "$changed" | sed '/^$/d' | wc -l | tr -d ' ')
|
||||
reason="[design-sync #388] This task changed ${n} SPA UI file(s) under web/src. Before wrapping up, MIRROR the visual change into the matching design-system/templates/chicorytv-admin/*.jsx prototype and push it to Claude Design (eb3b6122) in this same session, per docs/design-sync.md — so the design system does not drift from prod. If you already synced, or are deliberately deferring the mirror (say why), just note it and stop. DesignSync runs only from the main session. This one-shot reminder won't fire again this session."
|
||||
jq -n --arg r "$reason" '{decision:"block",reason:$r}'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse / Bash — after a successful `git worktree add`, stamp the new worktree with
|
||||
# this session's id (.claude-worktree-owner) so pretooluse-worktree-guard.sh (H7) can tell
|
||||
# a sibling worktree another session created apart from this session's own.
|
||||
# Fail-safe: any parse trouble → do nothing (the guard stays fail-open without a marker).
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
me=$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)
|
||||
|
||||
printf '%s' "$cmd" | grep -qE 'git[[:space:]]+worktree[[:space:]]+add\b' || exit 0
|
||||
[ -z "$me" ] && exit 0
|
||||
[ -z "$cwd" ] && cwd="$PWD"
|
||||
|
||||
# Extract the <path> arg of `git worktree add [flags] <path> [<commit-ish>]`.
|
||||
# Skip flags; skip the values of the value-taking flags (-b/-B/--reason). Worktree paths
|
||||
# in this repo have no spaces, so whitespace tokenization is safe.
|
||||
add_args=$(printf '%s' "$cmd" | sed -E 's/.*git[[:space:]]+worktree[[:space:]]+add[[:space:]]+//')
|
||||
path=""
|
||||
skip=0
|
||||
for tok in $add_args; do
|
||||
if [ "$skip" = 1 ]; then skip=0; continue; fi
|
||||
case "$tok" in
|
||||
-b|-B|--reason) skip=1; continue ;;
|
||||
--) continue ;;
|
||||
-*) continue ;;
|
||||
*) path=$(printf '%s' "$tok" | tr -d '"'"'"''); break ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$path" ] && exit 0
|
||||
case "$path" in /*) abs="$path" ;; *) abs="$cwd/$path" ;; esac
|
||||
[ -d "$abs" ] || exit 0
|
||||
# Don't clobber a marker a different session already planted.
|
||||
[ -f "$abs/.claude-worktree-owner" ] && exit 0
|
||||
printf '%s\n' "$me" > "$abs/.claude-worktree-owner" 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -1,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,85 +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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" start",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse-worktree-marker.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" finish",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.3.4.1",
|
||||
"version": "2025.3.0.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
|
||||
+5
-9
@@ -106,17 +106,13 @@ ij_json_wrap_long_lines = false
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
# --- Static-analysis pack adoption (ersatztv#15) ---
|
||||
# Threading analyzers and Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are enabled centrally.
|
||||
# Default their diagnostics to `suggestion`; the SDK's exact per-rule suggestion baseline lives in
|
||||
# eng/analyzers/sdk-all-suggestion.globalconfig because AnalysisLevel=latest-All otherwise injects
|
||||
# exact warning severities that outrank this bulk setting. High-value rules are promoted one at a
|
||||
# time. Explicit per-rule severities (e.g. ca1848 above) take precedence over both baselines.
|
||||
# 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
|
||||
|
||||
# A collection count can never be negative. Treat comparisons that therefore collapse to a
|
||||
# constant as errors; the first promotion caught a busy/idle branch that was permanently busy.
|
||||
dotnet_diagnostic.S3981.severity = warning
|
||||
|
||||
# Blazor components: analyzers run on .razor/.cshtml @code too, and TWAE would otherwise
|
||||
# turn their default-severity findings into build errors — keep them at suggestion as well.
|
||||
[*.razor]
|
||||
|
||||
@@ -22,15 +22,11 @@ on:
|
||||
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.
|
||||
# Single runner on jazz: serialize all runs so the push-main-then-tag release
|
||||
# flow can't collide on the shared :buildcache tag or the smoke container.
|
||||
concurrency:
|
||||
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
group: ersatztv-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
@@ -44,22 +40,13 @@ jobs:
|
||||
- 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
|
||||
fetch-depth: 0
|
||||
|
||||
- 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
|
||||
|
||||
@@ -101,40 +88,7 @@ jobs:
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
|
||||
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
|
||||
# per test project (via --collect above); ReportGenerator merges them into a human-readable
|
||||
# summary printed to the log and the job step summary. No floor is enforced yet ("decide on a
|
||||
# floor later"), so this step is purely informational — continue-on-error keeps a missing
|
||||
# report or a transient tool-install failure from ever blocking a build.
|
||||
- name: Coverage summary
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s globstar nullglob
|
||||
reports=(coverage/**/coverage.cobertura.xml)
|
||||
if [ ${#reports[@]} -eq 0 ]; then
|
||||
echo "No coverage reports found under ./coverage -- skipping summary."
|
||||
exit 0
|
||||
fi
|
||||
echo "Found ${#reports[@]} coverage report(s)."
|
||||
# `update` is install-or-update (idempotent, unlike `install` which errors if the tool
|
||||
# is already present under set -e); pinned for reproducible summary output.
|
||||
dotnet tool update --global dotnet-reportgenerator-globaltool --version 5.5.10 >/dev/null
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
reportgenerator \
|
||||
"-reports:coverage/**/coverage.cobertura.xml" \
|
||||
"-targetdir:coverage/report" \
|
||||
"-reporttypes:TextSummary;MarkdownSummaryGithub"
|
||||
echo "::group::Coverage summary"
|
||||
cat coverage/report/Summary.txt
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then
|
||||
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
@@ -148,9 +102,8 @@ jobs:
|
||||
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.
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
|
||||
--health-interval=5s
|
||||
@@ -159,21 +112,12 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -202,12 +146,7 @@ jobs:
|
||||
# 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;"
|
||||
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
@@ -216,99 +155,13 @@ jobs:
|
||||
--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
|
||||
dotnet ef database update --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
echo "::endgroup::"
|
||||
|
||||
functional-e2e:
|
||||
name: Functional E2E (curl contracts)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
|
||||
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
|
||||
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
|
||||
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
|
||||
# the unit-test gate — promote it to a required check / build dependency once it's proven
|
||||
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
|
||||
# Runs on PRs and on main (regression net); skipped for v* tag builds.
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
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: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
|
||||
|
||||
- name: Ensure ffmpeg is available
|
||||
run: command -v ffmpeg >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y ffmpeg)
|
||||
|
||||
- name: Boot instance and run functional-E2E harness
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
CFG="$(mktemp -d)"
|
||||
# e2e-local.sh copies wwwroot, launches the DLL in the background (logging to a file, so
|
||||
# this command substitution returns as soon as the app is ready), and prints PID/CONFIG_DIR.
|
||||
OUT="$(scripts/e2e-local.sh "$CFG")"
|
||||
printf '%s\n' "$OUT"
|
||||
PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')"
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# `small` = the dedicated small-jobs runner lane (server-management#574).
|
||||
# On PR runs this job only resolves its skip, but Gitea still dispatches it
|
||||
# as a task — on the ubuntu-latest runners that skip queued behind long
|
||||
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
|
||||
# capped at 4 CPUs / 10g.
|
||||
runs-on: small
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
@@ -427,214 +280,3 @@ jobs:
|
||||
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
-10
@@ -2,8 +2,6 @@
|
||||
*.*~
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
# Code-coverage output (dotnet test --results-directory ./coverage, ersatztv#15)
|
||||
/coverage/
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
@@ -56,11 +54,4 @@ 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
|
||||
web/node_modules/
|
||||
|
||||
@@ -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
|
||||
@@ -5,7 +5,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10
|
||||
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root `/` and every legacy route now 302 to `/app`, either via an explicit redirect in `ErsatzTV/LegacyUiRedirects.cs` or the Startup catch-all fallback (any unmatched non-`/api`/`/artwork`/`/docs`/`/openapi` path → `/app`). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE.
|
||||
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves un-migrated admin screens (collections, media browse, trakt, filler, watermarks, ffmpeg profiles, blocks/decos/templates, playout editors, logs, troubleshooting; Blazor home = `/system/health`); its removal is #91 phase (b), gated on parity issues #140–#147
|
||||
- **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
|
||||
@@ -15,7 +15,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, DI setup |
|
||||
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, legacy Blazor pages, 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 |
|
||||
@@ -35,10 +35,10 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: bumblebee (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on bumblebee → `/config` in container
|
||||
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** `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`.
|
||||
- **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 deploys via **Komodo GitOps**: the `media-servers` compose in `timothy/server-management` (`docker/bumblebee/stacks/media-servers/compose.yaml`) pins the version tag (currently `26.5.0`, deployed 2026-07-07); releasing = tag here, wait for the image build, bump that pin and push (the Komodo pre-deploy hook backs up before recreating). Test container tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -55,22 +55,10 @@ 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.
|
||||
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, 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.
|
||||
- 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/`)
|
||||
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
|
||||
- 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.
|
||||
@@ -82,12 +70,6 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6 + H10).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
|
||||
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **auto-grants** a merge (emits `permissionDecision: allow`, so **no** redundant mechanical prompt fires) only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked **and** a `Review-verdict:` comment references the PR's *current head sha* (**H10**); **denies** on an unticked box, red CI, or a stale/negative review verdict; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no `Review-verdict:` comment yet, no creds, Gitea down). On the auto-grant (satisfied) path the derived state **is** the consent — do not also ask conversationally to merge; a separate human confirmation is warranted only when the gate **asks** (ersatztv#314). **The H10 review-verdict convention**: after an adversarial/Codex review of a PR (or its latest fix commit), 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.
|
||||
|
||||
+2
-13
@@ -3,12 +3,6 @@
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<!-- Analyzer posture (ersatztv#15): enable the complete SDK rule set and the
|
||||
threading analyzer in every centrally managed project. The checked-in globalconfig
|
||||
keeps the SDK baseline at suggestion; individually promoted rules become CI-blocking. -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>latest-All</AnalysisLevel>
|
||||
<EnableThreadingAnalyzers>true</EnableThreadingAnalyzers>
|
||||
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
|
||||
packages as NU1901-1904 warnings. Several projects set
|
||||
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
|
||||
@@ -16,13 +10,8 @@
|
||||
advisories to warnings (still printed in build logs); NU1904 (critical)
|
||||
stays an error so criticals still block. Track fixes separately.
|
||||
WarningsAsErrors promotes NU1904 in EVERY project (even those without
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide.
|
||||
S3981 is the first explicitly promoted analyzer rule (ersatztv#15). -->
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904;S3981</WarningsAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EditorConfigFiles Include="$(MSBuildThisFileDirectory)eng/analyzers/sdk-all-suggestion.globalconfig" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+10
-8
@@ -1,7 +1,9 @@
|
||||
<Project>
|
||||
<!-- Guard on CPM so the gitignored .mcp tool, which deliberately uses inline package
|
||||
versions, does not inherit a versionless analyzer PackageReference. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PropertyGroup>
|
||||
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
@@ -10,11 +12,11 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every centrally managed project.
|
||||
Versions are central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored
|
||||
.mcp tool (which opts out of CPM) doesn't pull versionless references. They start at
|
||||
`suggestion` severity in .editorconfig so they don't fail the TreatWarningsAsErrors build;
|
||||
high-value rules are promoted to warning/error incrementally. -->
|
||||
<!-- 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>
|
||||
|
||||
@@ -5,21 +5,26 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
|
||||
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
|
||||
<PackageVersion Include="BlazorSortable" Version="5.2.1" />
|
||||
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.2" />
|
||||
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.79" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.66" />
|
||||
<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="EntityFrameworkProfiler.Appender" Version="6.0.6049" />
|
||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.10" />
|
||||
<PackageVersion Include="Heron.MudCalendar" Version="3.4.0" />
|
||||
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<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" />
|
||||
@@ -28,17 +33,14 @@
|
||||
<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="Markdig" Version="0.44.0" />
|
||||
<PackageVersion Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageVersion Include="MediatR.Courier.DependencyInjection" Version="5.0.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)" />
|
||||
@@ -59,6 +61,8 @@
|
||||
<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="MudBlazor" Version="8.15.0" />
|
||||
<PackageVersion Include="NaturalSort.Extension" Version="4.4.1" />
|
||||
<PackageVersion Include="NCalcSync" Version="6.3.2" />
|
||||
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
|
||||
@@ -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)}";
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ namespace ErsatzTV.Application.Artworks;
|
||||
/// 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)
|
||||
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
|
||||
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
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>>
|
||||
{
|
||||
// png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
|
||||
// formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
|
||||
private static readonly System.Collections.Generic.HashSet<string> AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp"
|
||||
};
|
||||
|
||||
private readonly IImageCache _imageCache;
|
||||
|
||||
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
|
||||
@@ -16,31 +25,15 @@ public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseEr
|
||||
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)
|
||||
string contentType = (request.ContentType ?? string.Empty).Trim();
|
||||
if (!AcceptedContentTypes.Contains(contentType))
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Uploaded file is not a supported image; supported types are: {string.Join(", ", ImageContentTypes.Accepted)}");
|
||||
$"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
|
||||
}
|
||||
|
||||
string contentType = maybeContentType.IfNone(string.Empty);
|
||||
|
||||
using var toCache = new MemoryStream(bytes, writable: false);
|
||||
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
|
||||
toCache,
|
||||
request.Stream,
|
||||
request.ArtworkKind);
|
||||
|
||||
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
|
||||
|
||||
@@ -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,44 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public static class AutoTuneAxisMap
|
||||
{
|
||||
// Server-owned Lucene smart-collection query for an axis value.
|
||||
public static string GenerateQuery(AutoTuneAxis axis, string value)
|
||||
{
|
||||
string escaped = EscapeLuceneValue(value);
|
||||
return axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => $"type:episode AND show_title:\"{escaped}\"",
|
||||
AutoTuneAxis.TvGenre => $"type:episode AND genre:\"{escaped}\"",
|
||||
AutoTuneAxis.MovieGenre => $"type:movie AND genre:\"{escaped}\"",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
}
|
||||
|
||||
// Human-facing channel name. Movie-genre channels are suffixed so a genre that exists for
|
||||
// both TV and movies ("Comedy" vs "Comedy Movies") does not produce two identically-named channels.
|
||||
public static string GenerateName(AutoTuneAxis axis, string value) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => value,
|
||||
AutoTuneAxis.TvGenre => value,
|
||||
AutoTuneAxis.MovieGenre => $"{value} Movies",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// PseudoTV per-type defaults: single-show channels play in episode order; genre channels shuffle.
|
||||
public static PlaybackOrder PlaybackOrderFor(AutoTuneAxis axis) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => PlaybackOrder.SeasonEpisode,
|
||||
AutoTuneAxis.TvGenre => PlaybackOrder.Shuffle,
|
||||
AutoTuneAxis.MovieGenre => PlaybackOrder.Shuffle,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
|
||||
public static string EscapeLuceneValue(string value) =>
|
||||
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public static class AutoTuneNumberAllocator
|
||||
{
|
||||
// Allocate `count` sequential integer channel numbers starting at `startingNumber`,
|
||||
// skipping any number already present in `existingNumbers`. Channel.Number is a string,
|
||||
// so numbers are returned as invariant-culture strings.
|
||||
public static List<string> Allocate(int startingNumber, int count, ISet<string> existingNumbers)
|
||||
{
|
||||
var result = new List<string>(count);
|
||||
int next = startingNumber;
|
||||
while (result.Count < count)
|
||||
{
|
||||
string candidate = next.ToString(CultureInfo.InvariantCulture);
|
||||
if (!existingNumbers.Contains(candidate))
|
||||
{
|
||||
result.Add(candidate);
|
||||
}
|
||||
|
||||
next++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,7 @@ public class BulkDeleteChannelsHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
@@ -54,10 +54,7 @@ public class BulkMoveChannelsToGroupHandler(
|
||||
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);
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
@@ -68,22 +68,19 @@ public class CreateChannelFromLineupHandler(
|
||||
}
|
||||
|
||||
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);
|
||||
cancellationToken);
|
||||
|
||||
// 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);
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return new CreateChannelFromLineupResponseModel(
|
||||
prepared.Channel.Id,
|
||||
|
||||
@@ -47,24 +47,20 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
// Delete the guide cache file through the filesystem abstraction (so it's observable under a
|
||||
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
|
||||
// process crashes in between (nothing reaps it, and GetChannelGuideHandler serves everything
|
||||
// in the cache folder). The guide xml is regenerable on demand, so losing it pre-commit is safe (#254).
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
_fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
dbContext.Channels.Remove(channel);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
|
||||
// CancellationToken.None so a late request cancellation can't abort it after the delete committed (#254)
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -157,23 +157,21 @@ public class UpdateChannelHandler(
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
||||
{
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, CancellationToken.None);
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), CancellationToken.None);
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
if (hasEpgChange)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
|
||||
}
|
||||
|
||||
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
|
||||
|
||||
@@ -81,12 +81,10 @@ public class UpdateChannelNumbersHandler(
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
// update channel list and xmltv
|
||||
// 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);
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), CancellationToken.None);
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record CreateAutoTunedChannels(
|
||||
int TemplateId,
|
||||
string Group,
|
||||
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
|
||||
|
||||
public record AutoTuneChannelSelection(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number);
|
||||
|
||||
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
|
||||
{
|
||||
public int CreatedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Created);
|
||||
public int SkippedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Skipped);
|
||||
public int FailedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Failed);
|
||||
}
|
||||
|
||||
public record AutoTuneChannelOutcome(
|
||||
string Name,
|
||||
AutoTuneOutcomeStatus Status,
|
||||
int? ChannelId,
|
||||
string Reason);
|
||||
|
||||
public enum AutoTuneOutcomeStatus
|
||||
{
|
||||
Created,
|
||||
Skipped,
|
||||
Failed
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class CreateAutoTunedChannelsHandler(ISender mediator)
|
||||
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
|
||||
{
|
||||
private const string NumberTakenError = "Channel number must be unique";
|
||||
private const string DefaultGroup = "Auto-Tuned";
|
||||
|
||||
public async Task<AutoTuneResult> Handle(
|
||||
CreateAutoTunedChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim();
|
||||
var outcomes = new List<AutoTuneChannelOutcome>();
|
||||
|
||||
foreach (AutoTuneChannelSelection selection in request.Channels ?? [])
|
||||
{
|
||||
outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken));
|
||||
}
|
||||
|
||||
return new AutoTuneResult(outcomes);
|
||||
}
|
||||
|
||||
private async Task<AutoTuneChannelOutcome> CreateOne(
|
||||
int templateId,
|
||||
string group,
|
||||
AutoTuneChannelSelection selection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string name = (selection.Name ?? string.Empty).Trim();
|
||||
if (name.Length is 0 or > 50)
|
||||
{
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name");
|
||||
}
|
||||
|
||||
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
|
||||
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
|
||||
|
||||
// 1. Create the smart collection that drives this channel.
|
||||
Either<BaseError, SmartCollectionViewModel> scResult =
|
||||
await mediator.Send(new CreateSmartCollection(query, name), cancellationToken);
|
||||
|
||||
SmartCollectionViewModel smartCollection = null;
|
||||
foreach (BaseError error in scResult.LeftToSeq())
|
||||
{
|
||||
return new AutoTuneChannelOutcome(
|
||||
name, AutoTuneOutcomeStatus.Failed, null, $"Smart collection: {error.Value}");
|
||||
}
|
||||
|
||||
foreach (SmartCollectionViewModel vm in scResult.RightToSeq())
|
||||
{
|
||||
smartCollection = vm;
|
||||
}
|
||||
|
||||
// 2. Create the channel from a single-item lineup referencing the smart collection.
|
||||
var command = new CreateChannelFromLineup(
|
||||
name,
|
||||
selection.Number,
|
||||
group,
|
||||
string.Empty,
|
||||
ArtworkContentTypeModel.None,
|
||||
IsEnabled: true,
|
||||
ShowInEpg: true,
|
||||
templateId,
|
||||
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
|
||||
[
|
||||
new CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType.SmartCollection,
|
||||
CollectionType.SmartCollection,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: null,
|
||||
SmartCollectionId: smartCollection.Id,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null)
|
||||
]);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
foreach (BaseError error in channelResult.LeftToSeq())
|
||||
{
|
||||
// Roll back the smart collection we just created so a retry of this
|
||||
// axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort;
|
||||
// the primary outcome below is still Skipped/Failed regardless of the delete result.
|
||||
// Swallow any exception (not just an Either.Left) so a transient infra failure
|
||||
// during rollback never aborts this channel's outcome or the batch; the
|
||||
// orphaned SmartCollection is an acceptable degraded outcome.
|
||||
try
|
||||
{
|
||||
await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// intentionally ignored; see comment above
|
||||
}
|
||||
|
||||
AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal)
|
||||
? AutoTuneOutcomeStatus.Skipped
|
||||
: AutoTuneOutcomeStatus.Failed;
|
||||
return new AutoTuneChannelOutcome(name, status, null, error.Value);
|
||||
}
|
||||
|
||||
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
|
||||
}
|
||||
}
|
||||
@@ -38,41 +38,6 @@ internal static class Mapper
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(Channel channel, int playoutCount)
|
||||
{
|
||||
ArtworkContentTypeModel logo = GetLogo(channel);
|
||||
return new ChannelDetailResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfileId,
|
||||
channel.SlugSeconds,
|
||||
new ChannelLogoResponseModel(logo.Path, logo.ContentType),
|
||||
channel.StreamSelectorMode,
|
||||
channel.StreamSelector,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
channel.PreferredAudioTitle,
|
||||
channel.PlayoutSource,
|
||||
channel.PlayoutMode,
|
||||
channel.MirrorSourceChannelId,
|
||||
channel.PlayoutOffset,
|
||||
channel.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId,
|
||||
playoutCount,
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode,
|
||||
channel.MusicVideoCreditsTemplate,
|
||||
channel.SongVideoMode,
|
||||
channel.TranscodeMode,
|
||||
channel.IdleBehavior,
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
}
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
channel.Id,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record PreviewAutoTuneChannels(
|
||||
List<AutoTuneAxis> Axes,
|
||||
int MinItems,
|
||||
int StartingNumber) : IRequest<Either<BaseError, List<AutoTuneProposal>>>;
|
||||
|
||||
public record AutoTuneProposal(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number,
|
||||
int ItemCount,
|
||||
bool AlreadyExists);
|
||||
@@ -1,144 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class PreviewAutoTuneChannelsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<PreviewAutoTuneChannels, Either<BaseError, List<AutoTuneProposal>>>
|
||||
{
|
||||
public async Task<Either<BaseError, List<AutoTuneProposal>>> Handle(
|
||||
PreviewAutoTuneChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Axes is null || request.Axes.Count == 0)
|
||||
{
|
||||
return BaseError.New("At least one axis is required");
|
||||
}
|
||||
|
||||
if (request.MinItems < 1)
|
||||
{
|
||||
return BaseError.New("Minimum items must be at least 1");
|
||||
}
|
||||
|
||||
if (request.StartingNumber < 1)
|
||||
{
|
||||
return BaseError.New("Starting channel number must be at least 1");
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Enumerate (axis, value, count) triples per requested axis, preserving axis order.
|
||||
var raw = new List<(AutoTuneAxis Axis, string Value, int Count)>();
|
||||
foreach (AutoTuneAxis axis in request.Axes.Distinct())
|
||||
{
|
||||
raw.AddRange(await EnumerateAxis(dbContext, axis, request.MinItems, cancellationToken));
|
||||
}
|
||||
|
||||
System.Collections.Generic.HashSet<string> existingNumbers = (await dbContext.Channels.AsNoTracking()
|
||||
.Select(c => c.Number).ToListAsync(cancellationToken))
|
||||
.ToHashSet();
|
||||
System.Collections.Generic.HashSet<string> existingNames = (await dbContext.Channels.AsNoTracking()
|
||||
.Select(c => c.Name).ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Drop entries whose generated name would be rejected at create time (Channel name <= 50
|
||||
// chars) before number allocation, so numbers aren't wasted on proposals that can never
|
||||
// be created.
|
||||
List<(AutoTuneAxis Axis, string Value, int Count, string Name)> survivors = raw
|
||||
.Select(r => (r.Axis, r.Value, r.Count, Name: AutoTuneAxisMap.GenerateName(r.Axis, r.Value)))
|
||||
.Where(r => r.Name.Length <= 50)
|
||||
.ToList();
|
||||
|
||||
List<string> numbers = AutoTuneNumberAllocator.Allocate(
|
||||
request.StartingNumber, survivors.Count, existingNumbers);
|
||||
|
||||
var proposals = new List<AutoTuneProposal>(survivors.Count);
|
||||
for (int i = 0; i < survivors.Count; i++)
|
||||
{
|
||||
(AutoTuneAxis axis, string value, int count, string name) = survivors[i];
|
||||
proposals.Add(new AutoTuneProposal(
|
||||
axis, value, name, numbers[i], count, existingNames.Contains(name)));
|
||||
}
|
||||
|
||||
return proposals;
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateAxis(
|
||||
TvContext dbContext, AutoTuneAxis axis, int minItems, CancellationToken cancellationToken) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => await EnumerateTvShows(dbContext, minItems, cancellationToken),
|
||||
AutoTuneAxis.TvGenre => await EnumerateEpisodeGenres(dbContext, minItems, cancellationToken),
|
||||
AutoTuneAxis.MovieGenre => await EnumerateMovieGenres(dbContext, minItems, cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateTvShows(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
// Episode count per show id (Episode -> Season -> ShowId). Proven query style from LibraryBrowseItemMapper.
|
||||
Dictionary<int, int> episodeCounts = await dbContext.Episodes.AsNoTracking()
|
||||
.GroupBy(e => e.Season.ShowId)
|
||||
.Select(g => new { ShowId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
|
||||
|
||||
var showTitles = await dbContext.ShowMetadata.AsNoTracking()
|
||||
.Select(sm => new { sm.ShowId, sm.Title })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Collapse shows that share a title (the generated show_title query matches them together).
|
||||
var byTitle = new Dictionary<string, int>();
|
||||
foreach (var row in showTitles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Title))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
episodeCounts.TryGetValue(row.ShowId, out int count);
|
||||
byTitle[row.Title] = byTitle.GetValueOrDefault(row.Title) + count;
|
||||
}
|
||||
|
||||
return byTitle
|
||||
.Where(kv => kv.Value >= minItems)
|
||||
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kv => (AutoTuneAxis.TvShow, kv.Key, kv.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateEpisodeGenres(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = await dbContext.EpisodeMetadata.AsNoTracking()
|
||||
.SelectMany(m => m.Genres)
|
||||
.GroupBy(g => g.Name)
|
||||
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return counts
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
|
||||
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(c => (AutoTuneAxis.TvGenre, c.Name, c.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateMovieGenres(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = await dbContext.MovieMetadata.AsNoTracking()
|
||||
.SelectMany(m => m.Genres)
|
||||
.GroupBy(g => g.Name)
|
||||
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return counts
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
|
||||
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(c => (AutoTuneAxis.MovieGenre, c.Name, c.Count))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelByIdForApi(int Id) : IRequest<Option<ChannelDetailResponseModel>>;
|
||||
@@ -1,15 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelByIdForApi, Option<ChannelDetailResponseModel>>
|
||||
{
|
||||
public Task<Option<ChannelDetailResponseModel>> Handle(
|
||||
GetChannelByIdForApi request,
|
||||
CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0));
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -18,8 +15,7 @@ public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IConfigElementRepository configElementRepository)
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
@@ -27,21 +23,6 @@ public partial class GetChannelGuideHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
maybeBaseUrl.IfNone(string.Empty),
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl);
|
||||
|
||||
// The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already
|
||||
// emitted as &), so the substituted base must be XML-escaped. A path prefix can legally
|
||||
// contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and
|
||||
// malform the whole guide. Normal URLs have no special chars, so this is a no-op for them.
|
||||
string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}");
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
@@ -67,7 +48,7 @@ public partial class GetChannelGuideHandler(
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
.Replace("{RequestBase}", requestBase)
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
@@ -89,7 +70,7 @@ public partial class GetChannelGuideHandler(
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", requestBase)
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
@@ -4,31 +4,19 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelPlaylistHandler(
|
||||
IChannelRepository channelRepository,
|
||||
IConfigElementRepository configElementRepository)
|
||||
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
{
|
||||
public async Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
maybeBaseUrl.IfNone(string.Empty),
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl);
|
||||
|
||||
List<Channel> channels = EnsureMode(await channelRepository.GetAll(cancellationToken), request.Mode);
|
||||
return new ChannelPlaylist(
|
||||
scheme,
|
||||
host,
|
||||
baseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken);
|
||||
}
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
|
||||
@@ -11,8 +10,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStatesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker)
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
|
||||
{
|
||||
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
|
||||
@@ -143,8 +141,7 @@ public class GetChannelStatesForApiHandler(
|
||||
return new ChannelStateResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
ffmpegSegmenterService.IsActive(channel.Number) ||
|
||||
directStreamSessionTracker.IsActive(channel.Number),
|
||||
ffmpegSegmenterService.IsActive(channel.Number),
|
||||
nowPlaying);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public static class ConcurrencyExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Persist changes that touch a versioned root but do <b>not</b> participate in the If-Match
|
||||
/// contract (e.g. a playout's settings/schedule-file/on-demand-checkpoint writer, a collection's
|
||||
/// name edit). Because the root's <c>Version</c> is an <c>IsConcurrencyToken</c>, EF guards every
|
||||
/// UPDATE of that row with <c>WHERE Version=@orig</c>, so a concurrent bump from a replace-all
|
||||
/// editor would otherwise surface as an unhandled <see cref="DbUpdateConcurrencyException" /> →
|
||||
/// 500 (issue #253 / #269). Phase-1 semantics for a missing <c>If-Match</c> is <b>force-write</b>,
|
||||
/// so on a concurrency failure we rebase onto the stored token: original becomes the stored value
|
||||
/// (the retry's WHERE then matches) and current becomes stored + our pending delta (a bumper's ++
|
||||
/// still advances the ETag past the concurrent writer's value — #269 rotation; a non-bumper adopts
|
||||
/// it unchanged) and retry; our own modified scalars still win. Bounded to avoid a livelock; if the row
|
||||
/// was deleted out from under us, that's a genuine conflict and rethrows.
|
||||
/// </summary>
|
||||
public static async Task<int> SaveChangesForcingVersion(
|
||||
this DbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex) when (attempt < 5)
|
||||
{
|
||||
var resolvedAny = false;
|
||||
foreach (EntityEntry entry in ex.Entries)
|
||||
{
|
||||
if (entry.Entity is not IVersionedAggregate)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PropertyValues databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken);
|
||||
if (databaseValues is null)
|
||||
{
|
||||
// The row was deleted out from under us — a genuine conflict, not a token race.
|
||||
throw;
|
||||
}
|
||||
|
||||
PropertyEntry version = entry.Property(nameof(IVersionedAggregate.Version));
|
||||
int dbVersion = (int)databaseValues[nameof(IVersionedAggregate.Version)]!;
|
||||
|
||||
// Rebase our pending delta on top of the stored token instead of adopting it verbatim:
|
||||
// a Version-bumping sibling (pending current = original + 1) must still advance the
|
||||
// ETag PAST the concurrent writer's value, or an editor holding that writer's ETag is
|
||||
// never invalidated by our change (#269 rotation silently lost under race). Non-bumpers
|
||||
// (delta 0, e.g. ErasePlayoutHistory) still adopt the stored token unchanged.
|
||||
int pendingDelta = (int)version.CurrentValue! - (int)version.OriginalValue!;
|
||||
version.OriginalValue = dbVersion;
|
||||
version.CurrentValue = dbVersion + pendingDelta;
|
||||
resolvedAny = true;
|
||||
}
|
||||
|
||||
if (!resolvedAny)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persist pending changes, mapping the EF optimistic-concurrency failure to
|
||||
/// <see cref="PreconditionFailedError" /> (→ 412). When a versioned root carries an
|
||||
/// <c>IsConcurrencyToken</c> column and its <c>Version</c> is bumped before saving, EF emits
|
||||
/// <c>UPDATE … WHERE Id=@id AND Version=@original</c>; a zero-row result (another writer won
|
||||
/// the race between our load and save) throws <see cref="DbUpdateConcurrencyException" />.
|
||||
/// This is the backstop that closes the load→save TOCTOU the handler pre-check cannot.
|
||||
/// Issue #253.
|
||||
/// </summary>
|
||||
public static async Task<Either<BaseError, Unit>> SaveChangesWithConcurrencyGuard(
|
||||
this DbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new PreconditionFailedError(
|
||||
"The resource was modified by another request. Reload and try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateIptvSettings(IptvSettingsViewModel IptvSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,51 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateIptvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateIptvSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateIptvSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Unit> validation = Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(request.IptvSettings, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(IptvSettingsViewModel iptvSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
string baseUrl = (iptvSettings.BaseUrl ?? string.Empty).Trim();
|
||||
|
||||
// A blank value clears the setting so the request-derived behavior is restored.
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
await configElementRepository.Delete(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.IptvBaseUrl, baseUrl, cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, Unit> Validate(UpdateIptvSettings request)
|
||||
{
|
||||
string baseUrl = request.IptvSettings.BaseUrl;
|
||||
|
||||
// Blank is valid (clears the override); a non-blank value must be a well-formed advertised base URL.
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
return AdvertisedBaseUrl.TryParse(baseUrl)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>(
|
||||
"Advertised base URL must be an absolute http(s) URL with no credentials, query, or fragment");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class IptvSettingsViewModel
|
||||
{
|
||||
public string BaseUrl { get; set; }
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetIptvSettings : IRequest<IptvSettingsViewModel>;
|
||||
@@ -1,19 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetIptvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetIptvSettings, IptvSettingsViewModel>
|
||||
{
|
||||
public async Task<IptvSettingsViewModel> Handle(GetIptvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
return new IptvSettingsViewModel
|
||||
{
|
||||
BaseUrl = await maybeBaseUrl.IfNoneAsync(string.Empty)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,21 +30,12 @@ public class DisconnectEmbyHandler : IRequestHandler<DisconnectEmby, Either<Base
|
||||
DisconnectEmby request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) —
|
||||
// release unconditionally so a throw from any awaited dependency (repo delete, search-index
|
||||
// commit, secret store) can't wedge the Emby lock until restart (design #202 finding 7).
|
||||
try
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _embySecretStore.DeleteAll();
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _embySecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
}
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true)
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -12,29 +12,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateEmbyPathReplacements request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken);
|
||||
|
||||
return await maybeSource.Match(
|
||||
Some: async embyMediaSource =>
|
||||
{
|
||||
Option<BaseError> maybeError = ValidateItems(request, embyMediaSource);
|
||||
return await maybeError.Match(
|
||||
Some: error => Task.FromResult(Left<BaseError, Unit>(error)),
|
||||
None: async () =>
|
||||
{
|
||||
await MergePathReplacements(request, embyMediaSource);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
});
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, Unit>(
|
||||
BaseError.New($"Emby media source {request.EmbyMediaSourceId} does not exist."))));
|
||||
}
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Unit> MergePathReplacements(
|
||||
UpdateEmbyPathReplacements request,
|
||||
@@ -54,38 +37,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath };
|
||||
|
||||
// Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by
|
||||
// EmbyMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the
|
||||
// mutation is all-or-nothing.
|
||||
private static Option<BaseError> ValidateItems(
|
||||
UpdateEmbyPathReplacements request,
|
||||
EmbyMediaSource embyMediaSource)
|
||||
{
|
||||
List<EmbyPathReplacementItem> items = request.PathReplacements ?? [];
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
EmbyMediaSourceMustExist(request, cancellationToken);
|
||||
|
||||
if (items.Any(item => item is null))
|
||||
{
|
||||
return BaseError.New("Path replacement items must not be null.");
|
||||
}
|
||||
|
||||
if (items.Any(item => string.IsNullOrWhiteSpace(item.EmbyPath) || string.IsNullOrWhiteSpace(item.LocalPath)))
|
||||
{
|
||||
return BaseError.New("Each path replacement requires a non-blank Emby path and local path.");
|
||||
}
|
||||
|
||||
var existingIds = (embyMediaSource.PathReplacements ?? new List<EmbyPathReplacement>())
|
||||
.Map(pr => pr.Id)
|
||||
.ToList();
|
||||
var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id))
|
||||
.Map(item => item.Id)
|
||||
.ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Emby media source " +
|
||||
$"{request.EmbyMediaSourceId}.");
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
|
||||
UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
</PropertyGroup>
|
||||
@@ -14,7 +15,6 @@
|
||||
<PackageReference Include="MediatR" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" />
|
||||
<PackageReference Include="WebMarkupMin.Core" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -139,7 +139,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(updateFFmpegProfile.Name) || updateFFmpegProfile.Name.Length > 50)
|
||||
if (updateFFmpegProfile.Name.Length > 50)
|
||||
{
|
||||
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
|
||||
}
|
||||
|
||||
@@ -52,17 +52,13 @@ internal static class Mapper
|
||||
ffmpegProfile.Id,
|
||||
ffmpegProfile.Name,
|
||||
ffmpegProfile.ThreadCount,
|
||||
ffmpegProfile.NormalizeAudio,
|
||||
ffmpegProfile.NormalizeVideo,
|
||||
ffmpegProfile.HardwareAcceleration,
|
||||
ffmpegProfile.VaapiDisplay,
|
||||
ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDevice,
|
||||
ffmpegProfile.QsvExtraHardwareFrames,
|
||||
ffmpegProfile.ResolutionId,
|
||||
ffmpegProfile.Resolution.Name,
|
||||
ffmpegProfile.ScalingBehavior,
|
||||
ffmpegProfile.PadMode,
|
||||
ffmpegProfile.VideoFormat,
|
||||
ffmpegProfile.VideoProfile,
|
||||
ffmpegProfile.VideoPreset,
|
||||
@@ -75,10 +71,8 @@ internal static class Mapper
|
||||
ffmpegProfile.AudioBitrate,
|
||||
ffmpegProfile.AudioBufferSize,
|
||||
ffmpegProfile.NormalizeLoudnessMode,
|
||||
ffmpegProfile.TargetLoudness,
|
||||
ffmpegProfile.AudioChannels,
|
||||
ffmpegProfile.AudioSampleRate,
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
ffmpegProfile.NormalizeColors,
|
||||
ffmpegProfile.DeinterlaceVideo == true);
|
||||
ffmpegProfile.DeinterlaceVideo);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,4 @@ public record CreateFillerPreset(
|
||||
int? PlaylistId,
|
||||
string Expression,
|
||||
bool UseChaptersAsMediaItems
|
||||
) : IRequest<Either<BaseError, CreateFillerPresetResult>>;
|
||||
|
||||
public record CreateFillerPresetResult(int FillerPresetId) : EntityIdResult(FillerPresetId);
|
||||
) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -6,25 +6,23 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public class CreateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<CreateFillerPreset, Either<BaseError, CreateFillerPresetResult>>
|
||||
: IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, CreateFillerPresetResult>> Handle(
|
||||
CreateFillerPreset request,
|
||||
CancellationToken cancellationToken)
|
||||
public async Task<Either<BaseError, Unit>> Handle(CreateFillerPreset request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(fp => Persist(dbContext, fp, cancellationToken));
|
||||
}
|
||||
|
||||
private static async Task<CreateFillerPresetResult> Persist(
|
||||
private static async Task<Unit> Persist(
|
||||
TvContext dbContext,
|
||||
FillerPreset fillerPreset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new CreateFillerPresetResult(fillerPreset.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, FillerPreset>> Validate(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -19,14 +18,8 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<FillerPreset> maybeFillerPreset = await FillerPresetMustExist(dbContext, request, cancellationToken);
|
||||
|
||||
// must-exist maps to a NotFoundError Either directly (not via Validation, which
|
||||
// aggregates errors and loses the subtype the API layer maps to 404)
|
||||
return await maybeFillerPreset.Match(
|
||||
Some: fillerPreset => DoDeletion(dbContext, fillerPreset).Map(Right<BaseError, Unit>),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"FillerPreset {request.FillerPresetId} does not exist.")));
|
||||
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => DoDeletion(dbContext, ps));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset)
|
||||
@@ -35,10 +28,11 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
|
||||
return dbContext.SaveChangesAsync().ToUnit();
|
||||
}
|
||||
|
||||
private static Task<Option<FillerPreset>> FillerPresetMustExist(
|
||||
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteFillerPreset request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FillerPresets
|
||||
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken);
|
||||
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"FillerPreset {request.FillerPresetId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -13,19 +12,8 @@ public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFac
|
||||
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<FillerPreset> maybeFillerPreset = await FillerPresetMustExist(dbContext, request, cancellationToken);
|
||||
|
||||
// must-exist maps to a NotFoundError Either directly (not via Validation, which
|
||||
// aggregates errors and loses the subtype the API layer maps to 404)
|
||||
return await maybeFillerPreset.Match(
|
||||
Some: async fillerPreset =>
|
||||
{
|
||||
Validation<BaseError, string> validation = await ValidateName(dbContext, request);
|
||||
return await validation.Apply((string _) =>
|
||||
ApplyUpdateRequest(dbContext, fillerPreset, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"FillerPreset {request.Id} does not exist.")));
|
||||
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyUpdateRequest(
|
||||
@@ -56,12 +44,20 @@ public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFac
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Option<FillerPreset>> FillerPresetMustExist(
|
||||
private static async Task<Validation<BaseError, FillerPreset>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateFillerPreset request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FillerPresetMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFillerPreset request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FillerPresets
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken);
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Filler preset does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -6,26 +6,7 @@ namespace ErsatzTV.Application.Filler;
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
|
||||
new(fillerPreset.Id, fillerPreset.Name, fillerPreset.FillerKind);
|
||||
|
||||
internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) =>
|
||||
new(
|
||||
fillerPreset.Id,
|
||||
fillerPreset.Name,
|
||||
fillerPreset.FillerKind,
|
||||
fillerPreset.FillerMode,
|
||||
fillerPreset.Duration,
|
||||
fillerPreset.Count,
|
||||
fillerPreset.PadToNearestMinute,
|
||||
fillerPreset.AllowWatermarks,
|
||||
fillerPreset.CollectionType,
|
||||
fillerPreset.CollectionId,
|
||||
fillerPreset.MediaItemId,
|
||||
fillerPreset.MultiCollectionId,
|
||||
fillerPreset.SmartCollectionId,
|
||||
fillerPreset.PlaylistId,
|
||||
fillerPreset.Expression,
|
||||
fillerPreset.UseChaptersAsMediaItems);
|
||||
new(fillerPreset.Id, fillerPreset.Name);
|
||||
|
||||
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
|
||||
new(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest<List<FillerPresetResponseModel>>;
|
||||
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
|
||||
|
||||
@@ -14,13 +14,9 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbCon
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
|
||||
if (request.FillerKind is { } fillerKind)
|
||||
{
|
||||
query = query.Where(fp => fp.FillerKind == fillerKind);
|
||||
}
|
||||
|
||||
List<FillerPreset> fillerPresets = await query.ToListAsync(cancellationToken);
|
||||
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return fillerPresets.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public record GetFillerPresetByIdForApi(int Id) : IRequest<Option<FillerPresetFullResponseModel>>;
|
||||
@@ -1,22 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Filler.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public class GetFillerPresetByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetFillerPresetByIdForApi, Option<FillerPresetFullResponseModel>>
|
||||
{
|
||||
public async Task<Option<FillerPresetFullResponseModel>> Handle(
|
||||
GetFillerPresetByIdForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(fp => fp.Id, fp => fp.Id == request.Id, cancellationToken)
|
||||
.MapT(ProjectToFullResponseModel);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@ using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null)
|
||||
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, string ContentType, int? MaxHeight = null)
|
||||
: IRequest<
|
||||
Either<BaseError, CachedImagePathViewModel>>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -96,13 +95,9 @@ public class
|
||||
}
|
||||
else
|
||||
{
|
||||
// Always derive the type from the stored file — never from a client-supplied value
|
||||
// (issue #283 — the old ?contentType= reflection was the stored-XSS sink). Clamp the
|
||||
// sniffed type to the image allow-list so a file whose bytes are not an accepted image
|
||||
// (a legacy cache entry poisoned before the upload sniff landed, or a hypothetical
|
||||
// polyglot) is served as a non-renderable download, never as HTML/script.
|
||||
string sniffed = MimeTypes.GetMimeTypeFromFile(cachePath)?.Name;
|
||||
mimeType = ImageContentTypes.IsAccepted(sniffed) ? sniffed : "application/octet-stream";
|
||||
mimeType = !string.IsNullOrWhiteSpace(request.ContentType)
|
||||
? request.ContentType
|
||||
: MimeTypes.GetMimeTypeFromFile(cachePath).Name;
|
||||
}
|
||||
|
||||
return new CachedImagePathViewModel(cachePath, mimeType);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public record ImageFolderExists(int LibraryFolderId) : IRequest<bool>;
|
||||
@@ -1,21 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public class ImageFolderExistsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ImageFolderExists, bool>
|
||||
{
|
||||
public async Task<bool> Handle(ImageFolderExists request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
return await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
lf => lf.Id == request.LibraryFolderId
|
||||
&& lf.LibraryPath.Library.MediaKind == LibraryMediaKind.Images,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -30,21 +30,12 @@ public class DisconnectJellyfinHandler : IRequestHandler<DisconnectJellyfin, Eit
|
||||
DisconnectJellyfin request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) —
|
||||
// release unconditionally so a throw from any awaited dependency (repo delete, search-index
|
||||
// commit, secret store) can't wedge the Jellyfin lock until restart (design #202 finding 7).
|
||||
try
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
}
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true) :
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan) :
|
||||
IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -12,29 +12,12 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
|
||||
public UpdateJellyfinPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId);
|
||||
|
||||
return await maybeSource.Match(
|
||||
Some: async jellyfinMediaSource =>
|
||||
{
|
||||
Option<BaseError> maybeError = ValidateItems(request, jellyfinMediaSource);
|
||||
return await maybeError.Match(
|
||||
Some: error => Task.FromResult(Left<BaseError, Unit>(error)),
|
||||
None: async () =>
|
||||
{
|
||||
await MergePathReplacements(request, jellyfinMediaSource);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
});
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, Unit>(
|
||||
BaseError.New($"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."))));
|
||||
}
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Unit> MergePathReplacements(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
@@ -54,38 +37,12 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
|
||||
private static JellyfinPathReplacement Project(JellyfinPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath };
|
||||
|
||||
// Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by
|
||||
// JellyfinMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the
|
||||
// mutation is all-or-nothing.
|
||||
private static Option<BaseError> ValidateItems(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
List<JellyfinPathReplacementItem> items = request.PathReplacements ?? [];
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> Validate(UpdateJellyfinPathReplacements request) =>
|
||||
JellyfinMediaSourceMustExist(request);
|
||||
|
||||
if (items.Any(item => item is null))
|
||||
{
|
||||
return BaseError.New("Path replacement items must not be null.");
|
||||
}
|
||||
|
||||
if (items.Any(item => string.IsNullOrWhiteSpace(item.JellyfinPath) || string.IsNullOrWhiteSpace(item.LocalPath)))
|
||||
{
|
||||
return BaseError.New("Each path replacement requires a non-blank Jellyfin path and local path.");
|
||||
}
|
||||
|
||||
var existingIds = (jellyfinMediaSource.PathReplacements ?? new List<JellyfinPathReplacement>())
|
||||
.Map(pr => pr.Id)
|
||||
.ToList();
|
||||
var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id))
|
||||
.Map(item => item.Id)
|
||||
.ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Jellyfin media source " +
|
||||
$"{request.JellyfinMediaSourceId}.");
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
|
||||
UpdateJellyfinPathReplacements request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public abstract class CallLibraryScannerHandler<TRequest>(
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger logger)
|
||||
{
|
||||
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/v1/scan/{scanId}";
|
||||
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/scan/{scanId}";
|
||||
|
||||
protected async Task<Either<BaseError, string>> PerformScan(
|
||||
ScanParameters parameters,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -15,18 +14,15 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public CreateLocalLibraryHandler(
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IFileSystem fileSystem,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_fileSystem = fileSystem;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
@@ -35,7 +31,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(_fileSystem, dbContext, request);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
@@ -48,30 +44,18 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(localLibrary.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if the enqueue fails
|
||||
// after we acquired the lock, release it here or it is held forever.
|
||||
_entityLocker.UnlockLibrary(localLibrary.Id);
|
||||
throw;
|
||||
}
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(localLibrary);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> Validate(
|
||||
IFileSystem fileSystem,
|
||||
TvContext dbContext,
|
||||
CreateLocalLibrary request) =>
|
||||
MediaSourceMustExist(dbContext, request)
|
||||
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
|
||||
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -15,28 +14,6 @@ public abstract class LocalLibraryHandlerBase
|
||||
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
|
||||
.Map(_ => localLibrary).AsTask();
|
||||
|
||||
/// <summary>
|
||||
/// Validates that every NEW path (<c>Id < 1</c> — see design #202 §C2) exists on the
|
||||
/// filesystem. Existing rows are exempt: an unmounted share must not block saving a rename,
|
||||
/// matching Blazor's behavior of only checking existence when a path is added.
|
||||
/// </summary>
|
||||
protected static Task<Validation<BaseError, LocalLibrary>> NewPathsMustExist(
|
||||
IFileSystem fileSystem,
|
||||
LocalLibrary localLibrary)
|
||||
{
|
||||
List<string> missing = localLibrary.Paths
|
||||
.Filter(p => p.Id < 1)
|
||||
.Filter(p => !fileSystem.Directory.Exists(p.Path))
|
||||
.Map(p => p.Path)
|
||||
.ToList();
|
||||
|
||||
Validation<BaseError, LocalLibrary> result = missing.Count == 0
|
||||
? Success<BaseError, LocalLibrary>(localLibrary)
|
||||
: Fail<BaseError, LocalLibrary>($"Path(s) do not exist on the filesystem: {string.Join(", ", missing)}");
|
||||
|
||||
return result.AsTask();
|
||||
}
|
||||
|
||||
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
|
||||
TvContext dbContext,
|
||||
LocalLibrary localLibrary,
|
||||
|
||||
@@ -79,31 +79,10 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Parameters> parameters =
|
||||
(await LibraryPathMustExist(dbContext, request, cancellationToken),
|
||||
await LocalLibraryMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
|
||||
return parameters
|
||||
.Bind(TargetLibraryMustDiffer)
|
||||
.Bind(TargetLibraryMustMatchMediaKind);
|
||||
}
|
||||
|
||||
// Blazor's move dialog filters the target-library picker to same-kind, source-excluded
|
||||
// libraries only (MoveLocalLibraryPathDialog.razor:82); an API/MCP client bypasses that
|
||||
// client-side filter today, so #202 moves both invariants into the handler (design #202 §C5,
|
||||
// finding 3).
|
||||
private static Validation<BaseError, Parameters> TargetLibraryMustDiffer(Parameters parameters) =>
|
||||
parameters.LibraryPath.LibraryId == parameters.Library.Id
|
||||
? Fail<BaseError, Parameters>("Target library must be different from the source path's current library")
|
||||
: Success<BaseError, Parameters>(parameters);
|
||||
|
||||
private static Validation<BaseError, Parameters> TargetLibraryMustMatchMediaKind(Parameters parameters) =>
|
||||
parameters.LibraryPath.Library.MediaKind != parameters.Library.MediaKind
|
||||
? Fail<BaseError, Parameters>("Target library must have the same media kind as the source path's library")
|
||||
: Success<BaseError, Parameters>(parameters);
|
||||
CancellationToken cancellationToken) =>
|
||||
(await LibraryPathMustExist(dbContext, request, cancellationToken),
|
||||
await LocalLibraryMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
|
||||
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public enum QueueLibraryScanResult
|
||||
{
|
||||
Queued,
|
||||
NotFound,
|
||||
SyncDisabled,
|
||||
AlreadyScanning
|
||||
}
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId, bool DeepScan = false) : IRequest<QueueLibraryScanResult>;
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;
|
||||
|
||||
@@ -17,11 +17,9 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker,
|
||||
ILogger<QueueLibraryScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueLibraryScanByLibraryId, QueueLibraryScanResult>
|
||||
: IRequestHandler<QueueLibraryScanByLibraryId, bool>
|
||||
{
|
||||
public async Task<QueueLibraryScanResult> Handle(
|
||||
QueueLibraryScanByLibraryId request,
|
||||
CancellationToken cancellationToken)
|
||||
public async Task<bool> Handle(QueueLibraryScanByLibraryId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
@@ -42,17 +40,10 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
if (!shouldSyncItems)
|
||||
{
|
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
|
||||
return QueueLibraryScanResult.SyncDisabled;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A true from LockLibrary confers ownership of exactly one release; a false means a scan
|
||||
// is already in progress and we own no release.
|
||||
if (!locker.LockLibrary(library.Id))
|
||||
{
|
||||
return QueueLibraryScanResult.AlreadyScanning;
|
||||
}
|
||||
|
||||
try
|
||||
if (locker.LockLibrary(library.Id))
|
||||
{
|
||||
logger.LogDebug("Queued library scan for library id {Id}", library.Id);
|
||||
|
||||
@@ -66,7 +57,7 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizePlexLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizePlexLibraryById(library.Id, request.DeepScan),
|
||||
new ForceSynchronizePlexLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
case JellyfinLibrary:
|
||||
@@ -74,7 +65,7 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizeJellyfinLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeJellyfinLibraryById(library.Id, request.DeepScan),
|
||||
new ForceSynchronizeJellyfinLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
case EmbyLibrary:
|
||||
@@ -82,23 +73,15 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizeEmbyLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeEmbyLibraryById(library.Id, request.DeepScan),
|
||||
new ForceSynchronizeEmbyLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if enqueueing fails
|
||||
// (e.g. request aborted / channel completed) after we acquired the lock, release
|
||||
// it here or it is held forever (EnqueueWithTraktLock pattern).
|
||||
locker.UnlockLibrary(library.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
return QueueLibraryScanResult.Queued;
|
||||
return true;
|
||||
}
|
||||
|
||||
return QueueLibraryScanResult.NotFound;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public enum QueueShowScanResult
|
||||
{
|
||||
Queued,
|
||||
NotFound,
|
||||
Unsupported,
|
||||
SyncDisabled,
|
||||
AlreadyScanning,
|
||||
ScanFailed
|
||||
}
|
||||
|
||||
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan)
|
||||
: IRequest<QueueShowScanResult>;
|
||||
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>;
|
||||
|
||||
@@ -19,9 +19,9 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
IMediator mediator,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<QueueShowScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueShowScanByLibraryId, QueueShowScanResult>
|
||||
: IRequestHandler<QueueShowScanByLibraryId, bool>
|
||||
{
|
||||
public async Task<QueueShowScanResult> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
|
||||
public async Task<bool> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
@@ -42,14 +42,14 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
if (!shouldSyncItems)
|
||||
{
|
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
|
||||
return QueueShowScanResult.SyncDisabled;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A false from LockLibrary means a scan is already in progress; we own no release.
|
||||
// Check if library is already being scanned - return false if locked
|
||||
if (!locker.LockLibrary(library.Id))
|
||||
{
|
||||
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id);
|
||||
return QueueShowScanResult.AlreadyScanning;
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
@@ -60,43 +60,41 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
|
||||
try
|
||||
{
|
||||
QueueShowScanResult outcome;
|
||||
var success = false;
|
||||
switch (library)
|
||||
{
|
||||
case PlexLibrary:
|
||||
Either<BaseError, string> plexResult = await mediator.Send(
|
||||
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
outcome = plexResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
success = plexResult.IsRight;
|
||||
break;
|
||||
case JellyfinLibrary:
|
||||
Either<BaseError, string> jellyfinResult = await mediator.Send(
|
||||
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
outcome = jellyfinResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
success = jellyfinResult.IsRight;
|
||||
break;
|
||||
case EmbyLibrary:
|
||||
Either<BaseError, string> embyResult = await mediator.Send(
|
||||
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
outcome = embyResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
success = embyResult.IsRight;
|
||||
break;
|
||||
case LocalLibrary:
|
||||
logger.LogWarning("Single show scanning is not supported for local libraries");
|
||||
outcome = QueueShowScanResult.Unsupported;
|
||||
break;
|
||||
default:
|
||||
logger.LogWarning("Unknown library type for library {Id}", library.Id);
|
||||
outcome = QueueShowScanResult.Unsupported;
|
||||
break;
|
||||
}
|
||||
|
||||
if (outcome == QueueShowScanResult.Queued && request.DeepScan)
|
||||
if (success && request.DeepScan)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken);
|
||||
}
|
||||
|
||||
return outcome;
|
||||
return success;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -105,6 +103,6 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
}
|
||||
}
|
||||
|
||||
return QueueShowScanResult.NotFound;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
@@ -18,20 +17,17 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public UpdateLocalLibraryHandler(
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IFileSystem fileSystem,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_fileSystem = fileSystem;
|
||||
_searchIndex = searchIndex;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
@@ -41,8 +37,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation =
|
||||
await Validate(_fileSystem, dbContext, request, cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
|
||||
@@ -101,17 +96,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if the enqueue fails
|
||||
// after we acquired the lock, release it here or it is held forever.
|
||||
_entityLocker.UnlockLibrary(existing.Id);
|
||||
throw;
|
||||
}
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,15 +104,13 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> Validate(
|
||||
IFileSystem fileSystem,
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken) =>
|
||||
LocalLibraryMustExist(dbContext, request, cancellationToken)
|
||||
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
|
||||
.BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
|
||||
.MapT(_ => parameters))
|
||||
.BindT(parameters => NewPathsMustExist(fileSystem, parameters.Incoming).MapT(_ => parameters));
|
||||
.MapT(_ => parameters));
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Flurl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.LibraryBrowse;
|
||||
|
||||
// Shared MediaItem -> LibraryBrowseItemResponseModel projection used by both the library-browse
|
||||
// search handler and the collection-items handler (#155). Keeping the per-kind hydration and the
|
||||
// rooted-artwork logic in one place avoids duplicating the Blazor-vs-SPA artwork rooting rules
|
||||
// (see the Artwork helper below and docs/api-conventions.md §4).
|
||||
internal static class LibraryBrowseItemMapper
|
||||
{
|
||||
// Hydrates an arbitrary set of media item ids (any kinds mixed) into response models. MediaItem
|
||||
// ids are globally unique across kinds, so passing the full id list to every per-kind query is
|
||||
// safe: each query only matches its own kind. Callers order/page the result themselves.
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItemsByIds(
|
||||
TvContext dbContext,
|
||||
IReadOnlyList<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var idList = ids.Distinct().ToList();
|
||||
|
||||
var results = new List<LibraryBrowseItemResponseModel>();
|
||||
results.AddRange(await GetMovies(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetShows(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetSeasons(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetArtists(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetEpisodes(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetMusicVideos(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetSongs(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetOtherVideos(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetImages(dbContext, idList, cancellationToken));
|
||||
results.AddRange(await GetRemoteStreams(dbContext, idList, cancellationToken));
|
||||
return results;
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Where(mm => ids.Contains(mm.MovieId))
|
||||
.Include(mm => mm.Artwork)
|
||||
.Include(mm => mm.Movie)
|
||||
.ThenInclude(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(mm => mm.Movie)
|
||||
.ThenInclude(m => m.MediaVersions)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(mm => mm.MovieId)
|
||||
.Select(g => g.OrderBy(mm => mm.Id).First())
|
||||
.Map(mm => new LibraryBrowseItemResponseModel(
|
||||
mm.MovieId,
|
||||
LibraryBrowseMediaType.Movie,
|
||||
mm.Title ?? string.Empty,
|
||||
mm.Movie.LibraryPath.LibraryId,
|
||||
mm.Movie.LibraryPath.Library.Name,
|
||||
Artwork(mm, ArtworkKind.Poster),
|
||||
BestDuration(mm.Movie.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
mm.MovieId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Where(e => ids.Contains(e.Season.ShowId))
|
||||
.GroupBy(e => e.Season.ShowId)
|
||||
.Select(g => new { ShowId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => ids.Contains(sm.ShowId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.Include(sm => sm.Show)
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(sm => sm.ShowId)
|
||||
.Select(g => g.OrderBy(sm => sm.Id).First())
|
||||
.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
sm.ShowId,
|
||||
LibraryBrowseMediaType.TelevisionShow,
|
||||
sm.Title ?? string.Empty,
|
||||
sm.Show.LibraryPath.LibraryId,
|
||||
sm.Show.LibraryPath.Library.Name,
|
||||
Artwork(sm, ArtworkKind.Poster),
|
||||
null,
|
||||
counts.TryGetValue(sm.ShowId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.TelevisionShow,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sm.ShowId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Where(e => ids.Contains(e.SeasonId))
|
||||
.GroupBy(e => e.SeasonId)
|
||||
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => ids.Contains(sm.SeasonId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.Include(sm => sm.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(shm => shm.Artwork)
|
||||
.Include(sm => sm.Season)
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(sm => sm.SeasonId)
|
||||
.Select(g => g.OrderBy(sm => sm.Id).First())
|
||||
.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
sm.SeasonId,
|
||||
LibraryBrowseMediaType.TelevisionSeason,
|
||||
SeasonTitle(sm),
|
||||
sm.Season.LibraryPath.LibraryId,
|
||||
sm.Season.LibraryPath.Library.Name,
|
||||
SeasonArtwork(sm),
|
||||
null,
|
||||
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.TelevisionSeason,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sm.SeasonId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, int> counts = await dbContext.MusicVideos
|
||||
.AsNoTracking()
|
||||
.Where(mv => ids.Contains(mv.ArtistId))
|
||||
.GroupBy(mv => mv.ArtistId)
|
||||
.Select(g => new { ArtistId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken);
|
||||
|
||||
return await dbContext.ArtistMetadata
|
||||
.AsNoTracking()
|
||||
.Where(am => ids.Contains(am.ArtistId))
|
||||
.Include(am => am.Artwork)
|
||||
.Include(am => am.Artist)
|
||||
.ThenInclude(a => a.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(am => am.ArtistId)
|
||||
.Select(g => g.OrderBy(am => am.Id).First())
|
||||
.Map(am => new LibraryBrowseItemResponseModel(
|
||||
am.ArtistId,
|
||||
LibraryBrowseMediaType.Artist,
|
||||
am.Title ?? string.Empty,
|
||||
am.Artist.LibraryPath.LibraryId,
|
||||
am.Artist.LibraryPath.Library.Name,
|
||||
Artwork(am, ArtworkKind.Thumbnail),
|
||||
null,
|
||||
counts.TryGetValue(am.ArtistId, out int count) ? count : 0,
|
||||
null,
|
||||
CollectionType.Artist,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
am.ArtistId,
|
||||
null)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetEpisodes(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Where(em => ids.Contains(em.EpisodeId))
|
||||
.Include(em => em.Artwork)
|
||||
.Include(em => em.Episode)
|
||||
.ThenInclude(e => e.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(em => em.Episode)
|
||||
.ThenInclude(e => e.MediaVersions)
|
||||
.Include(em => em.Episode)
|
||||
.ThenInclude(e => e.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(sh => sh.ShowMetadata)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(em => em.EpisodeId)
|
||||
.Select(g => g.OrderBy(em => em.Id).First())
|
||||
.Map(em => new LibraryBrowseItemResponseModel(
|
||||
em.EpisodeId,
|
||||
LibraryBrowseMediaType.Episode,
|
||||
em.Title ?? string.Empty,
|
||||
em.Episode.LibraryPath.LibraryId,
|
||||
em.Episode.LibraryPath.Library.Name,
|
||||
ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||
BestDuration(em.Episode.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.Episode,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
em.EpisodeId,
|
||||
null,
|
||||
EpisodeSubtitle(em),
|
||||
em.Episode.SeasonId)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.MusicVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Where(mvm => ids.Contains(mvm.MusicVideoId))
|
||||
.Include(mvm => mvm.Artwork)
|
||||
.Include(mvm => mvm.MusicVideo)
|
||||
.ThenInclude(mv => mv.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(mvm => mvm.MusicVideo)
|
||||
.ThenInclude(mv => mv.MediaVersions)
|
||||
.Include(mvm => mvm.MusicVideo)
|
||||
.ThenInclude(mv => mv.Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(mvm => mvm.MusicVideoId)
|
||||
.Select(g => g.OrderBy(mvm => mvm.Id).First())
|
||||
.Map(mvm => new LibraryBrowseItemResponseModel(
|
||||
mvm.MusicVideoId,
|
||||
LibraryBrowseMediaType.MusicVideo,
|
||||
mvm.Title ?? string.Empty,
|
||||
mvm.MusicVideo.LibraryPath.LibraryId,
|
||||
mvm.MusicVideo.LibraryPath.Library.Name,
|
||||
ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||
BestDuration(mvm.MusicVideo.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.MusicVideo,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
mvm.MusicVideoId,
|
||||
null,
|
||||
MusicVideoSubtitle(mvm))).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetSongs(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.SongMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => ids.Contains(sm.SongId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.Include(sm => sm.Song)
|
||||
.ThenInclude(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(sm => sm.Song)
|
||||
.ThenInclude(s => s.MediaVersions)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(sm => sm.SongId)
|
||||
.Select(g => g.OrderBy(sm => sm.Id).First())
|
||||
.Map(sm => new LibraryBrowseItemResponseModel(
|
||||
sm.SongId,
|
||||
LibraryBrowseMediaType.Song,
|
||||
sm.Title ?? string.Empty,
|
||||
sm.Song.LibraryPath.LibraryId,
|
||||
sm.Song.LibraryPath.Library.Name,
|
||||
ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||
BestDuration(sm.Song.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.Song,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sm.SongId,
|
||||
null,
|
||||
SongSubtitle(sm))).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetOtherVideos(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.OtherVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Where(ovm => ids.Contains(ovm.OtherVideoId))
|
||||
.Include(ovm => ovm.Artwork)
|
||||
.Include(ovm => ovm.OtherVideo)
|
||||
.ThenInclude(ov => ov.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(ovm => ovm.OtherVideo)
|
||||
.ThenInclude(ov => ov.MediaVersions)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(ovm => ovm.OtherVideoId)
|
||||
.Select(g => g.OrderBy(ovm => ovm.Id).First())
|
||||
.Map(ovm => new LibraryBrowseItemResponseModel(
|
||||
ovm.OtherVideoId,
|
||||
LibraryBrowseMediaType.OtherVideo,
|
||||
ovm.Title ?? string.Empty,
|
||||
ovm.OtherVideo.LibraryPath.LibraryId,
|
||||
ovm.OtherVideo.LibraryPath.Library.Name,
|
||||
ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||
BestDuration(ovm.OtherVideo.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.OtherVideo,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ovm.OtherVideoId,
|
||||
null,
|
||||
string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetImages(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.ImageMetadata
|
||||
.AsNoTracking()
|
||||
.Where(im => ids.Contains(im.ImageId))
|
||||
.Include(im => im.Artwork)
|
||||
.Include(im => im.Image)
|
||||
.ThenInclude(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(im => im.Image)
|
||||
.ThenInclude(i => i.MediaVersions)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(im => im.ImageId)
|
||||
.Select(g => g.OrderBy(im => im.Id).First())
|
||||
.Map(im => new LibraryBrowseItemResponseModel(
|
||||
im.ImageId,
|
||||
LibraryBrowseMediaType.Image,
|
||||
im.Title ?? string.Empty,
|
||||
im.Image.LibraryPath.LibraryId,
|
||||
im.Image.LibraryPath.Library.Name,
|
||||
ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail),
|
||||
BestDuration(im.Image.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.Image,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
im.ImageId,
|
||||
null,
|
||||
string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetRemoteStreams(
|
||||
TvContext dbContext,
|
||||
List<int> ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await dbContext.RemoteStreamMetadata
|
||||
.AsNoTracking()
|
||||
.Where(rsm => ids.Contains(rsm.RemoteStreamId))
|
||||
.Include(rsm => rsm.Artwork)
|
||||
.Include(rsm => rsm.RemoteStream)
|
||||
.ThenInclude(rs => rs.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(rsm => rsm.RemoteStream)
|
||||
.ThenInclude(rs => rs.MediaVersions)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list
|
||||
.GroupBy(rsm => rsm.RemoteStreamId)
|
||||
.Select(g => g.OrderBy(rsm => rsm.Id).First())
|
||||
.Map(rsm => new LibraryBrowseItemResponseModel(
|
||||
rsm.RemoteStreamId,
|
||||
LibraryBrowseMediaType.RemoteStream,
|
||||
rsm.Title ?? string.Empty,
|
||||
rsm.RemoteStream.LibraryPath.LibraryId,
|
||||
rsm.RemoteStream.LibraryPath.Library.Name,
|
||||
ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
|
||||
BestDuration(rsm.RemoteStream.MediaVersions),
|
||||
1,
|
||||
null,
|
||||
CollectionType.RemoteStream,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
rsm.RemoteStreamId,
|
||||
null,
|
||||
string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList());
|
||||
}
|
||||
|
||||
public static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
|
||||
{
|
||||
TimeSpan duration = versions
|
||||
.Select(v => v.Duration)
|
||||
.Where(d => d > TimeSpan.Zero)
|
||||
.DefaultIfEmpty()
|
||||
.Max();
|
||||
return duration > TimeSpan.Zero ? duration : null;
|
||||
}
|
||||
|
||||
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
|
||||
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, but the SPA
|
||||
// renders the value raw from under /app/, so the API must root the URL itself (issue #180).
|
||||
public static string Artwork(Metadata metadata, ArtworkKind artworkKind)
|
||||
{
|
||||
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artwork))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard).
|
||||
if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters";
|
||||
|
||||
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
|
||||
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
||||
{
|
||||
url.SetQueryParam("fillHeight", 440);
|
||||
}
|
||||
|
||||
return $"/artwork/{folder}/{url}";
|
||||
}
|
||||
|
||||
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
|
||||
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
||||
{
|
||||
url.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
return $"/artwork/{folder}/{url}";
|
||||
}
|
||||
|
||||
return $"/artwork/{folder}/{artwork}";
|
||||
}
|
||||
|
||||
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
|
||||
{
|
||||
string artwork = Artwork(metadata, primary);
|
||||
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
|
||||
}
|
||||
|
||||
private static string SeasonTitle(SeasonMetadata metadata)
|
||||
{
|
||||
string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Map(sm => sm.Title ?? string.Empty)
|
||||
.IfNone(string.Empty);
|
||||
string seasonTitle = metadata.Season.SeasonNumber == 0
|
||||
? "Specials"
|
||||
: $"Season {metadata.Season.SeasonNumber}";
|
||||
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
|
||||
}
|
||||
|
||||
// Seasons often have no poster of their own; fall back to the parent show's poster (issue #180).
|
||||
private static string SeasonArtwork(SeasonMetadata metadata)
|
||||
{
|
||||
string artwork = Artwork(metadata, ArtworkKind.Poster);
|
||||
if (!string.IsNullOrWhiteSpace(artwork))
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
return metadata.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty);
|
||||
}
|
||||
|
||||
private static string EpisodeSubtitle(EpisodeMetadata metadata)
|
||||
{
|
||||
string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone()
|
||||
.Map(sm => sm.Title ?? string.Empty)
|
||||
.IfNone(string.Empty);
|
||||
int seasonNumber = metadata.Episode.Season.SeasonNumber;
|
||||
string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}";
|
||||
return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}";
|
||||
}
|
||||
|
||||
private static string MusicVideoSubtitle(MusicVideoMetadata metadata)
|
||||
{
|
||||
string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone()
|
||||
.Map(am => am.Title ?? string.Empty)
|
||||
.IfNone(string.Empty);
|
||||
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
|
||||
if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
return $"{artist} - {album}";
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(artist) ? album : artist;
|
||||
}
|
||||
|
||||
private static string SongSubtitle(SongMetadata metadata)
|
||||
{
|
||||
string artists = string.Join(", ", metadata.Artists ?? []);
|
||||
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
|
||||
if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
return $"{artists} - {album}";
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(artists) ? album : artists;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user