Compare commits

..
5 Commits
Author SHA1 Message Date
timothy 65d88b5167 test(#289): pin BoundedLineReader cap/CRLF boundaries + document per-segment path assumption
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Non-blocking polish from the fix-commit re-review (verdict MERGEABLE):
- BuildPath: comment the assumption that each {param} is its own path segment
  (revalidate the whole path if a template ever concatenates adjacent params).
- Two additive BoundedLineReader tests: exactly-at-cap line returned intact
  (inclusive max), and \r\n carriage-return stripping. No logic change.

36 tests (was 34).

Refs #289
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:24:35 +02:00
timothy 4f68805d9a security(#289): review fixes — body-read timeout, transport-error response, bounded stdin, env clamps
Folds in findings from the independent reviews of the initial hardening diff
(a cold adversarial pass + a Codex pass — complementary catches).

Fork (HIGH/MED):
- Body-read timeout regression: ResponseHeadersRead moved the body read outside
  HttpClient.Timeout and it used CancellationToken.None, so a slow-drip upstream
  hung the single-threaded session. Now a per-request linked CTS (options
  .EffectiveRequestTimeout) covers headers + body; HttpClient.Timeout set to
  Infinite so one timer owns it. Verified live against a black-hole upstream:
  1s timeout → -32603 in ~1s, no hang.
- Transport/timeout exceptions escaped HandleAsync's catch filter → no response
  for the id → client hangs. Added a broad final catch → -32603 when hasId.

Codex (env/input robustness):
- ERSATZTV_REQUEST_TIMEOUT_SECONDS / ERSATZTV_MAX_RESPONSE_BYTES at int.MaxValue
  crashed at startup / overflowed `cap+1` to a negative alloc. ParseInt now
  clamps to [min,max]; the client also hard-ceils the cap (MaxAllowedResponseBytes).
- A ".."/"." path param collapsed the URL onto another route under Uri
  canonicalization — rejected in BuildPath.

Both reviewers (stdin OOM): ReadLineAsync buffered an unbounded line before any
guard. New BoundedLineReader drains+drops oversized lines (memory-bounded),
keeping subsequent lines aligned.

Nits: case-insensitive ParseBool fallback; UTF-8 boundary backoff so a mid-
codepoint truncation doesn't emit U+FFFD; renamed a misleading test.

34 tests (was 26); +8 covering the transport-error path, dot-segment rejection,
cap-overflow fallback, UTF-8 seam, and the bounded reader.

Refs #289 #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:20:10 +02:00
timothyandClaude Opus 4.8 55fc210385 security(#289): harden MCP server (read-only posture, JSON-DoS, response cap, arg validation)
From the #197 cold review's MCP-consumer pass. Hardens `ErsatzTV.Mcp` (PR #76)
before it can go live against a soon-remote-exposed API.

- HIGH verb-guard: runtime read-only *posture* (ERSATZTV_ALLOW_WRITES, default
  false) enforced in the executor — a wrong catalog entry can't mutate/delete,
  and future write tools (#58) slot in behind the opt-in. Not a hardcoded
  GET-only clamp, so the non-read-only final design stays reachable.
- HIGH JSON-crash DoS: guard JsonDocument.Parse in HandleAsync (-32700, id null),
  handle missing/non-string method (-32600) instead of throwing, and wrap the
  Program.Main read loop so one bad stdin line can never kill the session.
- MED unbounded response / LOW no-timeout: cap the buffered body
  (ERSATZTV_MAX_RESPONSE_BYTES, truncation marker) + explicit HttpClient.Timeout.
- MED args unvalidated: ToolArgumentValidator checks caller args against each
  tool's InputSchema (required present, no unknown args, basic types) before a
  request is built.
- MED prompt injection: documented untrusted-data posture in docs/mcp.md.
- LOW base-URL prefix: preserve a reverse-proxy path prefix in the URL join.

Also documents that ERSATZTV_API_KEY is now effectively required (Bundle A gates
all /api reads behind X-Api-Key). 14 new tests (26 total); live stdin smoke
confirms malformed/no-method/non-object lines no longer crash the loop.

Refs #289 #197 #58

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:08:07 +02:00
timothy d1c04030af chore: retrigger CI (MySQL service port collision with concurrent run)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:58:29 +02:00
timothy 945d108334 feat(mcp): add read-only API server foundation refs #58
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:44:44 +02:00
1027 changed files with 42464 additions and 122511 deletions
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
# ersatztv#303 H9 — docs/decisions.md is append-only. This blocks a commit / PR that DELETES or
# MODIFIES an existing line of that file; pure INSERTIONS anywhere are always allowed (adding a new
# entry inserts a TOC line near the top AND appends a block at the bottom — both are insertions, so
# numstat reports 0 deleted lines). A genuine factual fix to a past entry is the one legitimate edit:
# put the literal token [decisions-edit] in the commit message to override.
#
# Fail-open: any tooling trouble (unknown mode, non-numeric numstat, missing refs) -> allow. The point
# is to catch the accidental rewrite-history case, never to wedge a legitimate commit.
#
# Assumes decisions.md ends with a trailing newline (it does; .editorconfig enforces it). If that final
# newline were ever dropped, git would render the next append as a modify of the last line (deleted=1)
# and this would false-block the append until the author adds [decisions-edit] — cheap and self-correcting.
#
# Modes:
# staged <msgfile> pre-commit/commit-msg — staged diff vs HEAD; trailer read from <msgfile>
# range <base> <head> CI (PR) — merge-base diff base...head; trailer scanned across base..head msgs
set -euo pipefail
FILE="docs/decisions.md"
mode="${1:-}"
case "$mode" in
staged)
deleted=$(git diff --cached --numstat -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(cat "${2:-/dev/null}" 2>/dev/null || true)
;;
range)
base="${2:-}"; head="${3:-}"
[ -n "$base" ] && [ -n "$head" ] || exit 0 # missing refs -> fail-open
deleted=$(git diff --numstat "$base...$head" -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
msg=$(git log --format='%B' "$base..$head" 2>/dev/null || true)
;;
*)
exit 0 # unknown mode -> fail-open
;;
esac
# Empty (no change to the file) or '-' (binary) -> treat as 0 (fail-open / nothing to guard).
deleted="${deleted:-0}"
case "$deleted" in ''|*[!0-9]*) deleted=0 ;; esac
[ "$deleted" -gt 0 ] || exit 0 # pure insertion / no change -> allow
# Explicit override for a documented factual fix.
if printf '%s' "$msg" | grep -qiF '[decisions-edit]'; then
exit 0
fi
{
echo "decisions-guard (ersatztv#303 H9): docs/decisions.md is append-only — this change deletes/modifies ${deleted} existing line(s)."
echo " Append new entries at the bottom (plus a TOC line in the Index); do not rewrite settled entries."
echo " To fix a genuine factual error in a past entry, add the token [decisions-edit] to the commit message."
} >&2
exit 1
@@ -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
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env bash
# Husky pre-push backstop for ersatztv#303 H6 — the fast-forward-to-main path the Claude merge
# hook (pretooluse-merge-consent.sh) can't see. Reads git's pre-push ref lines on stdin; for a push
# to main it scans the pushed commits for a Gitea close-keyword (`fixes #N`), and if the linked
# issue's "## Done-when" checklist still has unticked boxes it BLOCKS the push.
#
# A git hook has no interactive "ask", so this is deliberately fail-OPEN: it only blocks when it can
# positively prove an unticked box (creds present, issue fetched, non-docs change). No creds, Gitea
# unreachable, docs-only diff, or no linked issue -> allow (a loud warning at most). The authoritative
# gate is the merge hook; this just catches a direct `git push origin main`.
#
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
set -euo pipefail
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
refs=$(cat || true)
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
exit 0 # can't verify -> fail-open (the merge hook is the real gate)
fi
gq() {
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$1" 2>/dev/null || true
else
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$1" 2>/dev/null || true
fi
}
zero=0000000000000000000000000000000000000000
blocked=""
while read -r localref localsha remoteref remotesha; do
[ "$remoteref" = "refs/heads/main" ] || continue
[ "$localsha" = "$zero" ] && continue # branch deletion
# Commit range being pushed. New branch (remotesha all-zero) -> just the tip, don't rescan history.
if [ "$remotesha" = "$zero" ]; then range="$localsha -1"; else range="$remotesha..$localsha"; fi
msgs=$(git log --format='%B' $range 2>/dev/null || true)
issues=$(printf '%s' "$msgs" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
[ -n "$issues" ] || continue
# Docs-only exemption over the pushed range.
changed=$(git diff --name-only $range 2>/dev/null || true)
if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
continue
fi
for n in $issues; do
ibody=$(gq "repos/timothy/ersatztv/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
[ -n "$ibody" ] || continue # can't fetch -> fail-open
unchecked=$(printf '%s\n' "$ibody" | awk '
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
grab && /^##[[:space:]]/ {grab=0}
grab {print}' | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
if [ "${unchecked:-0}" -gt 0 ]; then
blocked="${blocked} - issue #$n has $unchecked unticked ## Done-when box(es)\n"
fi
done
done <<EOF
$refs
EOF
if [ -n "$blocked" ]; then
printf 'husky - H6 merge-consent (ersatztv#303): push to main BLOCKED\n' >&2
printf '%b' "$blocked" >&2
printf 'Finish/tick every Done-when criterion (incl. adversarial review) first, or push a docs-only change.\n' >&2
exit 1
fi
exit 0
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# H11 (ersatztv#311) — refuse to push a branch that is BEHIND origin/main: rebase first, do NOT
# merge main in. A merge commit drags in files you never touched (e.g. the ~2500 legacy-BOM .cs),
# which then trips the pre-commit `dotnet format` hook on code that isn't yours (the #309 session).
# Rebasing keeps your diff to exactly what you changed.
#
# Fail-OPEN on anything we can't decide (a git pre-push hook has no "ask"): not a git repo,
# offline / fetch fails, no origin/main, HEAD unresolved -> allow the push. The only hard block is
# a positively-proven "behind origin/main". Deliberate exception: ETV_SKIP_REBASE_CHECK=1.
set -uo pipefail
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
# Best-effort fetch of the latest main; offline / no network -> don't block.
git fetch origin main --quiet 2>/dev/null || exit 0
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
# Pushing main itself, or a branch already rebased on top of it, means origin/main is an ANCESTOR
# of HEAD -> nothing to rebase, allow.
if git merge-base --is-ancestor origin/main HEAD 2>/dev/null; then
exit 0
fi
behind=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo '?')
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
echo "husky - push blocked (H11): '$branch' is behind origin/main by $behind commit(s)."
echo " Rebase before pushing — do NOT merge main in (a merge drags in files you didn't touch,"
echo " e.g. legacy-BOM .cs, and trips the format hook on code that isn't yours):"
echo " git fetch origin main && git rebase origin/main"
echo " Deliberate exception: ETV_SKIP_REBASE_CHECK=1 git push"
exit 1
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env bash
# PreToolUse / Agent (subagent spawn) — RAM-gate the fan-out.
# The historic 8-9-way crash was RAM starvation, not CPU load; gate on FREE RAM.
# Fail-open: if memory_pressure is unavailable/unparsable → allow.
set -euo pipefail
free=$(memory_pressure -Q 2>/dev/null | grep -oE 'free percentage: [0-9]+' | grep -oE '[0-9]+' || true)
[ -z "${free:-}" ] && exit 0
if [ "$free" -lt 10 ]; then
jq -n --arg f "$free" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("Free RAM \($f)% (<10%): do NOT spawn more agents — the historic crash was RAM starvation from an 8-9-way fan-out. Wait for memory_pressure -Q to recover, then retry.")}}'
elif [ "$free" -lt 20 ]; then
jq -n --arg f "$free" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:("Free RAM \($f)% (<20%): near the fan-out ceiling. Confirm before adding another build/implementer agent (read-only recon agents are cheap).")}}'
fi
exit 0
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
# PreToolUse / Bash — deny commands that violate a HARD RULE.
# Fail-open: any parse trouble → allow (exit 0 with no output).
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
# Match an actual env ASSIGNMENT in COMMAND POSITION — line start or right after a shell
# separator (; && || | ( ), optionally `export`. This deliberately does NOT match the name
# when it sits inside a quoted string (echo, git commit -m, jq test payloads), where the
# preceding char is a quote/word, not a separator — so mentions of the rule never false-trip.
if printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*(export[[:space:]]+)?ETV_UPDATE_GOLDENS='; then
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Blocked: ETV_UPDATE_GOLDENS regenerates golden-test baselines — HARD RULE (docs/handoffs lore); never set it in a session. Update a golden deliberately and reviewed, not via a guarded run."}}'
fi
exit 0
-183
View File
@@ -1,183 +0,0 @@
#!/usr/bin/env bash
# PreToolUse / mcp__gitea__pull_request_write — derive merge consent from STATE instead of
# trusting the agent's judgment (ersatztv#303 H6 + H10). A PR merge is the one irreversible op; allow it
# only when ALL are true:
# (a) the PR's CI combined status is green, AND
# (b) every checkbox in the linked issue's "## Done-when" section is ticked, AND
# (c) a review-verdict comment on the PR references the CURRENT head sha (H10) — proving the
# LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode:
# "re-review the fix commit, not just the initial PR diff").
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
# The H10 review-verdict convention: after reviewing a PR (or its latest fix commit), post a PR
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
#
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
# - state derivable and satisfied -> grant (auto-approve: permissionDecision "allow",
# so NO redundant permission prompt fires —
# the derived state IS the consent, ersatztv#314)
# - state derivable and NOT satisfied -> deny (actionable reason)
# - state NOT derivable (no creds, Gitea down,
# no linked issue, no Done-when section) -> ask (surface to a human/session judgment)
# Only a real merge is gated; every other pull_request_write method is passed through UNTOUCHED
# (bare exit 0 → normal permissioning still applies), NOT auto-granted.
#
# WHY "grant" (not a bare exit 0) on the satisfied path (ersatztv#314 root cause): a PreToolUse hook
# that exits 0 with no JSON does NOT auto-approve — it only declines to block, so control falls through
# to the normal permission system and the raw MCP prompt still fires. The gate therefore only ever
# ADDED a deny/ask net; it never REMOVED the baseline prompt on the happy path, so a satisfied merge
# was confirmed twice (conversationally + a redundant mechanical prompt). Emitting permissionDecision
# "allow" is what actually suppresses the prompt — "derive consent from state" made real.
#
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
set -euo pipefail
input=$(cat)
decide() { # $1=grant|allow|deny|ask $2=reason
case "$1" in
# grant = the gate is SATISFIED → auto-approve so no redundant permission prompt fires.
grant) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}'; exit 0 ;;
# allow = not our concern (non-merge method) → pass through untouched; normal permissioning applies.
allow) exit 0 ;;
deny) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'; exit 0 ;;
ask) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'; exit 0 ;;
esac
}
method=$(printf '%s' "$input" | jq -r '.tool_input.method // ""' 2>/dev/null || true)
[ "$method" = "merge" ] || decide allow ""
owner=$(printf '%s' "$input" | jq -r '.tool_input.owner // ""' 2>/dev/null || true)
repo=$(printf '%s' "$input" | jq -r '.tool_input.repo // ""' 2>/dev/null || true)
pr=$(printf '%s' "$input" | jq -r '.tool_input.pull_number // ""' 2>/dev/null || true)
mwcs=$(printf '%s' "$input" | jq -r '.tool_input.merge_when_checks_succeed // false' 2>/dev/null || true)
[ -n "$owner" ] && [ -n "$repo" ] && [ -n "$pr" ] || decide ask "H6 merge gate: could not read owner/repo/pull_number from the merge call; confirm manually that CI is green and the issue's Done-when boxes are ticked."
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
# curl wrapper carrying whichever auth is configured; empty output on any failure.
gq() {
local path="$1"
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
else
return 1
fi
}
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
decide ask "H6 merge gate: no Gitea credentials in env (ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH), so CI/Done-when state can't be verified. Confirm manually that CI is green and the linked issue's Done-when boxes are all ticked, then approve."
fi
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
[ -n "$prjson" ] || decide ask "H6 merge gate: could not fetch PR #$pr from Gitea (unreachable or auth rejected). Verify CI-green + Done-when manually before merging."
sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
files=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=100" | jq -r '.[].filename // empty' 2>/dev/null || true)
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
# Docs/process-only PR: the Done-when + review-verdict gate doesn't apply — but this exemption is a
# file-TYPE bypass, NOT the a+b+c "provably reviewed & ready" proof, so it does NOT auto-grant. It
# passes through to normal permissioning (one prompt). This deliberately keeps a human in the loop for
# process-control files (.claude/ / .gitea/ / .husky/ — the gate, CI, and git hooks themselves): a PR
# that weakens the gate must not silently self-merge (ersatztv#317 review nit). Only the satisfied
# merge path below auto-grants.
decide allow "" # passthrough (exit 0 → normal prompt), NOT grant
fi
# --- Linked issue: Gitea auto-close keywords in the PR body. ---
issues=$(printf '%s' "$body" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
[ -n "$issues" ] || decide ask "H6 merge gate: PR #$pr has no linked issue (no 'fixes #N' / 'closes #N' in its body), so there is no Done-when checklist to derive consent from. Confirm the work is complete + reviewed, then approve."
# --- (b) Done-when checkboxes: every linked issue must have an all-ticked section. ---
for n in $issues; do
ibody=$(gq "repos/$owner/$repo/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
[ -n "$ibody" ] || decide ask "H6 merge gate: could not fetch linked issue #$n. Verify its Done-when checklist manually before merging."
# Slice the "## Done-when" section: from that header to the next "## " (or EOF).
section=$(printf '%s\n' "$ibody" | awk '
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
grab && /^##[[:space:]]/ {grab=0}
grab {print}')
if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
decide ask "H6 merge gate: linked issue #$n has no '## Done-when' checklist section (the merge-consent convention — see CLAUDE.md Task Completion Protocol). Add one, or confirm completion manually and approve."
fi
unchecked=$(printf '%s\n' "$section" | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
if [ "${unchecked:-0}" -gt 0 ]; then
decide deny "H6 merge gate: BLOCKED — linked issue #$n has $unchecked unticked box(es) in its ## Done-when checklist. Finish (or explicitly tick) every completion criterion — including the adversarial-review box — before merging PR #$pr."
fi
done
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
if [ "$mwcs" != "true" ]; then
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
state=$(gq "repos/$owner/$repo/commits/$sha/status" | jq -r '.state // ""' 2>/dev/null || true)
case "$state" in
success) : ;;
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
*) decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success'. Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging." ;;
esac
fi
# --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the
# CURRENT head sha, so the latest commit is proven-reviewed (ersatztv#242: re-review the fix
# commit, not just the initial diff). Graceful adoption mirrors (b): a verdict comment that
# references head must be positive -> allow; one that exists only for an OLDER commit -> deny
# (the stale-review failure mode); NO verdict comment at all -> ask (convention not yet used).
[ -n "$sha" ] || decide ask "H10 merge gate: could not resolve PR #$pr head sha to verify a review verdict. Confirm the review covered the latest commit before merging."
short=${sha:0:7}
comments=$(gq "repos/$owner/$repo/issues/$pr/comments?limit=100")
if [ -z "$comments" ]; then
decide ask "H10 merge gate: could not fetch PR #$pr comments to verify a head-referencing review verdict ($short). Confirm the adversarial/Codex review covered the latest commit before merging."
fi
# Verdict lines across all comment bodies: a real verdict line STARTS with the marker (after optional
# leading whitespace). Anchoring to line-start is deliberate — it rejects a comment that merely QUOTES
# the positive template mid-sentence (an instruction "please post: Review-verdict: MERGEABLE @ <sha>",
# or the gate's own suggestion text echoed back), which would otherwise self-approve the merge.
verdicts=$(printf '%s' "$comments" | jq -r '.[].body // empty' 2>/dev/null | grep -iE '^[[:space:]]*review-verdict:' || true)
if [ -z "$verdicts" ]; then
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve."
fi
# Classify each verdict line by the sha it references (its "@ <sha>" field) and its verdict word.
# A line references the CURRENT head iff head BEGINS WITH that sha token AND the token is >=7 chars
# (git short-sha prefix semantics) — NOT a loose substring test: an older sha that merely contains
# the head prefix, or the head prefix appearing in an unrelated URL on the line, must NOT count
# (adversarial false-opens). The verdict token must sit right after the marker on the same line.
head_pos=0; head_neg=0; stale=0
while IFS= read -r line; do
[ -n "$line" ] || continue
# The sha the line references: the hex token in its "@ <sha>" field (>=7 chars), lowercased.
ref=$(printf '%s' "$line" | grep -ioE '@[[:space:]]*[0-9a-f]{7,40}' | head -1 \
| grep -oiE '[0-9a-f]{7,40}' | tr 'A-F' 'a-f' || true)
is_pos=0
# Positive iff the line's OWN leading verdict word (right after the line-start marker) is positive —
# anchored so a second, later `review-verdict: mergeable` substring on a BLOCKED line can't flip it.
if printf '%s' "$line" | grep -iqE '^[[:space:]]*review-verdict:[[:space:]]*(mergeable|approved|lgtm)'; then is_pos=1; fi
[ -z "$ref" ] && continue # marker present but no @<sha> -> falls through to the final ask
case "$sha" in
"$ref"*) if [ "$is_pos" = 1 ]; then head_pos=1; else head_neg=1; fi ;;
*) stale=1 ;;
esac
done <<VERDICTS
$verdicts
VERDICTS
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier MERGEABLE
# on the SAME head; and if the head were fixed the sha would change, so this can't wrongly block).
if [ "$head_neg" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr."
fi
if [ "$head_pos" = 1 ]; then
# (a) CI green + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
fi
if [ "$stale" = 1 ]; then
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'."
fi
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve."
# All derivable and satisfied -> auto-grant (defensive: the head_pos branch above already exits here).
decide grant "H6/H10 merge gate: satisfied — auto-granted."
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# PreToolUse / browser-navigate — deny opening download/stream endpoints in a tab
# (they hang the MCP session; curl them instead). Fail-open on parse trouble.
set -euo pipefail
input=$(cat)
url=$(printf '%s' "$input" | jq -r '.tool_input.url // ""' 2>/dev/null || true)
if printf '%s' "$url" | grep -qE '/iptv/|\.m3u8|/artwork/|playback\.m3u8'; then
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Blocked: do not open download/stream endpoints (/iptv, .m3u8, /artwork, playback.m3u8) in a browser tab — they stall the MCP session. curl them instead (docs/handoffs lore)."}}'
fi
exit 0
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# PreToolUse / Bash — deny `git commit`/`git merge` inside a sibling worktree that
# a DIFFERENT session created (burned us twice — #289 path-leak, the plumbing-merge
# workaround exists precisely because of this). Ownership is a `.claude-worktree-owner`
# marker (session id) written at `git worktree add` time by posttooluse-worktree-marker.sh.
#
# Fail-open by design: no marker, unparsable input, or marker == this session → allow.
# So the main tree (never marked) and pre-convention worktrees (no marker) are unaffected;
# only a commit/merge into another session's marked worktree is blocked.
set -euo pipefail
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
me=$(printf '%s' "$input" | jq -r '.session_id // ""' 2>/dev/null || true)
# Only guard the state-mutating ops. Match `git commit`/`git merge` in command position
# (line start or after a shell separator) so a quoted mention never false-trips.
printf '%s' "$cmd" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git[[:space:]]+(-C[[:space:]]+[^[:space:]]+[[:space:]]+)?(commit|merge)\b' || exit 0
[ -z "$cwd" ] && cwd="$PWD"
# Determine the effective directory the git op runs in. Two common redirections in the
# lore's usage move it off the session cwd: `git -C <path>` and a leading `cd <path> &&`.
effdir="$cwd"
cpath=$(printf '%s' "$cmd" | grep -oE 'git[[:space:]]+-C[[:space:]]+[^[:space:]&|;]+' | head -1 | sed -E 's/^git[[:space:]]+-C[[:space:]]+//' | tr -d '"'"'"'' || true)
cdpath=$(printf '%s' "$cmd" | grep -oE '^[[:space:]]*cd[[:space:]]+[^[:space:]&|;]+' | head -1 | sed -E 's/^[[:space:]]*cd[[:space:]]+//' | tr -d '"'"'"'' || true)
if [ -n "${cpath:-}" ]; then
effdir="$cpath"
elif [ -n "${cdpath:-}" ]; then
effdir="$cdpath"
fi
# Resolve a relative effective dir against the session cwd.
case "$effdir" in /*) : ;; *) effdir="$cwd/$effdir" ;; esac
root=$(git -C "$effdir" rev-parse --show-toplevel 2>/dev/null || true)
[ -z "$root" ] && exit 0
marker="$root/.claude-worktree-owner"
[ -f "$marker" ] || exit 0
owner=$(tr -d '[:space:]' < "$marker" 2>/dev/null || true)
[ -z "$owner" ] && exit 0
[ "$owner" = "$me" ] && exit 0
# Marker names a DIFFERENT session → deny.
jq -n --arg o "$owner" --arg r "$root" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("Blocked: worktree \($r) is owned by session \($o), not this one. Never commit/merge inside a sibling worktree another session created (#289 path-leak, plumbing-merge workaround). Commit from your own tree; if you genuinely own this worktree now, overwrite its .claude-worktree-owner marker with your session id.")}}'
exit 0
-64
View File
@@ -1,64 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-bash-guard.sh\"",
"timeout": 10
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-worktree-guard.sh\"",
"timeout": 10
}
]
},
{
"matcher": "mcp__plugin_playwright_playwright__browser_navigate|mcp__claude-in-chrome__navigate",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-nav-guard.sh\"",
"timeout": 10
}
]
},
{
"matcher": "Agent|Task",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-agent-ram.sh\"",
"timeout": 10
}
]
},
{
"matcher": "mcp__gitea__pull_request_write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-merge-consent.sh\"",
"timeout": 15
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/posttooluse-worktree-marker.sh\"",
"timeout": 10
}
]
}
]
}
}
+11 -270
View File
@@ -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
@@ -115,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
@@ -126,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
@@ -169,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"
@@ -183,33 +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::"
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:
@@ -328,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 -8
View File
@@ -54,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/
-15
View File
@@ -1,15 +0,0 @@
# Enforce the CLAUDE.md protocol: every commit message must carry a Co-Authored-By
# trailer. Merge commits are exempt (their MERGE_MSG has no trailer and shouldn't be
# rewritten).
if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
exit 0
fi
grep -q '^Co-Authored-By:' "$1" || {
echo 'husky - commit message missing Co-Authored-By trailer'
exit 1
}
# H9 (ersatztv#303) — docs/decisions.md is append-only. Block a commit that rewrites a settled
# entry unless the message carries [decisions-edit]. commit-msg runs after the index is final, so
# the staged diff is what's being committed; the message file ($1) supplies the override token.
./.claude/hooks/decisions-guard.sh staged "$1" || exit 1
-25
View File
@@ -1,25 +0,0 @@
cd web && npx lint-staged || exit 1
cd ..
# H3 (ersatztv#303) — never commit a screenshot dropped at the repo root. Belt-and-suspenders with
# .gitignore (catches a forced `git add -f`). Root-level *.png only; nested paths are legit assets.
root_png=$(git diff --cached --name-only --diff-filter=ACM | grep -iE '^[^/]+\.png$' || true)
if [ -n "$root_png" ]; then
echo "husky - refusing to commit root-level screenshot(s):"
printf ' %s\n' $root_png
echo " Move it out of the repo root or drop it (root *.png are review/debug artifacts; see .gitignore)."
exit 1
fi
# dotnet format on staged .cs files (repo root). Scoped to the staged files so we
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
# ~20-40s sln load for web-only commits).
cs_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.cs')
if [ -n "$cs_files" ]; then
echo "husky - dotnet format (verify) on staged .cs files"
# shellcheck disable=SC2086
dotnet format ErsatzTV.sln --verify-no-changes --include $cs_files || {
echo "husky - dotnet format found issues in staged .cs files; run 'dotnet format ErsatzTV.sln --include <files>' to fix"
exit 1
}
fi
-22
View File
@@ -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
+7 -25
View File
@@ -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.
+12 -8
View File
@@ -5,20 +5,25 @@
<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="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" />
@@ -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);
}
}
@@ -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;
-35
View File
@@ -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,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,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.");
}
}
}
@@ -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."));
}
@@ -15,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,
+1 -20
View File
@@ -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 &lt; 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;
}
}
@@ -7,5 +7,4 @@ public record GetLibraryBrowseItems(
int? LibraryId,
LibraryBrowseMediaType? MediaType,
int PageNum,
int PageSize,
int? ParentId = null) : IRequest<PagedLibraryBrowseItemsResponseModel>;
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -1,9 +1,12 @@
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Flurl;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.LibraryBrowse;
@@ -19,34 +22,6 @@ public class GetLibraryBrowseItemsHandler(
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
// Drill-in for detail pages: read a parent's children directly (bypassing Lucene) so the SPA can
// expand a show into its seasons (#180), a season into its episodes, or an artist into its music
// videos (#141/#161). Each reads in the natural display order for that kind.
if (request.ParentId.HasValue)
{
switch (request.MediaType)
{
case LibraryBrowseMediaType.TelevisionSeason:
{
await using TvContext seasonContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseSeasonsForShow(seasonContext, request, cancellationToken);
}
case LibraryBrowseMediaType.Episode:
{
await using TvContext episodeContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseEpisodesForSeason(episodeContext, request, cancellationToken);
}
case LibraryBrowseMediaType.MusicVideo:
{
await using TvContext musicVideoContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseMusicVideosForArtist(musicVideoContext, request, cancellationToken);
}
}
}
int offset = request.PageNum * request.PageSize;
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
@@ -116,24 +91,12 @@ public class GetLibraryBrowseItemsHandler(
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
LibraryBrowseMediaType.Episode => [LuceneSearchIndex.EpisodeType],
LibraryBrowseMediaType.MusicVideo => [LuceneSearchIndex.MusicVideoType],
LibraryBrowseMediaType.Song => [LuceneSearchIndex.SongType],
LibraryBrowseMediaType.OtherVideo => [LuceneSearchIndex.OtherVideoType],
LibraryBrowseMediaType.Image => [LuceneSearchIndex.ImageType],
LibraryBrowseMediaType.RemoteStream => [LuceneSearchIndex.RemoteStreamType],
null =>
[
LuceneSearchIndex.MovieType,
LuceneSearchIndex.ShowType,
LuceneSearchIndex.SeasonType,
LuceneSearchIndex.ArtistType,
LuceneSearchIndex.EpisodeType,
LuceneSearchIndex.MusicVideoType,
LuceneSearchIndex.SongType,
LuceneSearchIndex.OtherVideoType,
LuceneSearchIndex.ImageType,
LuceneSearchIndex.RemoteStreamType
LuceneSearchIndex.ArtistType
],
_ => []
};
@@ -152,184 +115,219 @@ public class GetLibraryBrowseItemsHandler(
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
List<int> episodeIds = searchItems.Where(i => i.Type == LuceneSearchIndex.EpisodeType).Select(i => i.Id).ToList();
List<int> musicVideoIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.MusicVideoType).Select(i => i.Id).ToList();
List<int> songIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SongType).Select(i => i.Id).ToList();
List<int> otherVideoIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.OtherVideoType).Select(i => i.Id).ToList();
List<int> imageIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ImageType).Select(i => i.Id).ToList();
List<int> remoteStreamIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.RemoteStreamType).Select(i => i.Id).ToList();
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetMovies(dbContext, movieIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in await GetMovies(dbContext, movieIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetShows(dbContext, showIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetSeasons(dbContext, seasonIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetArtists(dbContext, artistIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetEpisodes(dbContext, episodeIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetSongs(dbContext, songIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SongType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetImages(dbContext, imageIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item;
}
return searchItems
.Where(i => hydrated.ContainsKey((i.Type, i.Id)))
.Select(i => hydrated[(i.Type, i.Id)])
.ToList();
}
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseSeasonsForShow(
private static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
TvContext dbContext,
GetLibraryBrowseItems request,
List<int> ids,
CancellationToken cancellationToken)
{
List<int> allSeasonIds = await dbContext.Seasons
if (ids.Count == 0)
{
return [];
}
return await dbContext.MovieMetadata
.AsNoTracking()
.Where(s => s.ShowId == request.ParentId.Value)
.OrderBy(s => s.SeasonNumber)
.Select(s => s.Id)
.ToListAsync(cancellationToken);
int total = allSeasonIds.Count;
List<int> pageIds = allSeasonIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> seasons =
await LibraryBrowseItemMapper.GetSeasons(dbContext, pageIds, cancellationToken);
// GetSeasons groups by season id, so restore the requested season-number order.
Dictionary<int, LibraryBrowseItemResponseModel> byId = seasons.ToDictionary(s => s.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
.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());
}
// Drill-in: episodes of a specific season, in episode-number order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseEpisodesForSeason(
private static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
TvContext dbContext,
GetLibraryBrowseItems request,
List<int> ids,
CancellationToken cancellationToken)
{
List<int> allEpisodeIds = await dbContext.EpisodeMetadata
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking()
.Where(em => em.Episode.SeasonId == request.ParentId.Value)
.OrderBy(em => em.EpisodeNumber)
.ThenBy(em => em.EpisodeId)
.Select(em => em.EpisodeId)
.ToListAsync(cancellationToken);
.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);
// Distinct preserves order (LINQ-to-Objects) for episodes with multiple metadata rows.
allEpisodeIds = allEpisodeIds.Distinct().ToList();
int total = allEpisodeIds.Count;
List<int> pageIds = allEpisodeIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> episodes =
await LibraryBrowseItemMapper.GetEpisodes(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = episodes.ToDictionary(e => e.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
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());
}
// Drill-in: music videos of a specific artist, in album/track/title order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseMusicVideosForArtist(
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
TvContext dbContext,
GetLibraryBrowseItems request,
List<int> ids,
CancellationToken cancellationToken)
{
List<int> allMusicVideoIds = await dbContext.MusicVideoMetadata
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking()
.Where(mvm => mvm.MusicVideo.ArtistId == request.ParentId.Value)
.OrderBy(mvm => mvm.Album)
.ThenBy(mvm => mvm.Track)
.ThenBy(mvm => mvm.Title)
.ThenBy(mvm => mvm.MusicVideoId)
.Select(mvm => mvm.MusicVideoId)
.ToListAsync(cancellationToken);
.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);
allMusicVideoIds = allMusicVideoIds.Distinct().ToList();
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)
.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,
Artwork(sm, ArtworkKind.Poster),
null,
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
null,
CollectionType.TelevisionSeason,
null,
null,
null,
null,
sm.SeasonId,
null)).ToList());
}
int total = allMusicVideoIds.Count;
List<int> pageIds = allMusicVideoIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
private static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
List<LibraryBrowseItemResponseModel> musicVideos =
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, pageIds, cancellationToken);
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);
Dictionary<int, LibraryBrowseItemResponseModel> byId = musicVideos.ToDictionary(mv => mv.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
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());
}
private static async Task<int> CountCollections(
@@ -661,6 +659,16 @@ public class GetLibraryBrowseItemsHandler(
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
requestType is null || requestType == itemType;
private 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;
}
private static async Task<Dictionary<int, TimeSpan?>> GetManualCollectionDurations(
TvContext dbContext,
List<int> collectionIds,
@@ -676,7 +684,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(m => m.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(movie.MediaVersions);
TimeSpan? duration = BestDuration(movie.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[movie.Id] = duration.Value;
@@ -689,7 +697,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(e => e.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(episode.MediaVersions);
TimeSpan? duration = BestDuration(episode.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[episode.Id] = duration.Value;
@@ -702,7 +710,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(mv => mv.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(musicVideo.MediaVersions);
TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[musicVideo.Id] = duration.Value;
@@ -715,7 +723,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(ov => ov.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(otherVideo.MediaVersions);
TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[otherVideo.Id] = duration.Value;
@@ -728,7 +736,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(s => s.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(song.MediaVersions);
TimeSpan? duration = BestDuration(song.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[song.Id] = duration.Value;
@@ -741,7 +749,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(i => i.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(image.MediaVersions);
TimeSpan? duration = BestDuration(image.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[image.Id] = duration.Value;
@@ -754,7 +762,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(rs => rs.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(remoteStream.MediaVersions);
TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[remoteStream.Id] = duration.Value;
@@ -785,7 +793,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(mm => mm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
{
mediaItemArtwork[metadata.MovieId] = poster;
@@ -799,7 +807,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
{
mediaItemArtwork[metadata.ShowId] = poster;
@@ -813,7 +821,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
{
mediaItemArtwork[metadata.SeasonId] = poster;
@@ -827,7 +835,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(ovm => ovm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
{
mediaItemArtwork[metadata.OtherVideoId] = poster;
@@ -841,7 +849,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
{
mediaItemArtwork[metadata.SongId] = poster;
@@ -855,7 +863,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(im => im.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
{
mediaItemArtwork[metadata.ImageId] = poster;
@@ -869,7 +877,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(rsm => rsm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
{
mediaItemArtwork[metadata.RemoteStreamId] = poster;
@@ -894,6 +902,47 @@ public class GetLibraryBrowseItemsHandler(
.Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex))
.ToListAsync(cancellationToken);
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}";
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return 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 url;
}
return artwork;
}
private static string EscapeLike(string searchQuery) =>
searchQuery
.Replace("\\", "\\\\", StringComparison.Ordinal)
@@ -42,28 +42,17 @@ public class AddArtistToCollectionHandler :
private async Task<Unit> ApplyAddArtistRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Artist.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.Artist);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -44,28 +44,17 @@ public class AddEpisodeToCollectionHandler :
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Episode.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.Episode);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,10 +15,10 @@ public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextF
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => ApplyAddEpisodeRequest(dbContext, parameters, cancellationToken));
return await validation.Apply(parameters => ApplyAddEpisodeRequest(dbContext, parameters));
}
private static async Task<Unit> ApplyAddEpisodeRequest(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
private static async Task<Unit> ApplyAddEpisodeRequest(TvContext dbContext, Parameters parameters)
{
int index = parameters.Playlist.Items.Count > 0 ? parameters.Playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -32,11 +32,7 @@ public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextF
};
parameters.Playlist.Items.Add(playlistItem);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
await dbContext.SaveChangesAsync();
return Unit.Default;
}
@@ -41,28 +41,17 @@ public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection,
private async Task<Unit> ApplyAddImageRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Image.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.Image);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -73,35 +73,22 @@ public class AddItemsToCollectionHandler :
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
// No-op when every requested item is already a member: don't rotate the ETag or fan out
// rebuilds for an idempotent re-add — #269.
if (toAddIds.Count == 0)
{
return Unit.Default;
}
List<MediaItem> toAdd = await dbContext.MediaItems
.Filter(mi => toAddIds.Contains(mi.Id))
.ToListAsync(cancellationToken);
collection.MediaItems.AddRange(toAdd);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a).
collection.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), cancellationToken);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
}
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
@@ -46,8 +46,7 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
{ CollectionType.MusicVideo, request.MusicVideoIds },
{ CollectionType.OtherVideo, request.OtherVideoIds },
{ CollectionType.Song, request.SongIds },
{ CollectionType.Image, request.ImageIds },
{ CollectionType.RemoteStream, request.RemoteStreamIds }
{ CollectionType.Image, request.ImageIds }
};
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -69,10 +68,7 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
}
}
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
playlist.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
@@ -85,24 +81,17 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
await ValidateMovies(request),
await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request),
await ValidateMediaItems(dbContext, request, cancellationToken))
.Apply((collection, _, _, _, _, _) => collection);
await ValidateEpisodes(request))
.Apply((collection, _, _, _, _) => collection);
private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist(
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext,
AddItemsToPlaylist request,
CancellationToken cancellationToken)
{
Option<Playlist> maybePlaylist = await dbContext.Playlists
CancellationToken cancellationToken) =>
dbContext.Playlists
.Include(c => c.Items)
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken);
return maybePlaylist.ToValidation<BaseError>("Playlist does not exist.")
.Bind(playlist => playlist.IsSystem
? BaseError.New("Cannot add items to system (generated) playlist")
: Success<BaseError, Playlist>(playlist));
}
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToPlaylist request) =>
_movieRepository.AllMoviesExist(request.MovieIds)
@@ -131,30 +120,4 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
TvContext dbContext,
AddItemsToPlaylist request,
CancellationToken cancellationToken)
{
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
int existingCount = await dbContext.MediaItems
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
return existingCount == ids.Count
? Unit.Default
: BaseError.New("Media item does not exist");
}
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToPlaylist request) =>
request.MovieIds
.Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.Append(request.RemoteStreamIds);
}
@@ -42,28 +42,17 @@ public class AddMediaItemToCollectionHandler :
private async Task<Unit> ApplyAddMediaItemRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.MediaItem.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.MediaItem);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -42,28 +42,17 @@ public class AddMovieToCollectionHandler :
private async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Movie.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.Movie);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,10 +15,10 @@ public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFac
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters, cancellationToken));
return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters));
}
private static async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
private static async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters)
{
int index = parameters.Playlist.Items.Count > 0 ? parameters.Playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -32,11 +32,7 @@ public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFac
};
parameters.Playlist.Items.Add(playlistItem);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
await dbContext.SaveChangesAsync();
return Unit.Default;
}
@@ -44,28 +44,17 @@ public class AddMusicVideoToCollectionHandler :
private async Task<Unit> ApplyAddMusicVideoRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.MusicVideo.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.MusicVideo);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -44,28 +44,17 @@ public class AddOtherVideoToCollectionHandler :
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.OtherVideo.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -42,28 +42,17 @@ public class AddSeasonToCollectionHandler :
private async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Season.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.Season);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,10 +15,10 @@ public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFa
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => ApplyAddSeasonRequest(dbContext, parameters, cancellationToken));
return await validation.Apply(parameters => ApplyAddSeasonRequest(dbContext, parameters));
}
private static async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
private static async Task<Unit> ApplyAddSeasonRequest(TvContext dbContext, Parameters parameters)
{
int index = parameters.Playlist.Items.Count > 0 ? parameters.Playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -32,11 +32,7 @@ public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFa
};
parameters.Playlist.Items.Add(playlistItem);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
await dbContext.SaveChangesAsync();
return Unit.Default;
}
@@ -42,28 +42,17 @@ public class AddShowToCollectionHandler :
private async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Show.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.Show);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -15,10 +15,10 @@ public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFact
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters, cancellationToken));
return await validation.Apply(parameters => ApplyAddShowRequest(dbContext, parameters));
}
private static async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters, CancellationToken cancellationToken)
private static async Task<Unit> ApplyAddShowRequest(TvContext dbContext, Parameters parameters)
{
int index = parameters.Playlist.Items.Count > 0 ? parameters.Playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -32,11 +32,7 @@ public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFact
};
parameters.Playlist.Items.Add(playlistItem);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
await dbContext.SaveChangesAsync();
return Unit.Default;
}
@@ -42,28 +42,17 @@ public class AddSongToCollectionHandler :
private async Task<Unit> ApplyAddSongRequest(TvContext dbContext, Parameters parameters)
{
// No-op on an idempotent re-add: don't rotate the ETag or fan out rebuilds for an item that is
// already a member (also avoids a duplicate CollectionItem row) — #269.
if (parameters.Collection.MediaItems.Any(mi => mi.Id == parameters.Song.Id))
{
return Unit.Default;
}
parameters.Collection.MediaItems.Add(parameters.Song);
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
parameters.Collection.Version++;
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]), CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
if (await dbContext.SaveChangesAsync() > 0)
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]));
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -35,7 +35,7 @@ public class CreatePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory
TvContext dbContext,
CreatePlaylist request)
{
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 50)
if (request.Name.Length > 50)
{
return BaseError.New($"Playlist name \"{request.Name}\" is invalid");
}

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