Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2977f86c25 |
@@ -1,12 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# ersatztv#521 — the line-level append-only mechanic is retired. Decision integrity is now enforced by
|
||||
# the lifecycle validator. `[decisions-edit]` survives ONLY for rationale-prose edits (validator
|
||||
# body-diff, CI). This shim runs the structural validator over the working tree; the body-diff/no-
|
||||
# vanish checks run in CI where a base/head is available. Fail-open on any tooling trouble.
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 0
|
||||
command -v python3 >/dev/null 2>&1 || exit 0 # no python -> fail-open
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py
|
||||
rc=$?
|
||||
[ "$rc" -eq 1 ] && exit 1 # only a real validation failure blocks
|
||||
exit 0 # crashes/other codes -> fail-open
|
||||
# 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
|
||||
|
||||
@@ -53,14 +53,9 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push CI image
|
||||
# Moved off `small` with docker-build.yml's `build` (server-management#639). Being
|
||||
# "docker-only" made it look lightweight, but it is a full buildx of the .NET
|
||||
# toolchain image — the heaviest thing that ran in that lane. `small` is now
|
||||
# git-only and capped at 1g per job, which would OOM this build.
|
||||
#
|
||||
# Rare trigger (pushes touching docker/ci + a weekly cron), so it costs the
|
||||
# ubuntu-latest lane almost nothing, and ci-runner (.127) runs no prod workload.
|
||||
runs-on: ubuntu-latest
|
||||
# `small` = the small-jobs runner lane. This is a docker-only job (no toolchain needed —
|
||||
# it *builds* the toolchain), same as docker-build.yml's `build` job.
|
||||
runs-on: small
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -25,7 +25,7 @@ name: Build ErsatzTV Image
|
||||
# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md ->
|
||||
# "CI toolchain image" for the two-step procedure.
|
||||
#
|
||||
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
#
|
||||
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
|
||||
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -183,16 +183,6 @@ jobs:
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
# Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so
|
||||
# its high-water mark spans them (SPA build/test above are comparatively light). Paired with the
|
||||
# "Report peak container memory" step below. continue-on-error + a fail-open script => this
|
||||
# instrumentation never reddens a build. Why anon and not memory.peak: ersatztv#412 /
|
||||
# scripts/ci-peak-anon.sh header / docs/ci-cd.md "CI build memory".
|
||||
- name: Start peak-anon sampler (ersatztv#412)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: scripts/ci-peak-anon.sh start
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
@@ -233,32 +223,77 @@ jobs:
|
||||
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Memory of THIS job container, reported every run (ersatztv#406/#412, server-management#604).
|
||||
# #604 sizes the runners' per-job caps on these numbers. The headline is the TRUE PEAK ANON
|
||||
# sampled by the "Start peak-anon sampler" step above — NOT `memory.peak`, which is the
|
||||
# high-water mark of memory.current and charges reclaimable page cache to the cgroup (a build
|
||||
# job does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak). Page cache is
|
||||
# reclaimed under a tighter cap, not OOM-killed, so sizing a cap off `memory.peak` inverts the
|
||||
# decision. peak anon is the OOM-forcing number. Full rationale + the bumblebee demo:
|
||||
# scripts/ci-peak-anon.sh header and docs/ci-cd.md "CI build memory".
|
||||
# Memory of THIS job container, reported every run (ersatztv#406, server-management#604).
|
||||
# #604 sizes the runners' per-job caps on these numbers, and until now they were inherited
|
||||
# rather than measured: the 10g cap traces back to server-management#570 observing the image
|
||||
# build peg 5.999/6 GiB, which is a different job entirely.
|
||||
#
|
||||
# Runs LAST on purpose (after Coverage summary / reportgenerator, the job's last real workload)
|
||||
# and stops the sampler. `always()` so a failed Build/Test still gets a peak reading; the split
|
||||
# is read here (end-of-job = composition then, not at the peak instant — that is exactly why the
|
||||
# sampler exists). Skipped on docs-only/already-validated runs (nothing ran to measure).
|
||||
# ⚠️ READ THE BREAKDOWN, NOT JUST THE PEAK. `memory.peak` is the high-water mark of
|
||||
# `memory.current`, which charges **page cache** to the cgroup as well as anonymous memory —
|
||||
# it is NOT "peak RSS", and for a build job (NuGet/npm/obj/bin/coverage I/O) the cache
|
||||
# dominates. Demonstrated on bumblebee: a container with anon=0 that merely reads an 800 MB
|
||||
# file reports memory.peak=826 MiB, of which file=800 MiB. This matters because the naive
|
||||
# reading inverts the decision: page cache is **reclaimed** under a tighter cap, not
|
||||
# OOM-killed, so a large peak that is mostly `file` is NOT evidence that the cap must stay
|
||||
# high. `anon` (+ a little kernel/sock) is the part that actually forces an OOM.
|
||||
#
|
||||
# The split below is read at end-of-job, so it is the *current* composition rather than the
|
||||
# composition at the peak instant — indicative, not exact. Sizing a cap off one run is still
|
||||
# wrong; take a few runs, and treat anon as the floor and peak as the (cache-inflated)
|
||||
# ceiling. Refining this into a true peak-anon sample is ersatztv#412.
|
||||
#
|
||||
# Runs LAST on purpose: memory.peak read at step N reports the peak only up to N, so this
|
||||
# sits after Coverage summary to include reportgenerator, the job's last real workload.
|
||||
# cgroup v2 first, v1 fallback.
|
||||
#
|
||||
# Skipped on docs-only runs (ersatztv#416): nothing ran, so there is nothing to measure.
|
||||
- name: Report peak container memory
|
||||
# `always()` controls whether this step RUNS, not whether its failure fails the job. With
|
||||
# `defaults.run.shell: bash` (`-e -o pipefail`) a stray non-zero here would redden a green
|
||||
# test job, so `continue-on-error` makes it advisory — the same guarantee Coverage summary uses.
|
||||
# `always()` controls whether this step RUNS, not whether its failure fails the job — and
|
||||
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
|
||||
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
|
||||
# the same guarantee the Coverage summary step above uses.
|
||||
if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }}
|
||||
continue-on-error: true
|
||||
run: scripts/ci-peak-anon.sh report
|
||||
run: |
|
||||
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
|
||||
peak=""; src=""
|
||||
for f in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory/memory.max_usage_in_bytes; do
|
||||
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
|
||||
done
|
||||
if [ -z "$peak" ]; then
|
||||
echo "No cgroup peak-memory file readable in this container -- skipping."
|
||||
exit 0
|
||||
fi
|
||||
anon=""; file=""
|
||||
if [ -r /sys/fs/cgroup/memory.stat ]; then
|
||||
anon=$(awk '/^anon /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
|
||||
file=$(awk '/^file /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "::group::Container memory (ersatztv#406 / server-management#604)"
|
||||
printf 'peak (incl. page cache): %s MiB [%s bytes, %s]\n' "$(mib "$peak")" "$peak" "$src"
|
||||
if [ -n "$anon" ]; then
|
||||
printf 'end-of-job anon (the part that OOMs): %s MiB\n' "$(mib "$anon")"
|
||||
printf 'end-of-job file (page cache, reclaimable): %s MiB\n' "$(mib "${file:-0}")"
|
||||
echo 'NOTE: peak counts reclaimable page cache. Size caps on anon, not on peak.'
|
||||
else
|
||||
echo 'NOTE: no memory.stat breakdown available; peak includes reclaimable page cache.'
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{
|
||||
printf '**Container memory (test job):** peak %s MiB *(incl. reclaimable page cache)*' \
|
||||
"$(mib "$peak")"
|
||||
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' \
|
||||
"$(mib "$anon")" "$(mib "${file:-0}")"
|
||||
printf '\n'
|
||||
} >> "$GITHUB_STEP_SUMMARY" || true
|
||||
fi
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -417,7 +452,7 @@ jobs:
|
||||
# v* tag builds.
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -496,20 +531,12 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# Moved back off `small` (server-management#639). This is the one HEAVY job that
|
||||
# was still in that lane, and its 10g requirement was what pinned the lane's
|
||||
# per-job cap at 10g — which in turn capped the lane at ONE slot on a 25 GiB
|
||||
# host. Four jobs sharing one slot is what starved the git-only checks in act's
|
||||
# setup phase (>10 min, no logs, then fail). With this job gone, `small` is
|
||||
# git-only and can run wide and tiny on two hosts.
|
||||
#
|
||||
# The `ubuntu-latest` queueing that sent it to `small` in the first place
|
||||
# (server-management#574: a PR-run skip stuck 31 min behind long builds) does not
|
||||
# come back, because `needs: [test, migrations]` means this job cannot be
|
||||
# dispatched until those two have already finished — by which point the lane it
|
||||
# was queueing behind has drained. Real builds (main/tags) get the full
|
||||
# ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127).
|
||||
runs-on: ubuntu-latest
|
||||
# `small` = the dedicated small-jobs runner lane (server-management#574).
|
||||
# On PR runs this job only resolves its skip, but Gitea still dispatches it
|
||||
# as a task — on the ubuntu-latest runners that skip queued behind long
|
||||
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
|
||||
# capped at 4 CPUs / 10g.
|
||||
runs-on: small
|
||||
needs: [test, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
@@ -726,14 +753,12 @@ jobs:
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
||||
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
||||
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
|
||||
# vanishing from the active set without an archive copy) and that the generated active catalog
|
||||
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
|
||||
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
|
||||
# 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 lifecycle
|
||||
name: decisions.md append-only
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
@@ -741,19 +766,22 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Validate decision lifecycle
|
||||
- name: Enforce append-only
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
||||
- name: Active catalog in sync
|
||||
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
||||
- name: Kickoff guard
|
||||
run: bash scripts/check-kickoff-guard.sh
|
||||
./.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
|
||||
@@ -784,7 +812,7 @@ jobs:
|
||||
# 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -857,29 +885,19 @@ jobs:
|
||||
echo "Generated API artifacts are in sync."
|
||||
|
||||
# Formatting-as-you-touch gate (ersatztv#311): verify the .cs files THIS PR changed conform to
|
||||
# .editorconfig whitespace + charset=utf-8 (i.e. no UTF-8 BOM). Scoped to changed files so it
|
||||
# enforces "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500
|
||||
# pre-existing BOM files. A PR that touches no .cs skips the check and passes trivially (always
|
||||
# reports a status, so it is safe as a required check).
|
||||
#
|
||||
# ersatztv#469: uses `dotnet format whitespace . --folder`, NOT the full `dotnet format <sln>`.
|
||||
# `--folder` treats the tree as a plain folder of files and skips the MSBuild/Roslyn workspace load
|
||||
# + per-project compilation that dominated the old recipe (~8 min locally on a whole-solution run) —
|
||||
# `--include` only ever narrowed *which* files were checked, never what got loaded. Folder mode
|
||||
# reads .editorconfig and still flags WHITESPACE (indent/EOL/trailing/final-newline) and CHARSET
|
||||
# (BOM) violations — exactly what this gate exists to catch — in ~0.5s with no `dotnet restore`.
|
||||
# What it drops is the style/analyzer pass (naming/`var`/qualification), which this gate never
|
||||
# meaningfully enforced: those .editorconfig rules are :suggestion/:none severity. Full rationale +
|
||||
# non-vacuity evidence: docs/ci-cd.md → Formatting; docs/decisions.md.
|
||||
# .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)
|
||||
# Folder-mode whitespace is now a seconds-long, low-memory job (no Roslyn workspace, unlike the
|
||||
# 3.95 GiB full `dotnet format` measured in #406), so it no longer needs the memory headroom that
|
||||
# kept it on `ubuntu-latest`. Left here to avoid re-touching the lane/memory-cap accounting; a
|
||||
# move to a lighter lane is a server-management capacity call (#604).
|
||||
# Was on the `small` lane (ersatztv#390) to dodge a ~29 min queue; reverted to `ubuntu-latest`
|
||||
# in ersatztv#406 — `dotnet format` needs the .NET SDK and real memory, so it does not belong
|
||||
# in a lane sized for seconds-long shell jobs. See the api-docs job above for the full
|
||||
# rationale; server-management#604 grew this lane so the queue it was dodging is gone.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -906,14 +924,26 @@ jobs:
|
||||
echo "No .cs change -> skipping format verify (job passes)."
|
||||
fi
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.cs_changed == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.cs_changed == 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Verify formatting of changed .cs files
|
||||
if: steps.detect.outputs.cs_changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
mapfile -t files < /tmp/changed-cs.txt
|
||||
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig (whitespace + charset)..."
|
||||
if ! dotnet format whitespace . --folder --verify-no-changes --include "${files[@]}"; then
|
||||
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (whitespace or a UTF-8 BOM). Run 'dotnet format whitespace . --folder --include <files>' (or the full 'dotnet format ErsatzTV.sln --include <files>') and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
|
||||
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."
|
||||
|
||||
@@ -8,3 +8,8 @@ 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
|
||||
|
||||
+6
-14
@@ -1,12 +1,6 @@
|
||||
cd web && npx lint-staged || exit 1
|
||||
cd ..
|
||||
|
||||
# ersatztv#521 — decision-record lifecycle structural validator (replaces the old H9 append-only
|
||||
# line guard). Runs the same validator the CI `decisions lifecycle` job uses, over the working
|
||||
# tree (no base/head here, so only structural checks run; the body-diff/no-vanish checks run in
|
||||
# CI where a base ref exists). Fail-open shim — see .claude/hooks/decisions-guard.sh.
|
||||
./.claude/hooks/decisions-guard.sh || exit 1
|
||||
|
||||
# H3 (ersatztv#303) — never commit a screenshot dropped at the repo root. Belt-and-suspenders with
|
||||
# .gitignore (catches a forced `git add -f`). Root-level *.png only; nested paths are legit assets.
|
||||
root_png=$(git diff --cached --name-only --diff-filter=ACM | grep -iE '^[^/]+\.png$' || true)
|
||||
@@ -17,17 +11,15 @@ if [ -n "$root_png" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# dotnet format on staged .cs files (repo root). Uses `whitespace . --folder` — same recipe as
|
||||
# the CI `format` job (ersatztv#469): folder mode checks .editorconfig whitespace + charset (BOM)
|
||||
# without the MSBuild/Roslyn workspace load, so it runs in ~0.5s instead of the old ~20-40s sln
|
||||
# load. Keeping this identical to CI avoids a local hook that blocks on rules CI no longer enforces.
|
||||
# Skip entirely when no .cs is staged (avoids any cost for web-only commits).
|
||||
# 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 (whitespace verify) on staged .cs files"
|
||||
echo "husky - dotnet format (verify) on staged .cs files"
|
||||
# shellcheck disable=SC2086
|
||||
dotnet format whitespace . --folder --verify-no-changes --include $cs_files || {
|
||||
echo "husky - dotnet format found whitespace/BOM issues in staged .cs files; run 'dotnet format whitespace . --folder --include <files>' to fix"
|
||||
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
|
||||
|
||||
@@ -35,10 +35,10 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: **jazz (192.168.1.29)**, container `ersatztv`, port 8409. Media transcoders (Jellyfin, `ersatztv`, `ersatztv-test`) moved here from bumblebee on 2026-07-20 (server-management#633); bumblebee (192.168.1.99) still hosts the **CI runners** and the rest of the stacks. **Name-reuse trap**: `jazz` was an *earlier* name for the .99 host, so pre-2026-07-20 docs/commits saying "jazz" mean today's **bumblebee** — go by the IP, not the name.
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **Docker host**: bumblebee (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on bumblebee → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** stack — named **`jazz-media`** (the compose *project* is still `media-servers`; a dead `media-servers` stack lingers on bumblebee) — follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually `DeployStack jazz-media`. There is **no** auto-update fallback (`auto_update: false`) — promotion is manual. Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** `media-servers` stack follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually deploy the stack (Global Auto Update is the daily fallback). Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -56,7 +56,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
## Conventions
|
||||
|
||||
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
|
||||
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read the `docs/README.md` **task-signal map** and only the sections it points to for your task — not the whole corpus. **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code. **Decision/convention lookups start at the active catalog**, `docs/decisions/README.md` — resolve by topic/key, never by chasing a file path named in a historical comment (the breadcrumb rule; see `docs/README.md` → "Knowledge retrieval").
|
||||
- **Docs-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 |
|
||||
@@ -64,7 +64,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
| 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` (lifecycle: add record, relocate predecessor to archive/) + the affected doc |
|
||||
| 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.
|
||||
@@ -91,24 +91,11 @@ Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pa
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
|
||||
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
**`## Closing record` template** (step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval contract this feeds):
|
||||
|
||||
```markdown
|
||||
## Closing record
|
||||
**Outcome:** <what shipped / what didn't; PR link>
|
||||
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
|
||||
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
|
||||
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
|
||||
**Verification:** <tests run, live-E2E, CI status>
|
||||
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
|
||||
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
|
||||
```
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
|
||||
|
||||
@@ -9,13 +9,8 @@ namespace ErsatzTV.Application.Artworks;
|
||||
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
|
||||
{
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly IRemoteImageValidator _validator;
|
||||
|
||||
public UploadArtworkHandler(IImageCache imageCache, IRemoteImageValidator validator)
|
||||
{
|
||||
_imageCache = imageCache;
|
||||
_validator = validator;
|
||||
}
|
||||
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
|
||||
|
||||
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
|
||||
UploadArtwork request,
|
||||
@@ -43,22 +38,6 @@ public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseEr
|
||||
|
||||
string contentType = maybeContentType.IfNone(string.Empty);
|
||||
|
||||
// One rule: anything entering the logo cache is decode-budget-checked. A supported format is
|
||||
// not enough — a small header can declare a multi-gigabyte canvas (a decompression bomb), so
|
||||
// reject it here before it lands in the cache. The synthetic upload:// Uri is only for the
|
||||
// exception message text. (ersatztv#525)
|
||||
using (var probe = new MemoryStream(bytes, writable: false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _validator.Validate(probe, new Uri("upload://artwork"), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New($"Image cannot be used: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
using var toCache = new MemoryStream(bytes, writable: false);
|
||||
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
|
||||
toCache,
|
||||
|
||||
@@ -8,7 +8,6 @@ using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -22,7 +21,6 @@ public class CreateChannelFromLineupHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
IRemoteLogoCacher remoteLogoCacher,
|
||||
ILogger<CreateChannelFromLineupHandler> logger)
|
||||
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
|
||||
{
|
||||
@@ -39,42 +37,7 @@ public class CreateChannelFromLineupHandler(
|
||||
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Match(
|
||||
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
|
||||
Right: async prepared =>
|
||||
{
|
||||
Either<BaseError, PreparedCreate> resolved =
|
||||
await ResolveExternalLogo(request, prepared, cancellationToken);
|
||||
return await resolved.Match(
|
||||
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
|
||||
Right: p => PersistAndDispatch(dbContext, p, cancellationToken));
|
||||
});
|
||||
}
|
||||
|
||||
// The lineup logo artwork is built (in BuildChannel) with the raw request path. When that path is
|
||||
// an external http(s) URL, download + cache it and swap the cache name onto the logo artwork before
|
||||
// persisting (a cacher Left fails the whole create); a blank or already-local/cached path is left
|
||||
// unchanged. (ersatztv#525)
|
||||
private async Task<Either<BaseError, PreparedCreate>> ResolveExternalLogo(
|
||||
CreateChannelFromLineup request,
|
||||
PreparedCreate prepared,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = request.Logo?.Path ?? string.Empty;
|
||||
|
||||
if (!Artwork.IsExternalUrl(path))
|
||||
{
|
||||
return prepared;
|
||||
}
|
||||
|
||||
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
|
||||
return cached.Map(name =>
|
||||
{
|
||||
foreach (Artwork logo in prepared.Channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
{
|
||||
logo.Path = name;
|
||||
}
|
||||
|
||||
return prepared;
|
||||
});
|
||||
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -17,8 +16,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class CreateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
IRemoteLogoCacher remoteLogoCacher)
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
|
||||
{
|
||||
public async Task<Either<BaseError, CreateChannelResult>> Handle(
|
||||
@@ -27,52 +25,7 @@ public class CreateChannelHandler(
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Match(
|
||||
Succ: async channel =>
|
||||
{
|
||||
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
|
||||
return await resolvedLogo.Match(
|
||||
Right: async logoPath =>
|
||||
{
|
||||
ApplyResolvedLogo(request, channel, logoPath);
|
||||
return Right<BaseError, CreateChannelResult>(await PersistChannel(dbContext, channel));
|
||||
},
|
||||
Left: e => Task.FromResult(Left<BaseError, CreateChannelResult>(e)));
|
||||
},
|
||||
Fail: errors => Task.FromResult(Left<BaseError, CreateChannelResult>(errors.Join())));
|
||||
}
|
||||
|
||||
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
|
||||
// downloaded and cached (a cacher Left fails the whole save); an empty path or an
|
||||
// already-local/cached path passes through unchanged. (ersatztv#525)
|
||||
private async Task<Either<BaseError, string>> ResolveLogoPath(
|
||||
CreateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = request.Logo?.Path ?? string.Empty;
|
||||
|
||||
if (!Artwork.IsExternalUrl(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// When the incoming logo was an external URL, swap the downloaded cache name onto the logo
|
||||
// artwork built during validation so no URL is ever persisted in Artwork.Path. (ersatztv#525)
|
||||
private static void ApplyResolvedLogo(CreateChannel request, Channel channel, string resolvedLogoPath)
|
||||
{
|
||||
if (!Artwork.IsExternalUrl(request.Logo?.Path ?? string.Empty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Artwork logo in channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
{
|
||||
logo.Path = resolvedLogoPath;
|
||||
}
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
|
||||
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -20,8 +19,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class UpdateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
IRemoteLogoCacher remoteLogoCacher)
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelViewModel>> Handle(
|
||||
@@ -41,45 +39,17 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Validation<BaseError, Channel> validation =
|
||||
await Validate(dbContext, request, channel, cancellationToken);
|
||||
return await validation.Match(
|
||||
Succ: async c =>
|
||||
{
|
||||
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
|
||||
return await resolvedLogo.Match(
|
||||
Right: async logoPath => Right<BaseError, ChannelViewModel>(
|
||||
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
|
||||
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
|
||||
},
|
||||
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, ChannelViewModel>(
|
||||
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
|
||||
}
|
||||
|
||||
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
|
||||
// downloaded and cached (a cacher Left fails the whole save); an empty path (logo removal) or an
|
||||
// already-local/cached path passes through unchanged. (ersatztv#525)
|
||||
private async Task<Either<BaseError, string>> ResolveLogoPath(
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = request.Logo?.Path ?? string.Empty;
|
||||
|
||||
if (!Artwork.IsExternalUrl(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Channel c,
|
||||
UpdateChannel update,
|
||||
string resolvedLogoPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
|
||||
@@ -106,9 +76,9 @@ public class UpdateChannelHandler(
|
||||
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
|
||||
c.Artwork ??= [];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedLogoPath))
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
|
||||
{
|
||||
string logo = resolvedLogoPath;
|
||||
string logo = update.Logo.Path;
|
||||
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
|
||||
{
|
||||
logo = logo.Replace("iptv/logos/", string.Empty);
|
||||
|
||||
@@ -109,8 +109,7 @@ internal static class Mapper
|
||||
GetStreamingMode(channel),
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg,
|
||||
playoutCount,
|
||||
GetLogoUrl(channel));
|
||||
playoutCount);
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
@@ -124,31 +123,6 @@ internal static class Mapper
|
||||
channel.FFmpegProfile.VideoProfile,
|
||||
channel.FFmpegProfile.AudioFormat);
|
||||
|
||||
// Rooted, directly-usable channel-logo URL for the SPA's <img src> on browse surfaces (guide grid +
|
||||
// channels list), following the #181 artwork convention (docs/api-conventions.md §4): the SPA does no
|
||||
// client-side path building. External logo URLs pass through as-is; an uploaded logo ("iptv/logos/{file}")
|
||||
// is rooted with a leading slash so it resolves against the site root regardless of the current SPA route.
|
||||
// Returns null when the channel has no logo, so the SPA falls back to the generated initials "bug".
|
||||
#nullable enable
|
||||
internal static string? GetLogoUrl(Channel channel)
|
||||
{
|
||||
// Browse surfaces must not crash the whole list over a missing Artwork include; GetLogo assumes
|
||||
// the caller included Channel.Artwork (GetAll + the guide query do), but stay defensive here.
|
||||
if (channel.Artwork is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ArtworkContentTypeModel logo = GetLogo(channel);
|
||||
if (string.IsNullOrWhiteSpace(logo.Path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return logo.IsExternalUrl || logo.Path.StartsWith('/') ? logo.Path : $"/{logo.Path}";
|
||||
}
|
||||
#nullable restore
|
||||
|
||||
private static ArtworkContentTypeModel GetLogo(Channel channel)
|
||||
{
|
||||
Option<Artwork> maybeArtwork = channel.Artwork
|
||||
|
||||
@@ -47,7 +47,6 @@ public class GetChannelGuideDataHandler(
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.ShowInEpg)
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -122,7 +121,6 @@ public class GetChannelGuideDataHandler(
|
||||
new ChannelGuideChannelResponseModel(
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
Mapper.GetLogoUrl(channel),
|
||||
programmes.OrderBy(p => p.Start).ToList()));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -34,5 +34,4 @@ public record CreateFFmpegProfile(
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -68,11 +67,7 @@ public class CreateFFmpegProfileHandler :
|
||||
HardwareAcceleration = hwAccel,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
// store what the pipeline will actually use, never a pool size FFmpegState would
|
||||
// floor away at render time (ersatztv#529)
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames is { } frames
|
||||
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
|
||||
: null,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
|
||||
@@ -110,8 +105,7 @@ public class CreateFFmpegProfileHandler :
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
NormalizeColors = request.NormalizeColors,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo,
|
||||
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -35,5 +35,4 @@ public record UpdateFFmpegProfile(
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
|
||||
@@ -3,7 +3,6 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -55,11 +54,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
// store what the pipeline will actually use, so a profile doesn't keep displaying a pool
|
||||
// size that FFmpegState floors away at render time (ersatztv#529)
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames is { } frames
|
||||
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
|
||||
: null;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.PadMode = update.PadMode;
|
||||
@@ -107,7 +102,6 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.NormalizeColors = update.NormalizeColors;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
|
||||
|
||||
// don't save invalid preset
|
||||
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Resolutions;
|
||||
using ErsatzTV.Application.Resolutions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -35,5 +35,4 @@ public record FFmpegProfileViewModel(
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder);
|
||||
bool DeinterlaceVideo);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
@@ -37,8 +37,7 @@ internal static class Mapper
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeFramerate,
|
||||
profile.NormalizeColors,
|
||||
profile.DeinterlaceVideo == true,
|
||||
profile.QsvPreferNativeDecoder != false);
|
||||
profile.DeinterlaceVideo == true);
|
||||
|
||||
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
new(
|
||||
@@ -81,6 +80,5 @@ internal static class Mapper
|
||||
ffmpegProfile.AudioSampleRate,
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
ffmpegProfile.NormalizeColors,
|
||||
ffmpegProfile.DeinterlaceVideo == true,
|
||||
ffmpegProfile.QsvPreferNativeDecoder != false);
|
||||
ffmpegProfile.DeinterlaceVideo == true);
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
public record GetAllHealthCheckResultsForApi(bool Refresh = false) : IRequest<List<HealthCheckResponseModel>>;
|
||||
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
|
||||
|
||||
@@ -18,8 +18,7 @@ public class GetAllHealthCheckResultsForApiHandler
|
||||
{
|
||||
try
|
||||
{
|
||||
List<HealthCheckResult> results =
|
||||
await _healthCheckService.PerformHealthChecks(request.Refresh, cancellationToken);
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results
|
||||
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
|
||||
.Map(ProjectToResponseModel)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Health;
|
||||
using ErsatzTV.Core.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
|
||||
{
|
||||
try
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(false, cancellationToken);
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
@@ -70,23 +70,9 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CreateLocalLibrary request) =>
|
||||
MediaSourceMustExist(dbContext, request)
|
||||
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
|
||||
.BindT(MediaKindMustBeSupportedLocally)
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
|
||||
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
|
||||
|
||||
/// <summary>
|
||||
/// Mixed is only ever produced for remote (Jellyfin) libraries, where the media server classifies
|
||||
/// each item for us. No local folder scanner handles it, so a local Mixed library would fail every
|
||||
/// scan forever. The API takes a raw LibraryMediaKind, so this must be enforced here rather than
|
||||
/// left to the SPA's media-kind options.
|
||||
/// </summary>
|
||||
private static Validation<BaseError, LocalLibrary> MediaKindMustBeSupportedLocally(
|
||||
LocalLibrary localLibrary) =>
|
||||
localLibrary.MediaKind is LibraryMediaKind.Mixed
|
||||
? BaseError.New(
|
||||
"Local libraries cannot use the Mixed media kind; it is only valid for Jellyfin libraries.")
|
||||
: localLibrary;
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
|
||||
TvContext dbContext,
|
||||
CreateLocalLibrary request) =>
|
||||
|
||||
@@ -131,19 +131,9 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
long startupMs = (long)segments.ProcessStartup.TotalMilliseconds;
|
||||
long fillMs = (long)segments.SegmentFill.TotalMilliseconds;
|
||||
long setupMs = Math.Max(0, totalMs - startupMs - fillMs);
|
||||
// #472 sub-splits the startup work (81% of total, all of the variance) into the ErsatzTV-side
|
||||
// prep before FFmpeg is launched, FFmpeg's own init (input open+probe and decoder/encoder
|
||||
// init), and the wait for the playlist once FFmpeg is reporting progress. splitKind says how
|
||||
// much of that was actually observable for this sample. NOTE these buckets span the worker's
|
||||
// Run entry rather than the startup stopwatch, so they do NOT sum to startupMs — prep overlaps
|
||||
// the tail of setup. The log says "spans runEntry" so a reader can't miss it.
|
||||
// See ColdStartStartupSplit for the full set of caveats.
|
||||
ColdStartStartupSplit split = segments.StartupSplit;
|
||||
_logger.LogInformation(
|
||||
"HLS cold-start channel {Channel} mode {Mode}: total {TotalMs}ms " +
|
||||
"(setup {SetupMs}ms + startup {ProcessStartupMs}ms + fill {SegmentFillMs}ms), " +
|
||||
"startup split {SplitKind} spans runEntry (prep {PrepMs}ms + ffmpegInit {FFmpegInitMs}ms " +
|
||||
"+ firstGop {FirstGopMs}ms), " +
|
||||
"segments {SegmentsReached}/{InitialSegmentCount}, " +
|
||||
"deadlineExpired {DeadlineExpired}, subtitleBurnIn {SubtitleBurnIn}, hwaccel {HwAccel}",
|
||||
request.ChannelNumber,
|
||||
@@ -152,10 +142,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
setupMs,
|
||||
startupMs,
|
||||
fillMs,
|
||||
split.Kind,
|
||||
(long)split.Prep.TotalMilliseconds,
|
||||
(long)split.FFmpegInit.TotalMilliseconds,
|
||||
(long)split.FirstGop.TotalMilliseconds,
|
||||
segments.SegmentsReached,
|
||||
segments.InitialSegmentCount,
|
||||
segments.DeadlineExpired,
|
||||
|
||||
@@ -61,14 +61,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
// segments cannot exist until this process ran) — volatile for cross-thread visibility.
|
||||
private volatile string _coldStartFFmpegArguments;
|
||||
|
||||
// Stopwatch timestamps of the cold-start milestones used to sub-split the "startup" phase (#472).
|
||||
// Each is written once on the sequential Run loop and read on the handler thread from
|
||||
// WaitForPlaylistSegments; long fields cannot be volatile, so access goes through Volatile/
|
||||
// Interlocked. Zero means "never reached", which ColdStartStartupSplit degrades gracefully on.
|
||||
private long _coldStartRunTicks;
|
||||
private long _coldStartProcessLaunchedTicks;
|
||||
private long _coldStartFirstProgressTicks;
|
||||
|
||||
public HlsSessionWorker(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
@@ -195,10 +187,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
|
||||
|
||||
// anchor for the cold-start startup sub-split (#472); this runs before any later milestone,
|
||||
// so every sub-phase derived from it is non-negative by construction
|
||||
Volatile.Write(ref _coldStartRunTicks, Stopwatch.GetTimestamp());
|
||||
|
||||
try
|
||||
{
|
||||
_channelNumber = channelNumber;
|
||||
@@ -326,7 +314,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var processStartup = TimeSpan.Zero;
|
||||
var startupSplit = ColdStartStartupSplit.Unavailable;
|
||||
var segmentCount = 0;
|
||||
try
|
||||
{
|
||||
@@ -342,13 +329,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
_logger.LogDebug("Playlist exists");
|
||||
processStartup = sw.Elapsed;
|
||||
|
||||
// #472: sub-split the phase that #350 measured as 81% of cold-start and all of its variance
|
||||
startupSplit = ColdStartStartupSplit.FromTimestamps(
|
||||
Volatile.Read(ref _coldStartRunTicks),
|
||||
Volatile.Read(ref _coldStartProcessLaunchedTicks),
|
||||
Volatile.Read(ref _coldStartFirstProgressTicks),
|
||||
Stopwatch.GetTimestamp());
|
||||
|
||||
// start the segment-wait deadline only after the playlist file appears,
|
||||
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
|
||||
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
|
||||
@@ -382,8 +362,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
segmentCount,
|
||||
initialSegmentCount,
|
||||
segmentCount < initialSegmentCount,
|
||||
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments),
|
||||
startupSplit);
|
||||
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -597,30 +576,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
var progressParser = new FFmpegProgress();
|
||||
|
||||
// #472: the first -progress line is the only cold-start milestone FFmpeg gives us
|
||||
// for free (the pipeline runs -loglevel error -nostats, so stderr stays silent on a
|
||||
// healthy run). It means the input is open and probed and the decoder/encoder are
|
||||
// initialized. Record-once, so only the session's first process is measured.
|
||||
void ParseProgressLine(string line)
|
||||
{
|
||||
// the read short-circuits the timestamp call for every line after the first,
|
||||
// which is every line for the life of the session
|
||||
if (Volatile.Read(ref _coldStartFirstProgressTicks) == 0)
|
||||
{
|
||||
Interlocked.CompareExchange(ref _coldStartFirstProgressTicks, Stopwatch.GetTimestamp(), 0);
|
||||
}
|
||||
|
||||
progressParser.ParseLine(line);
|
||||
}
|
||||
|
||||
// everything before this point is ErsatzTV-side "prep" (playout item resolution,
|
||||
// pipeline build, graphics engine spawn); FFmpeg's own clock starts here
|
||||
Interlocked.CompareExchange(ref _coldStartProcessLaunchedTicks, Stopwatch.GetTimestamp(), 0);
|
||||
|
||||
CommandResult commandResult = await processWithPipe
|
||||
.WithWorkingDirectory(_workingDirectory)
|
||||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(ParseProgressLine))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(linkedCts.Token);
|
||||
|
||||
@@ -714,20 +673,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException
|
||||
&& cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// a cancellation anywhere in this method (including inside the mediator sends, which sit
|
||||
// outside the inner ffmpeg try below) is a shutdown or a client disconnect, not a fault.
|
||||
// Without this it reaches the catch-all and logs a channel-level ERROR with a stack
|
||||
// trace on every graceful teardown. The token check is load-bearing: TaskCanceledException
|
||||
// is also what HttpClient throws on ITS OWN timeout, and a real timeout inside ffprobe, a
|
||||
// media-server call or subtitle extraction must keep its ERROR-level signal rather than
|
||||
// being downgraded to a routine teardown. (ersatztv#473 review)
|
||||
_logger.LogInformation("Terminating HLS session for channel {Channel}", _channelNumber);
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error transcoding channel {Channel} - {Message}", _channelNumber, ex.Message);
|
||||
|
||||
+10
-30
@@ -1,4 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using CliWrap;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
@@ -42,7 +42,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
private readonly IGraphicsElementSelector _graphicsElementSelector;
|
||||
private readonly IDecoSelector _decoSelector;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IRemoteStreamProber _remoteStreamProber;
|
||||
private readonly ISongVideoGenerator _songVideoGenerator;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
private readonly bool _isDebugNoSync;
|
||||
@@ -63,11 +62,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
IWatermarkSelector watermarkSelector,
|
||||
IGraphicsElementSelector graphicsElementSelector,
|
||||
IDecoSelector decoSelector,
|
||||
IRemoteStreamProber remoteStreamProber,
|
||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
_remoteStreamProber = remoteStreamProber;
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_fileSystem = fileSystem;
|
||||
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
|
||||
@@ -552,7 +549,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Optional(channel.PlayoutOffset),
|
||||
!request.HlsRealtime);
|
||||
case PlayoutItemDoesNotExistOnDisk:
|
||||
case PlayoutItemNotAvailableFromMediaServer:
|
||||
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
@@ -854,15 +850,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
pmf.Path,
|
||||
pmf.Key);
|
||||
|
||||
var plexUrl =
|
||||
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
|
||||
|
||||
if (!await _remoteStreamProber.IsAvailable(plexUrl, cancellationToken))
|
||||
{
|
||||
return new PlayoutItemNotAvailableFromMediaServer(plexUrl);
|
||||
}
|
||||
|
||||
return new PlayoutItemWithPath(playoutItem, plexUrl);
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -878,14 +868,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
foreach (string itemId in jellyfinItemId)
|
||||
{
|
||||
var jellyfinUrl = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
|
||||
|
||||
if (!await _remoteStreamProber.IsAvailable(jellyfinUrl, cancellationToken))
|
||||
{
|
||||
return new PlayoutItemNotAvailableFromMediaServer(jellyfinUrl);
|
||||
}
|
||||
|
||||
return new PlayoutItemWithPath(playoutItem, jellyfinUrl);
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}");
|
||||
}
|
||||
|
||||
// attempt to remotely stream emby
|
||||
@@ -898,14 +883,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
foreach (string itemId in embyItemId)
|
||||
{
|
||||
var embyUrl = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
|
||||
|
||||
if (!await _remoteStreamProber.IsAvailable(embyUrl, cancellationToken))
|
||||
{
|
||||
return new PlayoutItemNotAvailableFromMediaServer(embyUrl);
|
||||
}
|
||||
|
||||
return new PlayoutItemWithPath(playoutItem, embyUrl);
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}");
|
||||
}
|
||||
|
||||
return new PlayoutItemDoesNotExistOnDisk(path);
|
||||
|
||||
@@ -45,8 +45,7 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
|
||||
|
||||
public async Task<TroubleshootingInfo> Handle(GetTroubleshootingInfo request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Support bundle wants current state, so force a fresh run rather than serving the poll cache.
|
||||
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(true, cancellationToken);
|
||||
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
|
||||
string version = Assembly.GetEntryAssembly()?
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.Watermarks;
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
|
||||
new(watermark.Id, watermark.Name, watermark.ImageSource);
|
||||
new(watermark.Id, watermark.Name);
|
||||
|
||||
internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) =>
|
||||
new(
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class ColdStartStartupSplitTests
|
||||
{
|
||||
// milestones are Stopwatch.GetTimestamp() values; build them from a base + millisecond offsets
|
||||
private const long Base = 1_000_000_000;
|
||||
|
||||
private static long At(double milliseconds) =>
|
||||
Base + (long)(milliseconds / 1000.0 * Stopwatch.Frequency);
|
||||
|
||||
[Test]
|
||||
public void Should_Split_Three_Ways_When_All_Milestones_Present()
|
||||
{
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(150),
|
||||
At(1200),
|
||||
At(1600));
|
||||
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
|
||||
split.Prep.TotalMilliseconds.ShouldBe(150, 1);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1);
|
||||
split.FirstGop.TotalMilliseconds.ShouldBe(400, 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sub_Phases_Should_Sum_To_Run_Entry_Through_Playlist()
|
||||
{
|
||||
// deliberately NOT "should sum to startup": the buckets span the worker's Run entry, which
|
||||
// begins before the request thread's startup stopwatch, so prep overlaps the tail of setup
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(150),
|
||||
At(1200),
|
||||
At(1600));
|
||||
|
||||
(split.Prep + split.FFmpegInit + split.FirstGop).TotalMilliseconds.ShouldBe(1600, 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Be_Unavailable_When_The_Playlist_Predates_The_Process_Launch()
|
||||
{
|
||||
// a stale live.m3u8 survives when the handler's pre-session folder wipe fails (EmptyFolder
|
||||
// swallows the failure into a warning). Every bucket would be meaningless, so report nothing
|
||||
// rather than a plausible-looking sample with a prep that exceeds the whole measured phase
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(1600),
|
||||
0,
|
||||
At(150));
|
||||
|
||||
split.ShouldBe(ColdStartStartupSplit.Unavailable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Stale_Playlist_Guard_Should_Take_Precedence_Over_The_Progress_Branches()
|
||||
{
|
||||
// without the guard, this input would be classified TwoWayLateProgress; the guard must be
|
||||
// evaluated first. (It can never preempt a ThreeWay: that requires processLaunched <=
|
||||
// playlistExists, which is exactly the negation of the guard condition.)
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(1600),
|
||||
At(1700),
|
||||
At(150));
|
||||
|
||||
split.ShouldBe(ColdStartStartupSplit.Unavailable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fall_Back_To_Two_Way_Split_When_Progress_Predates_The_Process_Launch()
|
||||
{
|
||||
// a progress timestamp older than the launch cannot belong to this process
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(100),
|
||||
At(150),
|
||||
At(120),
|
||||
At(1600));
|
||||
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
split.FirstGop.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Stay_Three_Way_When_Progress_Coincides_With_A_Boundary()
|
||||
{
|
||||
ColdStartStartupSplit atLaunch = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(150), At(1600));
|
||||
atLaunch.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
|
||||
atLaunch.FFmpegInit.ShouldBe(TimeSpan.Zero);
|
||||
atLaunch.FirstGop.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
|
||||
ColdStartStartupSplit atPlaylist = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(1600), At(1600));
|
||||
atPlaylist.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
|
||||
atPlaylist.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
atPlaylist.FirstGop.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fall_Back_To_Two_Way_Split_When_FFmpeg_Never_Reported_Progress()
|
||||
{
|
||||
// no -progress output before the playlist appeared: ffmpegInit must absorb the remainder
|
||||
// rather than the split inventing a firstGop boundary that was never observed
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(150),
|
||||
0,
|
||||
At(1600));
|
||||
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay);
|
||||
split.Prep.TotalMilliseconds.ShouldBe(150, 1);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
split.FirstGop.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Report_Late_Progress_Distinctly_When_Progress_Arrived_After_The_Playlist()
|
||||
{
|
||||
// the playlist is observed on the request thread while progress is recorded on the worker
|
||||
// thread; a progress milestone outside the phase must not produce a negative bucket
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(150),
|
||||
At(1800),
|
||||
At(1600));
|
||||
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWayLateProgress);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
split.FirstGop.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[TestCase(0L, 150L, 1200L, 1600L, TestName = "Run never started")]
|
||||
[TestCase(100L, 0L, 0L, 1600L, TestName = "Process never launched")]
|
||||
[TestCase(100L, 150L, 1200L, 0L, TestName = "Playlist never appeared")]
|
||||
public void Should_Be_Unavailable_When_A_Required_Milestone_Is_Missing(
|
||||
long runStarted,
|
||||
long processLaunched,
|
||||
long firstProgress,
|
||||
long playlistExists)
|
||||
{
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
runStarted == 0 ? 0 : At(runStarted),
|
||||
processLaunched == 0 ? 0 : At(processLaunched),
|
||||
firstProgress == 0 ? 0 : At(firstProgress),
|
||||
playlistExists == 0 ? 0 : At(playlistExists));
|
||||
|
||||
split.ShouldBe(ColdStartStartupSplit.Unavailable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Clamp_Rather_Than_Report_A_Negative_Prep()
|
||||
{
|
||||
// defensive: launch cannot precede Run entry, but telemetry must never show a negative
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(500),
|
||||
At(150),
|
||||
At(1200),
|
||||
At(1600));
|
||||
|
||||
split.Prep.ShouldBe(TimeSpan.Zero);
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1);
|
||||
split.FirstGop.TotalMilliseconds.ShouldBe(400, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Pins which watermarks ffmpeg may carry natively and which must go to the graphics engine.
|
||||
/// The remote-URL rule is the second half of the #502 fix: resolving the URL is useless if the
|
||||
/// resolved path is then handed to ffmpeg as a bare <c>-i</c> argument.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class FFmpegNativeWatermarkRoutingTests
|
||||
{
|
||||
private const string LocalPath = "/cache/logos/ab/abc123.png";
|
||||
|
||||
private static WatermarkOptions Options(
|
||||
string imagePath,
|
||||
ChannelWatermarkMode mode = ChannelWatermarkMode.Permanent) =>
|
||||
new(new ChannelWatermark { Id = 1, Name = "wm", Mode = mode }, imagePath, Option<int>.None);
|
||||
|
||||
[Test]
|
||||
public void Local_Path_Single_Permanent_Watermark_Uses_FFmpeg()
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath)])
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[TestCase("https://cdn.example.com/logos/channel.png")]
|
||||
[TestCase("http://cdn.example.com/logos/channel.png")]
|
||||
public void Remote_Url_Watermark_Goes_To_Graphics_Engine(string url)
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(url)])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The generated-initials fallback is a localhost URL. Only the deco path still emits it, and it is
|
||||
/// routed by its resolved path like any other URL — see the #502 entry in docs/decisions.md and #510.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Generated_Localhost_Logo_Url_Goes_To_Graphics_Engine()
|
||||
{
|
||||
FFmpegLibraryProcessService
|
||||
.CanUseFFmpegNativeWatermark(0, [Options("http://localhost:8409/iptv/logos/gen?text=Test")])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Graphics_Elements_Present_Goes_To_Graphics_Engine()
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(1, [Options(LocalPath)])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Multiple_Watermarks_Go_To_Graphics_Engine()
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath), Options(LocalPath)])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Watermarks_Does_Not_Use_FFmpeg()
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, []).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestCase(ChannelWatermarkMode.Intermittent)]
|
||||
[TestCase(ChannelWatermarkMode.None)]
|
||||
public void Non_Permanent_Watermark_Goes_To_Graphics_Engine(ChannelWatermarkMode mode)
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath, mode)])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="ChannelWatermarkImageSource.ChannelLogo" /> resolution at all three watermark
|
||||
/// precedence levels (playout item, channel, global). The shared fixture in
|
||||
/// <see cref="WatermarkSelectorTests" /> deliberately makes every watermark file exist, so it cannot
|
||||
/// express the "logo is an external URL" or "logo file is gone" cases this fixture exists for (#502).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WatermarkSelectorChannelLogoTests
|
||||
{
|
||||
private const string ExternalLogoUrl = "https://cdn.example.com/logos/channel.png";
|
||||
private const string LocalLogoPath = "abc123.png";
|
||||
private const string LocalLogoCachePath = "/cache/logos/ab/abc123.png";
|
||||
|
||||
private WatermarkSelector _selector;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var mockFileSystem = new MockFileSystem();
|
||||
mockFileSystem.Initialize().WithFile(LocalLogoCachePath);
|
||||
|
||||
var fakeImageCache = Substitute.For<IImageCache>();
|
||||
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
|
||||
.Returns(_ => LocalLogoCachePath);
|
||||
|
||||
_selector = new WatermarkSelector(
|
||||
mockFileSystem,
|
||||
fakeImageCache,
|
||||
Substitute.For<IDecoSelector>(),
|
||||
NullLogger<WatermarkSelector>.Instance);
|
||||
}
|
||||
|
||||
private static ChannelWatermark ChannelLogoWatermark(int id, string name) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
|
||||
Mode = ChannelWatermarkMode.Permanent
|
||||
};
|
||||
|
||||
private static Channel ChannelWithLogo(string logoPath, ChannelWatermark channelWatermark = null)
|
||||
{
|
||||
var channel = new Channel(Guid.Empty)
|
||||
{
|
||||
Id = 1,
|
||||
Number = "1",
|
||||
Name = "Test",
|
||||
StreamingMode = StreamingMode.TransportStream,
|
||||
Artwork = [],
|
||||
Watermark = channelWatermark,
|
||||
WatermarkId = channelWatermark?.Id
|
||||
};
|
||||
|
||||
if (logoPath is not null)
|
||||
{
|
||||
channel.Artwork.Add(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = logoPath });
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
// ---- external URL logo: render path must degrade to no bug, never fetch (#525) --------------
|
||||
//
|
||||
// As of #525 an external-URL logo is downloaded and cached at save time, so a URL path can only be a
|
||||
// row that failed migration. The render/watermark path must NOT fetch at compositing time: it degrades
|
||||
// to None (no on-screen bug) with a warning, rather than handing the URL downstream as a renderable
|
||||
// ImagePath (the #502 behavior these tests previously pinned).
|
||||
|
||||
[Test]
|
||||
public void PlayoutItemWatermark_Should_Ignore_External_Url_Channel_Logo()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(1, "PlayoutItem");
|
||||
Channel channel = ChannelWithLogo(ExternalLogoUrl);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
watermark,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(ExternalLogoUrl, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
// never hand the URL downstream as a renderable path
|
||||
result.IfSome(o => o.ImagePath.ShouldNotBe(ExternalLogoUrl));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GlobalWatermark_Should_Ignore_External_Url_Channel_Logo()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(3, "Global");
|
||||
Channel channel = ChannelWithLogo(ExternalLogoUrl);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
watermark);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scheme comparison goes through <see cref="Uri" />, which lower-cases it. Pinned because the fix
|
||||
/// turns on <c>Artwork.IsExternalUrl</c>, and a case-sensitive check would silently fall back to the
|
||||
/// existence-gated branch and re-introduce the defect for an oddly-cased URL.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo_Regardless_Of_Scheme_Case()
|
||||
{
|
||||
const string UpperCaseUrl = "HTTPS://cdn.example.com/logos/channel.png";
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(UpperCaseUrl, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- regressions: local-file behavior must not change ---------------------------------------
|
||||
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Use_Cached_Path_For_Local_Channel_Logo()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LocalLogoCachePath);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Be_Ignored_When_Local_Channel_Logo_File_Is_Missing()
|
||||
{
|
||||
var mockFileSystem = new MockFileSystem(); // nothing on disk
|
||||
var fakeImageCache = Substitute.For<IImageCache>();
|
||||
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
|
||||
.Returns(_ => LocalLogoCachePath);
|
||||
|
||||
var selector = new WatermarkSelector(
|
||||
mockFileSystem,
|
||||
fakeImageCache,
|
||||
Substitute.For<IDecoSelector>(),
|
||||
NullLogger<WatermarkSelector>.Instance);
|
||||
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope guard for #502: with no logo artwork at all, the resolved path is the generated-initials
|
||||
/// URL from <see cref="Images.ChannelLogoGenerator.GenerateChannelLogoUrl" />, which hardcodes
|
||||
/// localhost (issue #1). That fallback stays disabled here — reviving it is deliberately deferred
|
||||
/// in docs/decisions.md and is not part of this fix.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(null, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using ErsatzTV.Core.Images;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Images;
|
||||
|
||||
[TestFixture]
|
||||
public class RemoteImageDecodeBudgetTests
|
||||
{
|
||||
private static readonly Uri Uri = new("https://example.com/logo.png");
|
||||
|
||||
// the product is the real bound: 2500x2500 x600 is affordable on each axis alone but not together
|
||||
[Test]
|
||||
public void Should_Reject_Dimensions_And_Frames_Affordable_Alone_But_Not_Together()
|
||||
{
|
||||
((long)2500 * 2500).ShouldBeLessThanOrEqualTo(RemoteImageDecodeBudget.MaxRemoteDecodedPixels);
|
||||
600.ShouldBeLessThanOrEqualTo(RemoteImageDecodeBudget.MaxRemoteFrames);
|
||||
|
||||
InvalidOperationException ex = Should.Throw<InvalidOperationException>(
|
||||
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(2500, 2500, 600, Uri));
|
||||
ex.Message.ShouldContain("pixel limit");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_Too_Many_Frames_Even_When_Each_Is_Tiny() =>
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(8, 8, RemoteImageDecodeBudget.MaxRemoteFrames + 1, Uri))
|
||||
.Message.ShouldContain("frame limit");
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_A_Single_Oversized_Frame() =>
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => RemoteImageDecodeBudget.EnsureDimensionsAffordable(30000, 30000, Uri))
|
||||
.Message.ShouldContain("pixel limit");
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_A_Single_Large_Still_Within_Budget() =>
|
||||
Should.NotThrow(() => RemoteImageDecodeBudget.EnsureDecodeAffordable(7680, 4320, 1, Uri));
|
||||
|
||||
[Test]
|
||||
public void Should_Charge_At_Least_One_Frame_When_Header_Reports_None() =>
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(30000, 30000, 0, Uri));
|
||||
|
||||
[Test]
|
||||
public void Should_Afford_Fewer_Frames_As_Frames_Get_Larger()
|
||||
{
|
||||
RemoteImageDecodeBudget.AffordableFrames(8, 8).ShouldBe(RemoteImageDecodeBudget.MaxRemoteFrames);
|
||||
RemoteImageDecodeBudget.AffordableFrames(1000, 1000).ShouldBe(50);
|
||||
RemoteImageDecodeBudget.AffordableFrames(7000, 7000).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,6 @@ public record ChannelGuideProgrammeResponseModel(
|
||||
public record ChannelGuideChannelResponseModel(
|
||||
string Number,
|
||||
string Name,
|
||||
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
|
||||
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
|
||||
string? Logo,
|
||||
List<ChannelGuideProgrammeResponseModel> Programmes);
|
||||
|
||||
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
|
||||
|
||||
@@ -16,7 +16,4 @@ public record ChannelResponseModel(
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg,
|
||||
int PlayoutCount,
|
||||
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
|
||||
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
|
||||
string? Logo);
|
||||
int PlayoutCount);
|
||||
|
||||
@@ -36,5 +36,4 @@ public record FFmpegFullProfileResponseModel(
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder);
|
||||
bool DeinterlaceVideo);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Watermarks;
|
||||
|
||||
// ImageSource lets a client identify logo-driven presets (the seeded "Channel Bug") without
|
||||
// matching a user-editable name. Additive under the frozen /api/v1 contract (#286).
|
||||
public record WatermarkResponseModel(int Id, string Name, ChannelWatermarkImageSource ImageSource);
|
||||
public record WatermarkResponseModel(int Id, string Name);
|
||||
|
||||
@@ -24,7 +24,6 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
|
||||
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
|
||||
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
|
||||
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
|
||||
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
|
||||
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
|
||||
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -14,7 +14,6 @@ public record FFmpegProfile
|
||||
public VaapiDriver VaapiDriver { get; set; }
|
||||
public string VaapiDevice { get; set; }
|
||||
public int? QsvExtraHardwareFrames { get; set; }
|
||||
public bool? QsvPreferNativeDecoder { get; set; }
|
||||
public int ResolutionId { get; set; }
|
||||
public Resolution Resolution { get; set; }
|
||||
public ScalingBehavior ScalingBehavior { get; set; }
|
||||
@@ -64,7 +63,6 @@ public record FFmpegProfile
|
||||
NormalizeFramerate = false,
|
||||
HardwareAcceleration = HardwareAccelerationKind.None,
|
||||
QsvExtraHardwareFrames = 64,
|
||||
QsvPreferNativeDecoder = true,
|
||||
NormalizeAudio = true,
|
||||
NormalizeVideo = true,
|
||||
NormalizeColors = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum LibraryMediaKind
|
||||
{
|
||||
@@ -8,13 +8,5 @@ public enum LibraryMediaKind
|
||||
OtherVideos = 4,
|
||||
Songs = 5,
|
||||
Images = 6,
|
||||
RemoteStreams = 7,
|
||||
|
||||
/// <summary>
|
||||
/// A library whose contents are heterogeneous - movies, shows and music videos together.
|
||||
/// Only produced for remote (Jellyfin) libraries whose collection type is "mixed", where the
|
||||
/// media server classifies each item for us. A local library is never Mixed: the local folder
|
||||
/// scanners all share one video extension list and would claim each other's files.
|
||||
/// </summary>
|
||||
Mixed = 8
|
||||
RemoteStreams = 7
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Core.Errors;
|
||||
|
||||
public class PlayoutItemNotAvailableFromMediaServer : BaseError
|
||||
{
|
||||
public PlayoutItemNotAvailableFromMediaServer(string url) : base(
|
||||
$"Playout item is not available from media server\n{url}")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// How finely a cold-start's startup work could be broken down (#472).
|
||||
/// </summary>
|
||||
public enum ColdStartStartupSplitKind
|
||||
{
|
||||
/// <summary>
|
||||
/// No split available: the FFmpeg process was never launched, the playlist never appeared, or the
|
||||
/// playlist was observed before FFmpeg was launched (a stale playlist left behind because the
|
||||
/// pre-session transcode-folder wipe failed — it logs a warning and continues).
|
||||
/// </summary>
|
||||
Unavailable = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Two-way split: <c>prep</c> + <c>ffmpegInit</c>, because FFmpeg emitted no progress output at all
|
||||
/// before the playlist appeared. <c>ffmpegInit</c> therefore runs to the playlist.
|
||||
/// </summary>
|
||||
TwoWay = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Two-way split, distinguished because FFmpeg <em>did</em> report progress but only after the
|
||||
/// playlist was observed. Same buckets as <see cref="TwoWay"/>; kept separate because it means the
|
||||
/// playlist appeared before the first progress report rather than FFmpeg being silent, which is a
|
||||
/// different story about the pipeline (and is also what the 100ms playlist poll can manufacture).
|
||||
/// </summary>
|
||||
TwoWayLateProgress = 2,
|
||||
|
||||
/// <summary>Three-way split: <c>prep</c> + <c>ffmpegInit</c> + <c>firstGop</c>.</summary>
|
||||
ThreeWay = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sub-split of the HLS cold-start startup work (#472), which #350's measurement showed to be 81% of
|
||||
/// tune-in latency and to carry 100% of its variance while remaining a single opaque bucket.
|
||||
/// <para>
|
||||
/// <see cref="Prep"/> is ErsatzTV-side work before FFmpeg exists: playout-item resolution, pipeline
|
||||
/// build, graphics-engine spawn. <see cref="FFmpegInit"/> is FFmpeg from launch until it first reports
|
||||
/// progress — input open + probe (the NFS hypothesis) plus decoder/encoder init (the VAAPI-contention
|
||||
/// hypothesis). <see cref="FirstGop"/> is from that first progress report until <c>live.m3u8</c> exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>These buckets span the session worker's <c>Run</c> entry to the playlist appearing, which is NOT
|
||||
/// exactly the logged <c>startup</c> phase</b>: the worker is launched fire-and-forget slightly before
|
||||
/// the request thread starts the <c>startup</c> stopwatch, so <see cref="Prep"/> overlaps the tail of
|
||||
/// the logged <c>setup</c> bucket (in practice one config read). Do not expect
|
||||
/// <c>prep + ffmpegInit + firstGop</c> to equal <c>startup</c> — it is a superset by that overlap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Because the pipeline runs <c>-loglevel error -nostats -hide_banner</c>, a healthy FFmpeg writes
|
||||
/// nothing to stderr, so input-open and encoder-init cannot be separated from each other without
|
||||
/// changing the FFmpeg command — which this instrumentation deliberately does not do. The
|
||||
/// <c>-progress</c> stream on stdout is therefore the only zero-cost milestone available, and
|
||||
/// <see cref="FFmpegInit"/> necessarily lumps those two candidates together. #472 accepts this: a
|
||||
/// large <see cref="Prep"/> vs a large <see cref="FFmpegInit"/> is itself the first discrimination,
|
||||
/// and it is honest about what it cannot yet see.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Three further caveats when reading these numbers. The playlist is detected by a 100ms poll, so its
|
||||
/// timestamp is up to 100ms late and that error lands entirely in <see cref="FirstGop"/> — the
|
||||
/// smallest bucket — and can also flip a sample between <see cref="ColdStartStartupSplitKind.ThreeWay"/>
|
||||
/// and <see cref="ColdStartStartupSplitKind.TwoWayLateProgress"/>. And if the session's first FFmpeg
|
||||
/// process fails and a second one produces the playlist, <see cref="FFmpegInit"/> spans the first
|
||||
/// process's whole lifetime plus the retry while still being labelled as one process's init. And the
|
||||
/// stale-playlist guard below is best-effort rather than a proof: if the folder wipe failed, whether
|
||||
/// the stale playlist is observed before or after the launch milestone is a scheduling race, so an
|
||||
/// unlucky sample could still slip through as an implausibly fast one (most often
|
||||
/// <see cref="ColdStartStartupSplitKind.TwoWay"/>, since FFmpeg has usually not reported progress
|
||||
/// that early).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct ColdStartStartupSplit(
|
||||
TimeSpan Prep,
|
||||
TimeSpan FFmpegInit,
|
||||
TimeSpan FirstGop,
|
||||
ColdStartStartupSplitKind Kind)
|
||||
{
|
||||
public static readonly ColdStartStartupSplit Unavailable =
|
||||
new(TimeSpan.Zero, TimeSpan.Zero, TimeSpan.Zero, ColdStartStartupSplitKind.Unavailable);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the split from four <see cref="Stopwatch.GetTimestamp"/> milestones; <c>0</c> means the
|
||||
/// milestone never happened. Milestones are recorded on different threads (the session worker
|
||||
/// records the launch and progress ones; the request thread observes the playlist), so ordering is
|
||||
/// validated rather than assumed: any out-of-order or missing milestone degrades the result to a
|
||||
/// coarser <see cref="ColdStartStartupSplitKind"/> instead of producing a negative or invented bucket.
|
||||
/// </summary>
|
||||
public static ColdStartStartupSplit FromTimestamps(
|
||||
long runStarted,
|
||||
long processLaunched,
|
||||
long firstProgress,
|
||||
long playlistExists)
|
||||
{
|
||||
if (runStarted <= 0 || processLaunched <= 0 || playlistExists <= 0)
|
||||
{
|
||||
return Unavailable;
|
||||
}
|
||||
|
||||
if (processLaunched > playlistExists)
|
||||
{
|
||||
// the playlist was observed before FFmpeg was even launched, so it is a stale file: the
|
||||
// handler wipes the transcode folder before starting the session, but that wipe swallows
|
||||
// its failures into a warning (LocalFileSystem.EmptyFolder) and continues.
|
||||
// Every bucket would be meaningless; report nothing rather than a plausible-looking sample
|
||||
return Unavailable;
|
||||
}
|
||||
|
||||
// the worker's Run entry strictly precedes every later milestone; clamp anyway so a clock
|
||||
// oddity can never surface as a negative duration in telemetry
|
||||
TimeSpan prep = Elapsed(runStarted, processLaunched);
|
||||
|
||||
if (firstProgress <= 0 || firstProgress < processLaunched)
|
||||
{
|
||||
// FFmpeg reported no usable progress before the playlist appeared: fall back to the two-way
|
||||
// split #472 explicitly accepts, rather than inventing a boundary that was never observed
|
||||
return new ColdStartStartupSplit(
|
||||
prep,
|
||||
Elapsed(processLaunched, playlistExists),
|
||||
TimeSpan.Zero,
|
||||
ColdStartStartupSplitKind.TwoWay);
|
||||
}
|
||||
|
||||
if (firstProgress > playlistExists)
|
||||
{
|
||||
return new ColdStartStartupSplit(
|
||||
prep,
|
||||
Elapsed(processLaunched, playlistExists),
|
||||
TimeSpan.Zero,
|
||||
ColdStartStartupSplitKind.TwoWayLateProgress);
|
||||
}
|
||||
|
||||
return new ColdStartStartupSplit(
|
||||
prep,
|
||||
Elapsed(processLaunched, firstProgress),
|
||||
Elapsed(firstProgress, playlistExists),
|
||||
ColdStartStartupSplitKind.ThreeWay);
|
||||
}
|
||||
|
||||
private static TimeSpan Elapsed(long from, long to)
|
||||
{
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(from, to);
|
||||
return elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
@@ -68,29 +68,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether ffmpeg can carry the watermark itself (a single permanent watermark and no other graphics),
|
||||
/// rather than handing it to the graphics engine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A watermark whose resolved path is a remote URL is always refused here (#502). ffmpeg would receive it
|
||||
/// as a bare <c>-i</c> argument — and <c>ffprobe</c> the same string for animation detection — putting an
|
||||
/// unbounded network fetch inside stream startup, with no timeout, redirect or auth handling of ours, and
|
||||
/// with the image's real dimensions never probed. The graphics engine fetches remote images deliberately
|
||||
/// (<c>ImageElementBase.LoadImage</c>) and decodes them for their true size.
|
||||
/// <para>
|
||||
/// This is decided by the resolved <see cref="WatermarkOptions.ImagePath" /> alone, so it applies
|
||||
/// uniformly however the watermark was selected — channel, global, playout item <em>or</em> deco. Only
|
||||
/// `ChannelLogo` watermarks can produce a URL: `Custom` resolves through the image cache and
|
||||
/// `Resource` through the resources folder, so neither is ever rerouted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static bool CanUseFFmpegNativeWatermark(int graphicsElementCount, List<WatermarkOptions> watermarks) =>
|
||||
graphicsElementCount == 0
|
||||
&& watermarks.Count == 1
|
||||
&& watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent
|
||||
&& !Artwork.IsExternalUrl(wm.ImagePath));
|
||||
|
||||
public async Task<PlayoutItemResult> ForPlayoutItem(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
@@ -402,7 +379,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
|
||||
List<GraphicsElementContext> graphicsElementContexts = [];
|
||||
|
||||
if (CanUseFFmpegNativeWatermark(graphicsElements.Count, watermarks))
|
||||
// use ffmpeg for single permanent watermark, graphics engine for all others
|
||||
if (graphicsElements.Count == 0 && watermarks.Count == 1 && watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent))
|
||||
{
|
||||
foreach (var wm in watermarks)
|
||||
{
|
||||
@@ -609,8 +587,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
channel.FFmpegProfile.QsvPreferNativeDecoder != false);
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace ErsatzTV.Core.FFmpeg;
|
||||
/// FFmpeg process spawn + probe + libass/encoder init + first GOP, since the wait begins right
|
||||
/// after the fire-and-forget worker is launched); <see cref="SegmentFill"/> is Phase B (playlist
|
||||
/// exists -> the requested number of segments are present, or the 8s deadline).
|
||||
/// <see cref="StartupSplit"/> breaks Phase A down further (#472).
|
||||
/// </summary>
|
||||
public readonly record struct PlaylistSegmentsResult(
|
||||
TimeSpan ProcessStartup,
|
||||
@@ -14,5 +13,4 @@ public readonly record struct PlaylistSegmentsResult(
|
||||
int SegmentsReached,
|
||||
int InitialSegmentCount,
|
||||
bool DeadlineExpired,
|
||||
ColdStartFeatures Features,
|
||||
ColdStartStartupSplit StartupSplit);
|
||||
ColdStartFeatures Features);
|
||||
|
||||
@@ -216,7 +216,25 @@ public class WatermarkSelector(
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
logger.LogDebug("Watermark will come from playout item (channel logo)");
|
||||
|
||||
return ChannelLogoWatermarkOptions(channel, watermark);
|
||||
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
|
||||
Option<Artwork> maybeLogoArtwork =
|
||||
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
|
||||
foreach (var logoArtwork in maybeLogoArtwork)
|
||||
{
|
||||
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
|
||||
? logoArtwork.Path
|
||||
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
|
||||
}
|
||||
|
||||
if (fileSystem.File.Exists(channelPath))
|
||||
{
|
||||
return new WatermarkOptions(watermark, channelPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Channel logo no longer exists at {Path} and will be ignored",
|
||||
channelPath);
|
||||
return None;
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
@@ -247,7 +265,25 @@ public class WatermarkSelector(
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
logger.LogDebug("Watermark will come from channel (channel logo)");
|
||||
|
||||
return ChannelLogoWatermarkOptions(channel, channel.Watermark);
|
||||
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
|
||||
Option<Artwork> maybeLogoArtwork =
|
||||
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
|
||||
foreach (var logoArtwork in maybeLogoArtwork)
|
||||
{
|
||||
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
|
||||
? logoArtwork.Path
|
||||
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
|
||||
}
|
||||
|
||||
if (fileSystem.File.Exists(channelPath))
|
||||
{
|
||||
return new WatermarkOptions(channel.Watermark, channelPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Channel logo no longer exists at {Path} and will be ignored",
|
||||
channelPath);
|
||||
return None;
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
@@ -278,7 +314,25 @@ public class WatermarkSelector(
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
logger.LogDebug("Watermark will come from global (channel logo)");
|
||||
|
||||
return ChannelLogoWatermarkOptions(channel, watermark);
|
||||
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
|
||||
Option<Artwork> maybeLogoArtwork =
|
||||
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
|
||||
foreach (var logoArtwork in maybeLogoArtwork)
|
||||
{
|
||||
channelPath = Artwork.IsExternalUrl(logoArtwork.Path)
|
||||
? logoArtwork.Path
|
||||
: imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
|
||||
}
|
||||
|
||||
if (fileSystem.File.Exists(channelPath))
|
||||
{
|
||||
return new WatermarkOptions(watermark, channelPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Channel logo no longer exists at {Path} and will be ignored",
|
||||
channelPath);
|
||||
return None;
|
||||
default:
|
||||
throw new NotSupportedException("Unsupported watermark image source");
|
||||
}
|
||||
@@ -287,52 +341,6 @@ public class WatermarkSelector(
|
||||
return Option<WatermarkOptions>.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <see cref="ChannelWatermarkImageSource.ChannelLogo" /> watermark to a renderable path,
|
||||
/// shared by the playout-item, channel and global precedence levels so all three agree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As of #525 an external-URL logo is downloaded and cached at save time, so a URL path here can only
|
||||
/// be a row that failed migration. The render path must never fetch at compositing time, so such a row
|
||||
/// degrades to no watermark (no on-screen bug) with a warning rather than being handed downstream as a
|
||||
/// renderable URL (the #502 behavior). Other consumers (M3U, XMLTV, SPA JSON) still emit the raw URL for
|
||||
/// a not-yet-migrated row; only this render/watermark path changed.
|
||||
/// </remarks>
|
||||
private Option<WatermarkOptions> ChannelLogoWatermarkOptions(Channel channel, ChannelWatermark watermark)
|
||||
{
|
||||
foreach (var logoArtwork in Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo)))
|
||||
{
|
||||
if (Artwork.IsExternalUrl(logoArtwork.Path))
|
||||
{
|
||||
// As of #525 an external-URL logo is downloaded and cached at save time, so a URL here
|
||||
// means a row that failed migration. Do not fetch at render time; degrade to no bug.
|
||||
logger.LogWarning(
|
||||
"Channel logo for channel {Channel} is still an un-downloaded URL {Url}; re-save the "
|
||||
+ "channel to download it. Rendering without an on-screen bug.",
|
||||
channel.Number,
|
||||
logoArtwork.Path);
|
||||
return None;
|
||||
}
|
||||
|
||||
string cachedPath = imageCache.GetPathForImage(logoArtwork.Path, ArtworkKind.Logo, Option<int>.None);
|
||||
if (fileSystem.File.Exists(cachedPath))
|
||||
{
|
||||
return new WatermarkOptions(watermark, cachedPath, None);
|
||||
}
|
||||
|
||||
logger.LogWarning("Channel logo no longer exists at {Path} and will be ignored", cachedPath);
|
||||
return None;
|
||||
}
|
||||
|
||||
// with no logo artwork the only candidate is the generated-initials image, whose URL hardcodes
|
||||
// localhost (ChannelLogoGenerator.GenerateChannelLogoUrl, issue #1). It has never rendered here and
|
||||
// reviving it is deliberately deferred in docs/decisions.md, so it stays ignored.
|
||||
logger.LogWarning(
|
||||
"Channel logo no longer exists at {Path} and will be ignored",
|
||||
ChannelLogoGenerator.GenerateChannelLogoUrl(channel));
|
||||
return None;
|
||||
}
|
||||
|
||||
private List<WatermarkOptions> OptionsForWatermarks(Channel channel, IEnumerable<ChannelWatermark> watermarks)
|
||||
{
|
||||
var result = new List<WatermarkOptions>();
|
||||
@@ -374,14 +382,6 @@ public class WatermarkSelector(
|
||||
customPath,
|
||||
None);
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
// deliberately NOT ChannelLogoWatermarkOptions: the deco path has always passed its resolved
|
||||
// path through unchecked, so #502's File.Exists defect never reached it and its *resolution*
|
||||
// is unchanged here. Aligning its missing-file / no-artwork policy with the three precedence
|
||||
// levels above is a behavior change beyond this fix — tracked in #510.
|
||||
// Note this only scopes resolution: the ffmpeg-native-vs-graphics-engine routing in
|
||||
// FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark keys off the resolved path alone, so a
|
||||
// deco watermark resolving to a URL (an external logo, or the generated-initials URL below) is
|
||||
// rerouted to the graphics engine like any other. That is intended: it is the URL-aware path.
|
||||
string channelPath = ChannelLogoGenerator.GenerateChannelLogoUrl(channel);
|
||||
Option<Artwork> maybeLogoArtwork =
|
||||
Optional(channel.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Logo));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ErsatzTV.Core.Health;
|
||||
namespace ErsatzTV.Core.Health;
|
||||
|
||||
public interface IHealthCheckService
|
||||
{
|
||||
Task<List<HealthCheckResult>> PerformHealthChecks(bool forceRefresh, CancellationToken cancellationToken);
|
||||
Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken);
|
||||
HealthCheckSummary GetHealthCheckSummary();
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
namespace ErsatzTV.Core.Images;
|
||||
|
||||
/// <summary>
|
||||
/// The decode-budget policy for a remote image, as pure arithmetic so it can be enforced both at
|
||||
/// render time (graphics engine) and at save time (logo download) without materializing
|
||||
/// multi-gigabyte images. Extracted from ImageElementBase for reuse. (ersatztv#525, from #511.)
|
||||
/// </summary>
|
||||
public static class RemoteImageDecodeBudget
|
||||
{
|
||||
/// <summary>
|
||||
/// Ceiling on TOTAL decoded pixels — width x height x frames, as one product. Checking
|
||||
/// dimensions and frame count independently does not bound the decode: a 60 KiB 2500x2500 x600
|
||||
/// GIF passes both a 50 MP dimension check and a 600 frame check and costs ~14 GiB.
|
||||
/// </summary>
|
||||
public const long MaxRemoteDecodedPixels = 50_000_000;
|
||||
|
||||
/// <summary>Frame ceiling, a cheap legible guard against absurd counts of tiny frames.</summary>
|
||||
public const int MaxRemoteFrames = 600;
|
||||
|
||||
public static void EnsureDimensionsAffordable(int width, int height, Uri uri)
|
||||
{
|
||||
long pixels = (long)width * height;
|
||||
if (pixels > MaxRemoteDecodedPixels)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote image {uri} is {width}x{height} ({pixels} pixels), over the "
|
||||
+ $"{MaxRemoteDecodedPixels} pixel limit");
|
||||
}
|
||||
}
|
||||
|
||||
public static int AffordableFrames(int width, int height)
|
||||
{
|
||||
long perFrame = Math.Max((long)width * height, 1);
|
||||
return (int)Math.Clamp(MaxRemoteDecodedPixels / perFrame, 1, MaxRemoteFrames);
|
||||
}
|
||||
|
||||
public static void EnsureDecodeAffordable(int width, int height, int frameCount, Uri uri)
|
||||
{
|
||||
int frames = Math.Max(frameCount, 1);
|
||||
if (frames > MaxRemoteFrames)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote image {uri} has {frames} frames, over the {MaxRemoteFrames} frame limit");
|
||||
}
|
||||
|
||||
long totalPixels = (long)width * height * frames;
|
||||
if (totalPixels > MaxRemoteDecodedPixels)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote image {uri} decodes to {width}x{height} x{frames} frames "
|
||||
+ $"({totalPixels} pixels), over the {MaxRemoteDecodedPixels} pixel limit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Images;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a stream is a decodable image within the decode budget, throwing if not.
|
||||
/// Used by the logo save path and the artwork upload path (neither needs the decoded pixels,
|
||||
/// only "is this safe to cache"). The graphics engine uses the static
|
||||
/// RemoteImageValidator.DecodeAndValidate instead, which returns the Image it composites.
|
||||
/// (ersatztv#525)
|
||||
/// </summary>
|
||||
public interface IRemoteImageValidator
|
||||
{
|
||||
Task Validate(Stream stream, Uri uri, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Images;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches an external logo URL, validates it against the decode budget, and stores it in the
|
||||
/// image cache — turning a URL into a cache name so it is thereafter identical to an uploaded
|
||||
/// logo. Errors are returned, not thrown, so a save handler can surface a 400. (ersatztv#525)
|
||||
/// </summary>
|
||||
public interface IRemoteLogoCacher
|
||||
{
|
||||
Task<Either<BaseError, string>> CacheFromUrl(Uri uri, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -23,17 +23,6 @@ public interface ILibraryRepository
|
||||
Task SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
|
||||
Task CleanEtagsForLibraryPath(LibraryPath libraryPath);
|
||||
Task<Option<int>> GetParentFolderId(LibraryPath libraryPath, string folder, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <see cref="LibraryFolder" /> at <paramref name="folder" /> under
|
||||
/// <paramref name="libraryPath" />, creating it if it does not yet exist.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The existing folder is looked up from the database by <c>(LibraryPathId, Path)</c>. Callers do
|
||||
/// <b>not</b> need to eager-load <see cref="LibraryPath.LibraryFolders" /> — the remote (Jellyfin)
|
||||
/// sync path never does, and relying on that navigation collection here previously NRE'd every
|
||||
/// Jellyfin music-video scan (ersatztv#488).
|
||||
/// </remarks>
|
||||
Task<LibraryFolder> GetOrAddFolder(LibraryPath libraryPath, Option<int> maybeParentFolder, string folder);
|
||||
Task UpdateLibraryFolderId(MediaFile mediaFile, int libraryFolderId);
|
||||
Task UpdatePath(LibraryPath libraryPath, string normalizedLibraryPath);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -47,16 +47,6 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
|
||||
TLibrary library,
|
||||
List<string> episodeItemIds,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Cascade helpers (#476): when a parent is swept to FileNotFound because it is gone from the media
|
||||
// server, the per-parent loop never visits it, so its descendants are never reconciled. These flag
|
||||
// the descendants by parent MediaItem.Id (Season.ShowId / Episode.SeasonId are on the base tables).
|
||||
Task<List<int>> FlagFileNotFoundSeasonsForShows(
|
||||
List<int> showIds,
|
||||
CancellationToken cancellationToken);
|
||||
Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
|
||||
List<int> seasonIds,
|
||||
CancellationToken cancellationToken);
|
||||
Task<Option<int>> FlagUnavailable(TLibrary library, TEpisode episode, CancellationToken cancellationToken);
|
||||
Task<Option<int>> FlagRemoteOnly(TLibrary library, TEpisode episode, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a remote (http/https) image for the graphics engine, under a bounded timeout and a
|
||||
/// bounded response size.
|
||||
/// </summary>
|
||||
public interface IRemoteImageFetcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches <paramref name="uri" /> fully into memory.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A seekable, fully-buffered stream positioned at zero. The caller owns and must dispose it.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Throws rather than returning a failure value: every call site already wraps element
|
||||
/// initialization in a catch that disables the element, so a throw degrades to "no watermark"
|
||||
/// rather than a killed stream. An implementation's own deadline should surface as
|
||||
/// <see cref="TimeoutException" /> and caller cancellation as
|
||||
/// <see cref="OperationCanceledException" /> — though note the current call sites catch both
|
||||
/// alike, so today the distinction only sharpens the log message.
|
||||
/// </remarks>
|
||||
Task<Stream> Fetch(Uri uri, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a media-server remote-stream URL still resolves to playable media.
|
||||
/// </summary>
|
||||
public interface IRemoteStreamProber
|
||||
{
|
||||
/// <summary>
|
||||
/// Probes <paramref name="url" />, following redirects as ffmpeg would.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>false</c> only when the media server itself reported the media gone — i.e. a 404 that
|
||||
/// arrived <em>after</em> ErsatzTV's own <c>/media/{provider}/...</c> endpoint redirected.
|
||||
/// Every other outcome returns <c>true</c> (fail-open), including an un-redirected 404: that
|
||||
/// one came from ErsatzTV's own endpoint, which also 404s when the media source is
|
||||
/// unconfigured or momentarily missing, and honouring it would blank every item on that
|
||||
/// source. Timeouts, transport failures and all other status codes likewise return
|
||||
/// <c>true</c>, so a probe that cannot answer never prevents a tune that would have worked.
|
||||
/// </returns>
|
||||
/// <exception cref="OperationCanceledException">
|
||||
/// May propagate when <paramref name="cancellationToken" /> is cancelled while the probe is
|
||||
/// in flight. Caller cancellation is a genuine signal (shutdown / client disconnect), not a
|
||||
/// probe failure, so it is not absorbed by the fail-open behaviour above. Cancelling after
|
||||
/// the probe has already completed returns normally. The prober's own internal timeout does
|
||||
/// <em>not</em> throw — it fails open.
|
||||
/// </exception>
|
||||
Task<bool> IsAvailable(string url, CancellationToken cancellationToken);
|
||||
}
|
||||
-16
@@ -35,20 +35,4 @@ public class QsvHardwareAccelerationOptionTests
|
||||
"-filter_hw_device", "hw"
|
||||
]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GlobalOptions_WithHardwareDecode_AndPreferNative_ShouldUseVaapiDecodeToSoftware()
|
||||
{
|
||||
var option = new QsvHardwareAccelerationOption("/dev/dri/renderD128", FFmpegCapability.Hardware, preferNativeDecoder: true);
|
||||
|
||||
option.GlobalOptions.ShouldBe(
|
||||
[
|
||||
"-hwaccel", "vaapi",
|
||||
"-init_hw_device", "vaapi=va:/dev/dri/renderD128",
|
||||
"-init_hw_device", "qsv=hw@va",
|
||||
"-filter_hw_device", "hw"
|
||||
]);
|
||||
// must NOT keep frames on the GPU as VA-API surfaces
|
||||
option.GlobalOptions.ShouldNotContain("-hwaccel_output_format");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
using ErsatzTV.FFmpeg.Pipeline;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Tests.Pipeline;
|
||||
|
||||
[TestFixture]
|
||||
public class QsvPipelineBuilderTests
|
||||
{
|
||||
private readonly ILogger _logger = Substitute.For<ILogger>();
|
||||
|
||||
[Test]
|
||||
public void Qsv_PreferNativeDecoder_Should_Decode_Via_Vaapi_To_Software_Then_Qsv_Encode()
|
||||
{
|
||||
string command = BuildAndPrint(preferNativeDecoder: true);
|
||||
|
||||
// VA-API decode, frames downloaded to software (NO hwaccel_output_format)
|
||||
command.ShouldContain("-hwaccel vaapi");
|
||||
command.ShouldNotContain("-hwaccel_output_format");
|
||||
command.ShouldNotContain("-hwaccel qsv");
|
||||
// no QSV *decoder* input option (decoder input options sit directly before "-readrate"/"-i";
|
||||
// "-c:v h264_qsv -" alone would also match the encoder's "-c:v h264_qsv -low_power ..." output option)
|
||||
command.ShouldNotContain("-c:v h264_qsv -readrate");
|
||||
// derived-device chain retained for the QSV encoder
|
||||
command.ShouldContain("-init_hw_device vaapi=va:/dev/dri/renderD128");
|
||||
command.ShouldContain("-init_hw_device qsv=hw@va");
|
||||
// software frames re-uploaded before QSV filters/encoder (proves NO bare vpp_qsv on VA-API frames)
|
||||
command.ShouldContain("hwupload=extra_hw_frames");
|
||||
// QSV encoder still used
|
||||
command.ShouldContain("h264_qsv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Qsv_Default_Should_Decode_And_Encode_With_Qsv()
|
||||
{
|
||||
string command = BuildAndPrint(preferNativeDecoder: false);
|
||||
|
||||
command.ShouldContain("-hwaccel qsv");
|
||||
command.ShouldContain("-hwaccel_output_format qsv");
|
||||
command.ShouldContain("h264_qsv");
|
||||
command.ShouldNotContain("-hwaccel vaapi");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Qsv_PreferNativeDecoder_Interlaced_Should_Hwupload_Before_Deinterlace_Qsv()
|
||||
{
|
||||
string command = BuildInterlacedAndPrint();
|
||||
|
||||
// VA-API decode, software frames
|
||||
command.ShouldContain("-hwaccel vaapi");
|
||||
command.ShouldNotContain("-hwaccel_output_format");
|
||||
|
||||
// software frames re-uploaded BEFORE deinterlace_qsv (never a bare deinterlace_qsv on VA-API frames)
|
||||
command.ShouldContain("hwupload=extra_hw_frames");
|
||||
command.ShouldContain("hwupload=extra_hw_frames=64,deinterlace_qsv");
|
||||
// exactly one deinterlace_qsv, and (assertion above) it is preceded by hwupload — so
|
||||
// there is no second, bare deinterlace_qsv running on VA-API frames
|
||||
(command.Split("deinterlace_qsv").Length - 1).ShouldBe(1);
|
||||
|
||||
command.ShouldContain("h264_qsv");
|
||||
}
|
||||
|
||||
// ersatztv#529: a stored qsvExtraHardwareFrames of 0 produced hwupload=extra_hw_frames=0, which
|
||||
// leaves the QSV pool no headroom. Measured on the deployed FFmpeg 8.1.2: with 0 the filter graph
|
||||
// fails with -12 and writes zero segments as soon as the input is not throttled (a work-ahead
|
||||
// start, or #350's cold-start burst); with 64 the same command writes segments either way.
|
||||
[TestCase(-1)]
|
||||
[TestCase(0)]
|
||||
[TestCase(1)]
|
||||
[TestCase(63)]
|
||||
public void Qsv_Should_Never_Upload_With_Less_Than_Minimum_Extra_Hardware_Frames(int configured)
|
||||
{
|
||||
string command = BuildAndPrint(preferNativeDecoder: true, maybeExtraHardwareFrames: configured);
|
||||
|
||||
// pinned to the literal value measured against the deployed FFmpeg, not to the constant, so
|
||||
// that lowering the floor in code cannot quietly satisfy this test
|
||||
command.ShouldContain("hwupload=extra_hw_frames=64");
|
||||
|
||||
// every upload site in the whole command, not just the one this pipeline happens to emit
|
||||
ShouldNeverUploadBelowMinimum(command);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Qsv_Interlaced_Should_Never_Deinterlace_Upload_Below_Minimum_Extra_Hardware_Frames()
|
||||
{
|
||||
// the deinterlace upload is a separate formatter fed from QsvPipelineBuilder, so it needs
|
||||
// its own guard (ersatztv#529)
|
||||
string command = BuildInterlacedAndPrint(maybeExtraHardwareFrames: 0);
|
||||
|
||||
command.ShouldContain("hwupload=extra_hw_frames=64,deinterlace_qsv");
|
||||
ShouldNeverUploadBelowMinimum(command);
|
||||
}
|
||||
|
||||
private static void ShouldNeverUploadBelowMinimum(string command)
|
||||
{
|
||||
MatchCollection matches = Regex.Matches(command, @"extra_hw_frames=(-?\d+)");
|
||||
matches.Count.ShouldBeGreaterThan(0, "expected the pipeline to upload to QSV at all");
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
int.Parse(match.Groups[1].Value)
|
||||
.ShouldBeGreaterThanOrEqualTo(FFmpegState.MinimumQsvExtraHardwareFrames, command);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Qsv_Should_Honor_Extra_Hardware_Frames_Above_The_Minimum()
|
||||
{
|
||||
string command = BuildAndPrint(preferNativeDecoder: true, maybeExtraHardwareFrames: 128);
|
||||
|
||||
command.ShouldContain("hwupload=extra_hw_frames=128");
|
||||
}
|
||||
|
||||
private string BuildInterlacedAndPrint(Option<int> maybeExtraHardwareFrames = default) =>
|
||||
BuildAndPrint(
|
||||
preferNativeDecoder: true,
|
||||
maybeExtraHardwareFrames,
|
||||
ScanKind.Interlaced,
|
||||
deinterlace: true);
|
||||
|
||||
private string BuildAndPrint(
|
||||
bool preferNativeDecoder,
|
||||
Option<int> maybeExtraHardwareFrames = default,
|
||||
ScanKind scanKind = ScanKind.Progressive,
|
||||
bool deinterlace = false)
|
||||
{
|
||||
(VideoInputFile videoInputFile, AudioInputFile audioInputFile, FFmpegState ffmpegState, FrameState desiredState) =
|
||||
BuildQsvH264Pipeline(preferNativeDecoder, scanKind, deinterlace);
|
||||
|
||||
ffmpegState = ffmpegState with { MaybeQsvExtraHardwareFrames = maybeExtraHardwareFrames };
|
||||
|
||||
var builder = new QsvPipelineBuilder(
|
||||
new DefaultFFmpegCapabilities(),
|
||||
new DefaultHardwareCapabilities(),
|
||||
HardwareAccelerationMode.Qsv,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Option<GraphicsEngineInput>.None,
|
||||
"",
|
||||
"",
|
||||
_logger);
|
||||
|
||||
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
|
||||
|
||||
return PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
|
||||
}
|
||||
|
||||
private static (VideoInputFile, AudioInputFile, FFmpegState, FrameState) BuildQsvH264Pipeline(
|
||||
bool preferNativeDecoder,
|
||||
ScanKind scanKind,
|
||||
bool deinterlace)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<VideoStream>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
VideoFormat.H264,
|
||||
VideoProfile.Main,
|
||||
new PixelFormatYuv420P(),
|
||||
ColorParams.Default,
|
||||
new FrameSize(1920, 1080),
|
||||
"1:1",
|
||||
"16:9",
|
||||
FrameRate.DefaultFrameRate,
|
||||
false,
|
||||
scanKind)
|
||||
});
|
||||
|
||||
var audioInputFile = new AudioInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
|
||||
new AudioState(
|
||||
AudioFormat.Aac,
|
||||
2,
|
||||
320,
|
||||
640,
|
||||
48,
|
||||
false,
|
||||
AudioFilter.None,
|
||||
Option<double>.None));
|
||||
|
||||
var desiredState = new FrameState(
|
||||
true,
|
||||
false,
|
||||
VideoFormat.H264,
|
||||
VideoProfile.Main,
|
||||
VideoPreset.Unset,
|
||||
false,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(1280, 720),
|
||||
new FrameSize(1280, 720),
|
||||
Option<FrameSize>.None,
|
||||
FFmpegFilterMode.Software,
|
||||
false,
|
||||
Option<FrameRate>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false,
|
||||
deinterlace);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.Qsv,
|
||||
HardwareAccelerationMode.Qsv,
|
||||
Option<string>.None,
|
||||
"/dev/dri/renderD128",
|
||||
Option<TimeSpan>.None,
|
||||
Option<TimeSpan>.None,
|
||||
false,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
OutputFormatKind.MpegTs,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
TimeSpan.Zero,
|
||||
Option<int>.None,
|
||||
Option<int>.None,
|
||||
false,
|
||||
false,
|
||||
"linear",
|
||||
false,
|
||||
preferNativeDecoder);
|
||||
|
||||
return (videoInputFile, audioInputFile, ffmpegState, desiredState);
|
||||
}
|
||||
|
||||
private static string PrintCommand(
|
||||
Option<VideoInputFile> videoInputFile,
|
||||
Option<AudioInputFile> audioInputFile,
|
||||
Option<WatermarkInputFile> watermarkInputFile,
|
||||
Option<ConcatInputFile> concatInputFile,
|
||||
Option<GraphicsEngineInput> graphicsEngineInput,
|
||||
FFmpegPipeline pipeline)
|
||||
{
|
||||
IList<string> arguments = CommandGenerator.GenerateArguments(
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
watermarkInputFile,
|
||||
concatInputFile,
|
||||
graphicsEngineInput,
|
||||
pipeline.PipelineSteps,
|
||||
pipeline.IsIntelVaapiOrQsv);
|
||||
|
||||
var command = string.Join(" ", arguments);
|
||||
|
||||
Console.WriteLine($"Generated command: ffmpeg {string.Join(" ", arguments)}");
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
public class DefaultFFmpegCapabilities() : FFmpegCapabilities(
|
||||
string.Empty,
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>());
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Encoder;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
@@ -532,168 +531,6 @@ public class PipelineBuilderBaseTests
|
||||
"-nostdin -hide_banner -nostats -loglevel error -i /test/input/file.png -vf scale=-1:200:force_original_aspect_ratio=decrease /test/output/file.jpg");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Burst_Initial_Segments_When_Option_Is_Supported()
|
||||
{
|
||||
string command = BuildRealtimeCommand(new BurstCapableFFmpegCapabilities());
|
||||
|
||||
// burst covers the first two 4s segments, then the 1.05 throttle resumes (ersatztv#350).
|
||||
// anchor on the input path so this can't be satisfied by some other input carrying the
|
||||
// burst; audio and video share one file here, so exactly one input is expected
|
||||
command.ShouldContain("-readrate 1.05 -readrate_initial_burst 8 -i /tmp/whatever.mkv");
|
||||
Regex.Matches(command, "-readrate_initial_burst 8").Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Not_Burst_When_Option_Is_Unsupported()
|
||||
{
|
||||
string command = BuildRealtimeCommand(new DefaultFFmpegCapabilities());
|
||||
|
||||
command.ShouldContain("-readrate 1.05 -i");
|
||||
command.ShouldNotContain("-readrate_initial_burst");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Realtime_Input_Should_Not_Burst_A_Still_Image()
|
||||
{
|
||||
// a still image is paced by the realtime filter, so bursting would only run audio ahead
|
||||
string command = BuildRealtimeCommand(new BurstCapableFFmpegCapabilities(), stillImage: true);
|
||||
|
||||
// the positive anchor keeps this from passing vacuously if the helper ever stops
|
||||
// producing a realtime audio input at all
|
||||
command.ShouldContain("-readrate 1.05");
|
||||
command.ShouldNotContain("-readrate_initial_burst");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Concat_Should_Never_Burst()
|
||||
{
|
||||
// concat reads already-written segments from the running segmenter; bursting would
|
||||
// gallop through them
|
||||
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", new FrameSize(1920, 1080));
|
||||
|
||||
var builder = new SoftwarePipelineBuilder(
|
||||
new BurstCapableFFmpegCapabilities(),
|
||||
HardwareAccelerationMode.None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
concatInputFile,
|
||||
Option<GraphicsEngineInput>.None,
|
||||
"",
|
||||
"",
|
||||
_logger);
|
||||
|
||||
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
|
||||
|
||||
string command = PrintCommand(None, None, None, concatInputFile, None, result);
|
||||
|
||||
command.ShouldContain("-readrate 1.0");
|
||||
command.ShouldNotContain("-readrate_initial_burst");
|
||||
}
|
||||
|
||||
private string BuildRealtimeCommand(IFFmpegCapabilities capabilities, bool stillImage = false)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
"/tmp/whatever.mkv",
|
||||
new List<VideoStream>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
VideoFormat.H264,
|
||||
VideoProfile.Main,
|
||||
new PixelFormatYuv420P(),
|
||||
ColorParams.Default,
|
||||
new FrameSize(1920, 1080),
|
||||
"1:1",
|
||||
"16:9",
|
||||
FrameRate.DefaultFrameRate,
|
||||
stillImage,
|
||||
ScanKind.Progressive)
|
||||
});
|
||||
|
||||
var desiredState = new FrameState(
|
||||
true,
|
||||
false,
|
||||
VideoFormat.Hevc,
|
||||
VideoProfile.Main,
|
||||
VideoPreset.Unset,
|
||||
false,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(1920, 1080),
|
||||
new FrameSize(1920, 1080),
|
||||
Option<FrameSize>.None,
|
||||
FFmpegFilterMode.HardwareIfPossible,
|
||||
false,
|
||||
Option<FrameRate>.None,
|
||||
2000,
|
||||
4000,
|
||||
90_000,
|
||||
false,
|
||||
false);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
false,
|
||||
HardwareAccelerationMode.None,
|
||||
HardwareAccelerationMode.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
TimeSpan.FromSeconds(1),
|
||||
Option<TimeSpan>.None,
|
||||
false,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
OutputFormatKind.MpegTs,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
Option<string>.None,
|
||||
TimeSpan.Zero,
|
||||
Option<int>.None,
|
||||
Option<int>.None,
|
||||
false,
|
||||
false,
|
||||
"clip",
|
||||
false);
|
||||
|
||||
// a *separate* audio input matters here: for a still image the video input takes no readrate
|
||||
// at all, so only a distinct audio input can prove the burst was suppressed (this is the
|
||||
// song shape — cover art plus an audio file)
|
||||
var audioInputFile = new AudioInputFile(
|
||||
stillImage ? "/tmp/whatever.mp3" : "/tmp/whatever.mkv",
|
||||
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
|
||||
new AudioState(
|
||||
AudioFormat.Aac,
|
||||
2,
|
||||
320,
|
||||
640,
|
||||
48,
|
||||
false,
|
||||
AudioFilter.None,
|
||||
Option<double>.None));
|
||||
|
||||
var builder = new SoftwarePipelineBuilder(
|
||||
capabilities,
|
||||
HardwareAccelerationMode.None,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Option<GraphicsEngineInput>.None,
|
||||
"",
|
||||
"",
|
||||
_logger);
|
||||
|
||||
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
|
||||
|
||||
return PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
|
||||
}
|
||||
|
||||
private static string PrintCommand(
|
||||
Option<VideoInputFile> videoInputFile,
|
||||
Option<AudioInputFile> audioInputFile,
|
||||
@@ -726,13 +563,4 @@ public class PipelineBuilderBaseTests
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>());
|
||||
|
||||
public class BurstCapableFFmpegCapabilities() : FFmpegCapabilities(
|
||||
string.Empty,
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string>(),
|
||||
new System.Collections.Generic.HashSet<string> { FFmpegKnownOption.ReadrateInitialBurst.Name },
|
||||
new System.Collections.Generic.HashSet<string>());
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ public record FFmpegKnownOption
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
// ffmpeg 6.1+; lets a readrate-throttled input read flat out for an initial window
|
||||
public static FFmpegKnownOption ReadrateInitialBurst => new("readrate_initial_burst");
|
||||
|
||||
public static IList<string> AllOptions =>
|
||||
[
|
||||
ReadrateInitialBurst.Name
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Decoder;
|
||||
|
||||
// VA-API-accelerated decode that downloads frames to system memory (no
|
||||
// -hwaccel_output_format). Pairs with `-hwaccel vaapi` from
|
||||
// QsvHardwareAccelerationOption on the "prefer native decoder" QSV path: the
|
||||
// error-tolerant VA-API decoder feeds software frames into the QSV builder's
|
||||
// format=nv12,hwupload,vpp_qsv branch, which re-uploads for the QSV encoder.
|
||||
public class DecoderVaapiToSoftware : DecoderBase
|
||||
{
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Software;
|
||||
|
||||
public override string Name => "implicit_vaapi";
|
||||
|
||||
// no -c:v (implicit decoder; `-hwaccel vaapi` drives VA-API) and no
|
||||
// -hwaccel_output_format (frames download to software)
|
||||
public override string[] InputOptions(InputFile inputFile) => [];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
|
||||
namespace ErsatzTV.FFmpeg;
|
||||
|
||||
@@ -27,20 +27,9 @@ public record FFmpegState(
|
||||
bool IsSongWithProgress,
|
||||
bool IsHdrTonemap,
|
||||
string TonemapAlgorithm,
|
||||
bool IsTroubleshooting,
|
||||
bool QsvPreferNativeDecoder = false)
|
||||
bool IsTroubleshooting)
|
||||
{
|
||||
// the QSV upload pool needs headroom for the frames in flight through the filter graph.
|
||||
// extra_hw_frames=0 leaves none, so any input that is not throttled exhausts it: the graph
|
||||
// fails with -12 (Cannot allocate memory), h264_qsv reports "Could not open encoder before
|
||||
// EOF", and the output file gets no packets at all. Input throttling was the only thing
|
||||
// hiding it — a work-ahead start (no -readrate) and #350's cold-start burst both remove that
|
||||
// throttle, so the channel simply dies. A stored 0 is therefore treated as "no pool
|
||||
// configured" rather than honored literally (ersatztv#529)
|
||||
public const int MinimumQsvExtraHardwareFrames = 64;
|
||||
|
||||
public int QsvExtraHardwareFrames =>
|
||||
Math.Max(MaybeQsvExtraHardwareFrames.IfNone(MinimumQsvExtraHardwareFrames), MinimumQsvExtraHardwareFrames);
|
||||
public int QsvExtraHardwareFrames => MaybeQsvExtraHardwareFrames.IfNone(64);
|
||||
|
||||
public static FFmpegState Concat(bool saveReport, string channelName) =>
|
||||
new(
|
||||
|
||||
+10
-16
@@ -1,12 +1,9 @@
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
|
||||
|
||||
public class QsvHardwareAccelerationOption(
|
||||
Option<string> device,
|
||||
FFmpegCapability decodeCapability,
|
||||
bool preferNativeDecoder = false) : GlobalOption
|
||||
public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapability decodeCapability) : GlobalOption
|
||||
{
|
||||
// TODO: read this from ffmpeg output
|
||||
private readonly List<string> _supportedFFmpegFormats = new()
|
||||
@@ -19,18 +16,15 @@ public class QsvHardwareAccelerationOption(
|
||||
{
|
||||
get
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
if (decodeCapability is FFmpegCapability.Hardware)
|
||||
var result = new List<string>
|
||||
{
|
||||
// native path: decode with the error-tolerant VA-API decoder and let ffmpeg
|
||||
// download frames to system memory (no -hwaccel_output_format), so the QSV
|
||||
// filter graph's software->hwupload branch bridges them to the QSV encoder.
|
||||
// default path: decode (and keep frames) on QSV.
|
||||
result.AddRange(
|
||||
preferNativeDecoder
|
||||
? ["-hwaccel", "vaapi"]
|
||||
: ["-hwaccel", "qsv", "-hwaccel_output_format", "qsv"]);
|
||||
"-hwaccel", "qsv",
|
||||
"-hwaccel_output_format", "qsv"
|
||||
};
|
||||
|
||||
if (decodeCapability is not FFmpegCapability.Hardware)
|
||||
{
|
||||
result.Clear();
|
||||
}
|
||||
|
||||
var deviceConfigured = false;
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.FFmpeg.Environment;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.InputOption;
|
||||
|
||||
public class ReadrateInputOption(double readRate, Option<int> initialBurstSeconds) : IInputOption
|
||||
public class ReadrateInputOption(double readRate) : IInputOption
|
||||
{
|
||||
public ReadrateInputOption(double readRate)
|
||||
: this(readRate, Option<int>.None)
|
||||
{
|
||||
}
|
||||
|
||||
public EnvironmentVariable[] EnvironmentVariables => [];
|
||||
|
||||
public string[] GlobalOptions => [];
|
||||
|
||||
public string[] InputOptions(InputFile inputFile)
|
||||
{
|
||||
var result = new List<string>
|
||||
{
|
||||
public string[] InputOptions(InputFile inputFile) =>
|
||||
[
|
||||
"-readrate",
|
||||
readRate.ToString("0.0####", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
// burst-read this much input before the readrate throttle kicks in, so a cold start doesn't
|
||||
// have to wait ~realtime for the first segment to be written (ersatztv#350)
|
||||
foreach (int burst in initialBurstSeconds)
|
||||
{
|
||||
result.Add("-readrate_initial_burst");
|
||||
result.Add(burst.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
];
|
||||
|
||||
public string[] FilterOptions => [];
|
||||
public string[] OutputOptions => [];
|
||||
|
||||
@@ -17,11 +17,6 @@ namespace ErsatzTV.FFmpeg.Pipeline;
|
||||
|
||||
public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
{
|
||||
// enough input to cover the first couple of HLS segments; the segmenter waits for
|
||||
// ffmpeg.segmenter.initial_segment_count (default 1) of them before serving the playlist.
|
||||
// an operator who raises that setting above 2 gets less of the benefit (ersatztv#350)
|
||||
private const int InitialBurstSeconds = OutputFormatHls.SegmentSeconds * 2;
|
||||
|
||||
private readonly Option<AudioInputFile> _audioInputFile;
|
||||
private readonly Option<ConcatInputFile> _concatInputFile;
|
||||
private readonly IFFmpegCapabilities _ffmpegCapabilities;
|
||||
@@ -855,24 +850,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
}
|
||||
|
||||
double readRate = desiredState.VideoFormat == VideoFormat.Copy ? 1.0 : 1.05;
|
||||
|
||||
// without a burst, the readrate throttle applies from the very first read, so the first
|
||||
// segment cannot be written faster than ~realtime and every start pays a multi-second wait.
|
||||
// burst enough input to cover the first segments, then settle to readRate. note this is
|
||||
// per ffmpeg process, i.e. per playout item, not only on the session's cold start
|
||||
// (ersatztv#350)
|
||||
//
|
||||
// a still image is paced by the realtime filter instead, and its video input takes no
|
||||
// readrate at all, so bursting there would only run the audio input ahead of the video
|
||||
bool isStillImage = videoInputFile.VideoStreams.Any(s => s.StillImage);
|
||||
|
||||
Option<int> initialBurstSeconds =
|
||||
!isStillImage && _ffmpegCapabilities.HasOption(FFmpegKnownOption.ReadrateInitialBurst)
|
||||
? InitialBurstSeconds
|
||||
: Option<int>.None;
|
||||
|
||||
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds)));
|
||||
videoInputFile.AddOption(new ReadrateInputOption(readRate, initialBurstSeconds));
|
||||
_audioInputFile.Iter(a => a.AddOption(new ReadrateInputOption(readRate)));
|
||||
videoInputFile.AddOption(new ReadrateInputOption(readRate));
|
||||
}
|
||||
|
||||
protected static void SetStillImageLoop(
|
||||
|
||||
@@ -51,8 +51,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
}
|
||||
|
||||
protected override bool IsIntelVaapiOrQsv(FFmpegState ffmpegState) =>
|
||||
ffmpegState.DecoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv
|
||||
or HardwareAccelerationMode.Vaapi ||
|
||||
ffmpegState.DecoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv ||
|
||||
ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv;
|
||||
|
||||
protected override FFmpegState SetAccelState(
|
||||
@@ -62,21 +61,6 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
PipelineContext context,
|
||||
ICollection<IPipelineStep> pipelineSteps)
|
||||
{
|
||||
// surface the floor rather than applying it silently: a profile that deliberately asked for
|
||||
// a smaller pool now gets a larger one, which costs additional surfaces (ersatztv#529)
|
||||
foreach (int configured in ffmpegState.MaybeQsvExtraHardwareFrames
|
||||
.Filter(f => f < FFmpegState.MinimumQsvExtraHardwareFrames))
|
||||
{
|
||||
// this fires per pipeline build, before we know whether this particular pipeline uploads
|
||||
// at all — a fully-hardware path may consume neither value — so word it conditionally
|
||||
_logger.LogWarning(
|
||||
"QSV extra hardware frames is configured as {Configured}, which leaves the upload pool too "
|
||||
+ "little headroom and fails transcoding on any unthrottled read; will use {Applied} "
|
||||
+ "wherever frames are uploaded",
|
||||
configured,
|
||||
FFmpegState.MinimumQsvExtraHardwareFrames);
|
||||
}
|
||||
|
||||
FFmpegCapability decodeCapability = _hardwareCapabilities.CanDecode(
|
||||
videoStream.Codec,
|
||||
videoStream.Profile,
|
||||
@@ -118,22 +102,13 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
// give a bogus value so no cuda devices are visible to ffmpeg
|
||||
pipelineSteps.Add(new CudaVisibleDevicesVariable("999"));
|
||||
|
||||
// native (VA-API) decode is a Linux-only path: ffmpeg has no vaapi hwaccel on
|
||||
// Windows, where QSV capabilities are also over-reported, so keep QSV decode there
|
||||
bool preferNativeDecode = ffmpegState.QsvPreferNativeDecoder != false && !OperatingSystem.IsWindows();
|
||||
|
||||
pipelineSteps.Add(new QsvHardwareAccelerationOption(
|
||||
ffmpegState.VaapiDevice,
|
||||
decodeCapability,
|
||||
preferNativeDecode));
|
||||
pipelineSteps.Add(new QsvHardwareAccelerationOption(ffmpegState.VaapiDevice, decodeCapability));
|
||||
|
||||
// disable hw accel if decoder/encoder isn't supported
|
||||
return ffmpegState with
|
||||
{
|
||||
DecoderHardwareAccelerationMode = decodeCapability == FFmpegCapability.Hardware
|
||||
? preferNativeDecode
|
||||
? HardwareAccelerationMode.Vaapi
|
||||
: HardwareAccelerationMode.Qsv
|
||||
? HardwareAccelerationMode.Qsv
|
||||
: HardwareAccelerationMode.None,
|
||||
EncoderHardwareAccelerationMode = encodeCapability == FFmpegCapability.Hardware
|
||||
? HardwareAccelerationMode.Qsv
|
||||
@@ -155,7 +130,6 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.Vc1) => new DecoderVc1Qsv(),
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.Vp9) => new DecoderVp9Qsv(),
|
||||
(HardwareAccelerationMode.Qsv, VideoFormat.Av1) => new DecoderAv1Qsv(),
|
||||
(HardwareAccelerationMode.Vaapi, _) => new DecoderVaapiToSoftware(),
|
||||
|
||||
_ => GetSoftwareDecoder(videoStream)
|
||||
};
|
||||
|
||||
-7265
File diff suppressed because it is too large
Load Diff
-29
@@ -1,29 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_FFmpegProfile_QsvPreferNativeDecoder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "QsvPreferNativeDecoder",
|
||||
table: "FFmpegProfile",
|
||||
type: "tinyint(1)",
|
||||
nullable: true,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QsvPreferNativeDecoder",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -906,11 +906,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<int?>("QsvExtraHardwareFrames")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool?>("QsvPreferNativeDecoder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("tinyint(1)")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("ResolutionId")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
-7090
File diff suppressed because it is too large
Load Diff
-29
@@ -1,29 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_FFmpegProfile_QsvPreferNativeDecoder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "QsvPreferNativeDecoder",
|
||||
table: "FFmpegProfile",
|
||||
type: "INTEGER",
|
||||
nullable: true,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QsvPreferNativeDecoder",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -873,11 +873,6 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Property<int?>("QsvExtraHardwareFrames")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool?>("QsvPreferNativeDecoder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("ResolutionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Images;
|
||||
|
||||
[TestFixture]
|
||||
public class RemoteImageValidatorTests
|
||||
{
|
||||
private static readonly Uri Uri = new("https://example.com/logo.png");
|
||||
|
||||
// decode cases exercise the static method (used by the render path)
|
||||
[Test]
|
||||
public async Task Should_Decode_A_Normal_Image()
|
||||
{
|
||||
await using MemoryStream stream = await RealPng(64, 32);
|
||||
using Image image = await RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None);
|
||||
image.Width.ShouldBe(64);
|
||||
image.Height.ShouldBe(32);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_A_Decompression_Bomb_By_Declared_Dimensions()
|
||||
{
|
||||
await using MemoryStream stream = PngHeaderDeclaring(30000, 30000);
|
||||
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None));
|
||||
ex.Message.ShouldContain("pixel limit");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_An_Apng_Whose_Header_Under_Reports_Its_Frames()
|
||||
{
|
||||
await using MemoryStream stream = Apng(64, 64, RemoteImageDecodeBudget.MaxRemoteFrames + 100);
|
||||
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None));
|
||||
ex.Message.ShouldContain("frame limit");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Decode_An_Apng_That_A_Default_Identify_Cannot_Read()
|
||||
{
|
||||
await using MemoryStream stream = Apng(288, 288, 60);
|
||||
stream.Position = 0;
|
||||
await Should.ThrowAsync<Exception>(() => Image.IdentifyAsync(stream));
|
||||
stream.Position = 0;
|
||||
using Image image = await RemoteImageValidator.DecodeAndValidate(stream, Uri, CancellationToken.None);
|
||||
image.Frames.Count.ShouldBe(60);
|
||||
}
|
||||
|
||||
// the Core interface Validate() is the save/upload contract: throws on invalid, returns on valid,
|
||||
// never surfaces an ImageSharp type
|
||||
[Test]
|
||||
public async Task Validate_Returns_On_A_Good_Image()
|
||||
{
|
||||
IRemoteImageValidator validator = new RemoteImageValidator();
|
||||
await using MemoryStream stream = await RealPng(64, 32);
|
||||
await Should.NotThrowAsync(() => validator.Validate(stream, Uri, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Validate_Throws_On_A_Bomb()
|
||||
{
|
||||
IRemoteImageValidator validator = new RemoteImageValidator();
|
||||
await using MemoryStream stream = PngHeaderDeclaring(30000, 30000);
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => validator.Validate(stream, Uri, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>A real multi-frame APNG. Small on the wire, many frames — the shape that matters.</summary>
|
||||
private static MemoryStream Apng(int width, int height, int frames)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
for (var i = 1; i < frames; i++)
|
||||
{
|
||||
image.Frames.CreateFrame();
|
||||
}
|
||||
|
||||
var stream = new MemoryStream();
|
||||
image.Save(stream, new PngEncoder { ColorType = PngColorType.RgbWithAlpha });
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
// PNG chunk CRC-32 (IEEE, reflected). hand-rolled because the repo does not reference
|
||||
// System.IO.Hashing, and ImageSharp validates the CRC of critical chunks like IHDR.
|
||||
private static uint Crc32(ReadOnlySpan<byte> data)
|
||||
{
|
||||
uint crc = 0xFFFFFFFF;
|
||||
foreach (byte b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
|
||||
}
|
||||
}
|
||||
|
||||
return crc ^ 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
/// <summary>A real, decodable PNG.</summary>
|
||||
private static async Task<MemoryStream> RealPng(int width, int height)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
var stream = new MemoryStream();
|
||||
await image.SaveAsync(stream, new PngEncoder());
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A PNG signature plus a single valid IHDR chunk declaring <paramref name="width" /> x
|
||||
/// <paramref name="height" /> and nothing else — enough for Identify, far too little to
|
||||
/// decode. This is what a decompression bomb looks like at the point we have to reject it.
|
||||
/// </summary>
|
||||
private static MemoryStream PngHeaderDeclaring(int width, int height)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
stream.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
|
||||
var ihdr = new byte[17];
|
||||
"IHDR"u8.CopyTo(ihdr);
|
||||
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(4), width);
|
||||
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(8), height);
|
||||
ihdr[12] = 8; // bit depth
|
||||
ihdr[13] = 6; // color type: truecolor + alpha
|
||||
ihdr[14] = 0; // compression
|
||||
ihdr[15] = 0; // filter
|
||||
ihdr[16] = 0; // interlace
|
||||
|
||||
var length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, 13);
|
||||
stream.Write(length);
|
||||
stream.Write(ihdr);
|
||||
|
||||
var crc = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32(ihdr));
|
||||
stream.Write(crc);
|
||||
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using static LanguageExt.Prelude;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Images;
|
||||
|
||||
[TestFixture]
|
||||
public class RemoteLogoCacherTests
|
||||
{
|
||||
private static readonly Uri Uri = new("https://example.com/logo.png");
|
||||
|
||||
[Test]
|
||||
public async Task Should_Fetch_Validate_And_Cache_Returning_The_Name()
|
||||
{
|
||||
MemoryStream png = await RealPng(64, 64);
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
fetcher.Fetch(Uri, Arg.Any<CancellationToken>()).Returns(png);
|
||||
var validator = Substitute.For<IRemoteImageValidator>();
|
||||
validator.Validate(png, Uri, Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
|
||||
var cache = Substitute.For<IImageCache>();
|
||||
cache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo).Returns(Right<BaseError, string>("abc123"));
|
||||
|
||||
var cacher = new RemoteLogoCacher(fetcher, validator, cache);
|
||||
Either<BaseError, string> result = await cacher.CacheFromUrl(Uri, CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
result.IfRight(name => name.ShouldBe("abc123"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_Left_When_The_Fetch_Throws()
|
||||
{
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
fetcher.Fetch(Uri, Arg.Any<CancellationToken>()).Returns<Stream>(_ => throw new TimeoutException("timed out"));
|
||||
var cacher = new RemoteLogoCacher(fetcher, Substitute.For<IRemoteImageValidator>(), Substitute.For<IImageCache>());
|
||||
|
||||
Either<BaseError, string> result = await cacher.CacheFromUrl(Uri, CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.IfLeft(e => e.Value.ShouldContain("timed out"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_Left_When_Validation_Rejects_A_Bomb()
|
||||
{
|
||||
MemoryStream png = await RealPng(64, 64);
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
fetcher.Fetch(Uri, Arg.Any<CancellationToken>()).Returns(png);
|
||||
var validator = Substitute.For<IRemoteImageValidator>();
|
||||
validator.Validate(png, Uri, Arg.Any<CancellationToken>())
|
||||
.Returns<Task>(_ => throw new InvalidOperationException("over the 50000000 pixel limit"));
|
||||
var cacher = new RemoteLogoCacher(fetcher, validator, Substitute.For<IImageCache>());
|
||||
|
||||
Either<BaseError, string> result = await cacher.CacheFromUrl(Uri, CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.IfLeft(e => e.Value.ShouldContain("pixel limit"));
|
||||
}
|
||||
|
||||
private static async Task<MemoryStream> RealPng(int w, int h)
|
||||
{
|
||||
using var img = new Image<Rgba32>(w, h);
|
||||
var ms = new MemoryStream();
|
||||
await img.SaveAsync(ms, new PngEncoder());
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
}
|
||||
@@ -53,141 +53,6 @@ public class JellyfinApiClientTests
|
||||
libraries[0].ShouldSyncItems.ShouldBeFalse();
|
||||
libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Project_Mixed_Libraries()
|
||||
{
|
||||
const string response = """
|
||||
[
|
||||
{
|
||||
"Name": "Music Videos",
|
||||
"CollectionType": "mixed",
|
||||
"ItemId": "library-9",
|
||||
"LibraryOptions": {
|
||||
"PathInfos": []
|
||||
}
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var client = new JellyfinApiClient(
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
Substitute.For<IJellyfinPathReplacementService>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
new SingleResponseHttpClientFactory(response),
|
||||
Substitute.For<ILogger<JellyfinApiClient>>());
|
||||
|
||||
Either<BaseError, List<JellyfinLibrary>> result =
|
||||
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
|
||||
libraries.Count.ShouldBe(1);
|
||||
libraries[0].Name.ShouldBe("Music Videos");
|
||||
libraries[0].ItemId.ShouldBe("library-9");
|
||||
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
|
||||
libraries[0].ShouldSyncItems.ShouldBeFalse();
|
||||
libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-9");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Project_Libraries_With_No_CollectionType_As_Mixed()
|
||||
{
|
||||
const string response = """
|
||||
[
|
||||
{
|
||||
"Name": "Standup",
|
||||
"ItemId": "library-10",
|
||||
"LibraryOptions": {
|
||||
"PathInfos": []
|
||||
}
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var client = new JellyfinApiClient(
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
Substitute.For<IJellyfinPathReplacementService>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
new SingleResponseHttpClientFactory(response),
|
||||
Substitute.For<ILogger<JellyfinApiClient>>());
|
||||
|
||||
Either<BaseError, List<JellyfinLibrary>> result =
|
||||
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
|
||||
libraries.Count.ShouldBe(1);
|
||||
libraries[0].Name.ShouldBe("Standup");
|
||||
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
|
||||
}
|
||||
|
||||
// Jellyfin serializes "no content type" as absent, empty or whitespace depending on version;
|
||||
// all three mean mixed content, so all three must project identically.
|
||||
[TestCase("\"CollectionType\": \"\",")]
|
||||
[TestCase("\"CollectionType\": \" \",")]
|
||||
public async Task Should_Project_Libraries_With_Blank_CollectionType_As_Mixed(string collectionTypeLine)
|
||||
{
|
||||
string response = $$"""
|
||||
[
|
||||
{
|
||||
"Name": "Standup",
|
||||
{{collectionTypeLine}}
|
||||
"ItemId": "library-12",
|
||||
"LibraryOptions": {
|
||||
"PathInfos": []
|
||||
}
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var client = new JellyfinApiClient(
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
Substitute.For<IJellyfinPathReplacementService>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
new SingleResponseHttpClientFactory(response),
|
||||
Substitute.For<ILogger<JellyfinApiClient>>());
|
||||
|
||||
Either<BaseError, List<JellyfinLibrary>> result =
|
||||
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
|
||||
libraries.Count.ShouldBe(1);
|
||||
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed);
|
||||
}
|
||||
|
||||
// Guard: mixed must not become a catch-all. Jellyfin "music" (audio) libraries have no
|
||||
// supported scanner, so they must keep falling through to None.
|
||||
[Test]
|
||||
public async Task Should_Not_Project_Unknown_CollectionTypes()
|
||||
{
|
||||
const string response = """
|
||||
[
|
||||
{
|
||||
"Name": "Explo Discovery",
|
||||
"CollectionType": "music",
|
||||
"ItemId": "library-11",
|
||||
"LibraryOptions": {
|
||||
"PathInfos": []
|
||||
}
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var client = new JellyfinApiClient(
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
Substitute.For<IJellyfinPathReplacementService>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
new SingleResponseHttpClientFactory(response),
|
||||
Substitute.For<ILogger<JellyfinApiClient>>());
|
||||
|
||||
Either<BaseError, List<JellyfinLibrary>> result =
|
||||
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
result.RightToSeq().Single().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleResponseHttpClientFactory(string response) : IHttpClientFactory
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Streaming.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// The byte cap in <c>HttpRemoteImageFetcher</c> does not bound decoding: a decompression bomb
|
||||
/// is tiny on the wire and enormous in memory. These pin the header-first check that does.
|
||||
/// (ersatztv#511)
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class RemoteImageDecodeLimitTests
|
||||
{
|
||||
private static readonly Uri ImageUri = new("https://example.com/logo.png");
|
||||
|
||||
[Test]
|
||||
public async Task Should_Decode_A_Normal_Image()
|
||||
{
|
||||
await using MemoryStream stream = await RealPng(64, 32);
|
||||
|
||||
using Image image = await ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None);
|
||||
|
||||
image.Width.ShouldBe(64);
|
||||
image.Height.ShouldBe(32);
|
||||
}
|
||||
|
||||
// the bomb: a few dozen bytes on the wire, ~3.6 GB if decoded. it sails through the byte cap,
|
||||
// the content-type check and the Content-Length reject -- only the header dimensions catch it.
|
||||
[Test]
|
||||
public async Task Should_Reject_An_Image_Whose_Declared_Dimensions_Are_A_Decompression_Bomb()
|
||||
{
|
||||
await using MemoryStream stream = PngHeaderDeclaring(30000, 30000);
|
||||
|
||||
stream.Length.ShouldBeLessThan(100, "the point is that this is tiny on the wire");
|
||||
|
||||
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("pixel limit");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Accept_Dimensions_Exactly_At_The_Limit()
|
||||
{
|
||||
// 10000 x 5000 = 50,000,000 -- exactly the budget, so it must NOT be rejected. the decode
|
||||
// then fails on the truncated body, which proves the check let it through. NOTE this test
|
||||
// would also pass with the guard deleted entirely; deletion is covered by the bomb test
|
||||
// above, and the boundary arithmetic by RemoteImageDecodeBudgetTests (ErsatzTV.Core.Tests).
|
||||
await using MemoryStream stream = PngHeaderDeclaring(10000, 5000);
|
||||
|
||||
Exception ex = await Should.ThrowAsync<Exception>(
|
||||
() => ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldNotContain("pixel limit");
|
||||
}
|
||||
|
||||
// the retained-frame budget is INDEPENDENT of the decode budget: this source is trivial to
|
||||
// decode (6 MP total) but retains ~5 GB of SKBitmap once every frame is scaled to 1080p
|
||||
[Test]
|
||||
public void Should_Reject_Cheap_Frames_That_Are_Expensive_Once_Scaled()
|
||||
{
|
||||
Should.NotThrow(() => RemoteImageDecodeBudget.EnsureDecodeAffordable(100, 100, 600, ImageUri));
|
||||
|
||||
InvalidOperationException ex = Should.Throw<InvalidOperationException>(
|
||||
() => ImageElementBase.EnsureScaledFramesAffordable(600, 1920, 1080, ImageUri));
|
||||
|
||||
ex.Message.ShouldContain("pixel limit");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_A_Scaled_Watermark_Sized_Animation()
|
||||
{
|
||||
// a 10%-width logo on a 1080p frame, animated
|
||||
Should.NotThrow(() => ImageElementBase.EnsureScaledFramesAffordable(600, 192, 108, ImageUri));
|
||||
}
|
||||
|
||||
|
||||
// --- B1 regression: the header's frame count is a LIE for APNG ---
|
||||
|
||||
// ImageSharp 3.1.12 reports FrameMetadataCollection.Count == 0 for an APNG while the decoder
|
||||
// produces every frame. A budget derived from that header count is enforced on a number the
|
||||
// decoder does not honor — this exact payload shape, at 4000x4000, is ~134 KiB on the wire and
|
||||
// ~36 GiB decoded. The bound therefore has to be imposed ON THE DECODER (DecoderOptions.
|
||||
// MaxFrames) and re-verified against the real frame count. (ersatztv#511, second re-review.)
|
||||
[Test]
|
||||
public async Task Should_Reject_An_Animation_Whose_Header_Under_Reports_Its_Frames()
|
||||
{
|
||||
await using MemoryStream stream = Apng(64, 64, RemoteImageDecodeBudget.MaxRemoteFrames + 100);
|
||||
|
||||
// the premise: the header really does under-report, so a header-derived budget waves it through
|
||||
stream.Position = 0;
|
||||
ImageInfo info = await Image.IdentifyAsync(stream);
|
||||
info.FrameMetadataCollection.Count.ShouldBe(0, "the APNG header under-reports; that is the whole point");
|
||||
Should.NotThrow(() => RemoteImageDecodeBudget.EnsureDecodeAffordable(64, 64, info.FrameMetadataCollection.Count, ImageUri));
|
||||
|
||||
stream.Position = 0;
|
||||
InvalidOperationException ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None));
|
||||
|
||||
ex.Message.ShouldContain("frame limit");
|
||||
}
|
||||
|
||||
// ...and an animation within budget still decodes IN FULL -- the decoder cap must not silently
|
||||
// truncate legitimate content by a frame
|
||||
[Test]
|
||||
public async Task Should_Decode_An_Animation_Within_Budget_Without_Truncating_It()
|
||||
{
|
||||
await using MemoryStream stream = Apng(64, 64, 300);
|
||||
|
||||
using Image image = await ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None);
|
||||
|
||||
image.Frames.Count.ShouldBe(300);
|
||||
}
|
||||
|
||||
|
||||
// H2 regression: a default `Image.Identify` throws InvalidImageContentException on most APNGs
|
||||
// (measured: 13 of 16 shapes, including ones ImageSharp's own encoder wrote) even though
|
||||
// `Image.Load` reads them back perfectly. 288x288 is one of the throwing shapes; 64x64 x300 --
|
||||
// used by the tests above -- happens NOT to be, which is exactly why they could not see this.
|
||||
// Without the MaxFrames=1 workaround on the Identify, every animated-PNG logo that worked
|
||||
// before this change would be silently disabled. (ersatztv#511, fourth re-review.)
|
||||
[Test]
|
||||
public async Task Should_Decode_An_Apng_That_A_Default_Identify_Cannot_Read()
|
||||
{
|
||||
await using MemoryStream stream = Apng(288, 288, 60);
|
||||
|
||||
// the premise: a default Identify really does fail on this file
|
||||
stream.Position = 0;
|
||||
await Should.ThrowAsync<Exception>(() => Image.IdentifyAsync(stream));
|
||||
|
||||
stream.Position = 0;
|
||||
using Image image = await ImageElementBase.DecodeRemoteImage(stream, ImageUri, CancellationToken.None);
|
||||
|
||||
image.Width.ShouldBe(288);
|
||||
image.Height.ShouldBe(288);
|
||||
image.Frames.Count.ShouldBe(60);
|
||||
}
|
||||
|
||||
/// <summary>A real multi-frame APNG. Small on the wire, many frames — the shape that matters.</summary>
|
||||
private static MemoryStream Apng(int width, int height, int frames)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
for (var i = 1; i < frames; i++)
|
||||
{
|
||||
image.Frames.CreateFrame();
|
||||
}
|
||||
|
||||
var stream = new MemoryStream();
|
||||
image.Save(stream, new PngEncoder { ColorType = PngColorType.RgbWithAlpha });
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
// PNG chunk CRC-32 (IEEE, reflected). hand-rolled because the repo does not reference
|
||||
// System.IO.Hashing, and ImageSharp validates the CRC of critical chunks like IHDR.
|
||||
private static uint Crc32(ReadOnlySpan<byte> data)
|
||||
{
|
||||
uint crc = 0xFFFFFFFF;
|
||||
foreach (byte b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
|
||||
}
|
||||
}
|
||||
|
||||
return crc ^ 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
/// <summary>A real, decodable PNG.</summary>
|
||||
private static async Task<MemoryStream> RealPng(int width, int height)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
var stream = new MemoryStream();
|
||||
await image.SaveAsync(stream, new PngEncoder());
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A PNG signature plus a single valid IHDR chunk declaring <paramref name="width" /> x
|
||||
/// <paramref name="height" /> and nothing else — enough for Identify, far too little to
|
||||
/// decode. This is what a decompression bomb looks like at the point we have to reject it.
|
||||
/// </summary>
|
||||
private static MemoryStream PngHeaderDeclaring(int width, int height)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
stream.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
|
||||
var ihdr = new byte[17];
|
||||
"IHDR"u8.CopyTo(ihdr);
|
||||
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(4), width);
|
||||
BinaryPrimitives.WriteInt32BigEndian(ihdr.AsSpan(8), height);
|
||||
ihdr[12] = 8; // bit depth
|
||||
ihdr[13] = 6; // color type: truecolor + alpha
|
||||
ihdr[14] = 0; // compression
|
||||
ihdr[15] = 0; // filter
|
||||
ihdr[16] = 0; // interlace
|
||||
|
||||
var length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, 13);
|
||||
stream.Write(length);
|
||||
stream.Write(ihdr);
|
||||
|
||||
var crc = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32(ihdr));
|
||||
stream.Write(crc);
|
||||
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using LanguageExt;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Streaming.Graphics;
|
||||
|
||||
/// <summary>
|
||||
/// The degradation contract for a remote watermark image: whatever the fetcher throws, the
|
||||
/// element disables itself and the stream survives. (ersatztv#511)
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WatermarkElementRemoteImageTests
|
||||
{
|
||||
private const string RemoteLogo = "https://example.com/logo.png";
|
||||
|
||||
private static readonly Exception[] FetchFailures =
|
||||
[
|
||||
new TimeoutException("timed out fetching remote image"),
|
||||
new InvalidOperationException("remote image exceeds the byte limit"),
|
||||
new HttpRequestException("no route to host")
|
||||
];
|
||||
|
||||
[TestCaseSource(nameof(FetchFailures))]
|
||||
public async Task Should_Disable_The_Watermark_When_The_Fetch_Fails(Exception failure)
|
||||
{
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>()).Returns<Task<Stream>>(_ => throw failure);
|
||||
|
||||
var element = new WatermarkElement(
|
||||
RemoteWatermarkOptions(),
|
||||
fetcher,
|
||||
Substitute.For<ILogger>());
|
||||
|
||||
// must not throw -- a killed graphics element must never propagate into the stream
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
|
||||
element.IsFinished.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// the counterpart: caller cancellation is not a fetch failure, but it must still not escape
|
||||
// into the streaming pipeline as an unhandled exception
|
||||
[Test]
|
||||
public async Task Should_Disable_The_Watermark_When_The_Caller_Cancels()
|
||||
{
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Task<Stream>>(_ => throw new OperationCanceledException());
|
||||
|
||||
var element = new WatermarkElement(RemoteWatermarkOptions(), fetcher, Substitute.For<ILogger>());
|
||||
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
|
||||
element.IsFinished.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// a remote path really does route to the fetcher -- without this the tests above would pass
|
||||
// even if LoadImage stopped recognising http(s) urls
|
||||
[Test]
|
||||
public async Task Should_Route_A_Remote_Path_Through_The_Fetcher()
|
||||
{
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Task<Stream>>(_ => throw new TimeoutException());
|
||||
|
||||
var element = new WatermarkElement(RemoteWatermarkOptions(), fetcher, Substitute.For<ILogger>());
|
||||
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
|
||||
await fetcher.Received(1).Fetch(new Uri(RemoteLogo), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ...and a local path must not touch the network at all
|
||||
[Test]
|
||||
public async Task Should_Not_Use_The_Fetcher_For_A_Local_Path()
|
||||
{
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
|
||||
var options = new WatermarkOptions(Watermark(), "/no/such/logo.png", Option<int>.None);
|
||||
var element = new WatermarkElement(options, fetcher, Substitute.For<ILogger>());
|
||||
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
|
||||
await fetcher.DidNotReceive().Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>());
|
||||
element.IsFinished.ShouldBeTrue();
|
||||
}
|
||||
|
||||
|
||||
// --- the RETENTION budget is wired in, and is remote-only (M4 from re-review) ---
|
||||
//
|
||||
// These two differ ONLY in the scale percent, so together they detect deletion of the
|
||||
// `if (isRemoteUri) EnsureScaledFramesAffordable(...)` call site: without it the first case
|
||||
// would succeed. The source is trivial to decode (300 x 64x64 = 1.2 MP, well inside the decode
|
||||
// budget) but retains ~2.5 GB of SKBitmap once every frame is scaled to 1080p.
|
||||
|
||||
[Test]
|
||||
public async Task Should_Disable_The_Watermark_When_Scaled_Frames_Blow_The_Retention_Budget()
|
||||
{
|
||||
var element = new WatermarkElement(
|
||||
RemoteWatermarkOptions(widthPercent: 100),
|
||||
FetcherReturning(Apng(64, 64, 300)),
|
||||
Substitute.For<ILogger>());
|
||||
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
|
||||
element.IsFinished.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Keep_The_Watermark_When_The_Same_Animation_Is_Scaled_Small()
|
||||
{
|
||||
var element = new WatermarkElement(
|
||||
RemoteWatermarkOptions(widthPercent: 10),
|
||||
FetcherReturning(Apng(64, 64, 300)),
|
||||
Substitute.For<ILogger>());
|
||||
|
||||
await element.InitializeAsync(Context(), CancellationToken.None);
|
||||
|
||||
element.IsFinished.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static IRemoteImageFetcher FetcherReturning(Stream stream)
|
||||
{
|
||||
var fetcher = Substitute.For<IRemoteImageFetcher>();
|
||||
fetcher.Fetch(Arg.Any<Uri>(), Arg.Any<CancellationToken>()).Returns(stream);
|
||||
return fetcher;
|
||||
}
|
||||
|
||||
private static MemoryStream Apng(int width, int height, int frames)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
for (var i = 1; i < frames; i++)
|
||||
{
|
||||
image.Frames.CreateFrame();
|
||||
}
|
||||
|
||||
var stream = new MemoryStream();
|
||||
image.Save(stream, new PngEncoder { ColorType = PngColorType.RgbWithAlpha });
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static WatermarkOptions RemoteWatermarkOptions(double widthPercent = 10) =>
|
||||
new(Watermark(widthPercent), RemoteLogo, Option<int>.None);
|
||||
|
||||
private static ChannelWatermark Watermark(double widthPercent = 10) =>
|
||||
new()
|
||||
{
|
||||
Name = "test",
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
Location = WatermarkLocation.BottomRight,
|
||||
Size = WatermarkSize.Scaled,
|
||||
WidthPercent = widthPercent,
|
||||
HorizontalMarginPercent = 5,
|
||||
VerticalMarginPercent = 5,
|
||||
Opacity = 100
|
||||
};
|
||||
|
||||
private static GraphicsEngineContext Context() =>
|
||||
new(
|
||||
"1",
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
new Resolution { Width = 1920, Height = 1080 },
|
||||
new Resolution { Width = 1920, Height = 1080 },
|
||||
new FrameRate("30"),
|
||||
DateTimeOffset.UnixEpoch,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(1),
|
||||
TimeSpan.FromMinutes(1));
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using ErsatzTV.Infrastructure.Streaming;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Streaming;
|
||||
|
||||
[TestFixture]
|
||||
public class HttpRemoteImageFetcherTests
|
||||
{
|
||||
private static readonly Uri ImageUri = new("https://example.com/logo.png");
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_The_Buffered_Body_On_Success()
|
||||
{
|
||||
byte[] payload = [1, 2, 3, 4, 5];
|
||||
HttpRemoteImageFetcher fetcher = FetcherReturning(Ok(payload, "image/png"));
|
||||
|
||||
await using Stream result = await fetcher.Fetch(ImageUri, CancellationToken.None);
|
||||
|
||||
result.Position.ShouldBe(0);
|
||||
var read = new byte[payload.Length];
|
||||
await result.ReadExactlyAsync(read);
|
||||
read.ShouldBe(payload);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Throw_On_An_Error_Status()
|
||||
{
|
||||
HttpRemoteImageFetcher fetcher = FetcherReturning(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
|
||||
await Should.ThrowAsync<HttpRequestException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
|
||||
}
|
||||
|
||||
// an html error page served with a 200 must never reach the decoder
|
||||
[Test]
|
||||
public async Task Should_Reject_A_Non_Image_Content_Type()
|
||||
{
|
||||
HttpRemoteImageFetcher fetcher = FetcherReturning(Ok([1, 2, 3], "text/html"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
|
||||
}
|
||||
|
||||
// hosts that omit the header, and static file servers that default to octet-stream, are common
|
||||
// enough that rejecting them would break working logos for no security gain -- ImageSharp
|
||||
// decodes by magic bytes, and the wire-size + decode-budget caps are the real protection.
|
||||
[TestCase(null)]
|
||||
[TestCase("application/octet-stream")]
|
||||
public async Task Should_Accept_A_Missing_Or_Generic_Content_Type(string mediaType)
|
||||
{
|
||||
HttpRemoteImageFetcher fetcher = FetcherReturning(Ok([1, 2, 3], mediaType));
|
||||
|
||||
await using Stream result = await fetcher.Fetch(ImageUri, CancellationToken.None);
|
||||
|
||||
result.Length.ShouldBe(3);
|
||||
}
|
||||
|
||||
// the advertised length is the cheap reject: the body must not be pulled at all
|
||||
[Test]
|
||||
public async Task Should_Reject_An_Oversized_Content_Length_Without_Reading_The_Body()
|
||||
{
|
||||
var body = new TrackingStream(HttpRemoteImageFetcher.MaxImageBytes + 1);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) };
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
||||
response.Content.Headers.ContentLength = HttpRemoteImageFetcher.MaxImageBytes + 1;
|
||||
|
||||
HttpRemoteImageFetcher fetcher = FetcherReturning(response);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
|
||||
body.BytesRead.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ...and a host that lies about (or omits) Content-Length is still capped, because the copy
|
||||
// itself counts bytes. (the DECODE bomb -- small on the wire, huge in memory -- is a different
|
||||
// problem, bounded by the decode/retention budgets in ImageElementBase, not by this.)
|
||||
[Test]
|
||||
public async Task Should_Cap_A_Body_That_Does_Not_Advertise_Its_Length()
|
||||
{
|
||||
var body = new ChunkedZeroStream(HttpRemoteImageFetcher.MaxImageBytes * 2);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) };
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
||||
response.Content.Headers.ContentLength = null;
|
||||
|
||||
HttpRemoteImageFetcher fetcher = FetcherReturning(response);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
|
||||
|
||||
// proves this went through the COPY cap and not the Content-Length early reject: the body
|
||||
// really was read, and it stopped at the limit rather than draining all 20 MiB.
|
||||
body.Position.ShouldBeGreaterThan(0);
|
||||
|
||||
// the over-read bound is ONE RENTED BUFFER, and ArrayPool.Rent(81920) actually hands back
|
||||
// 131072 -- deriving it keeps this honest if the request size or the pool bucketing changes.
|
||||
byte[] rented = ArrayPool<byte>.Shared.Rent(81920);
|
||||
ArrayPool<byte>.Shared.Return(rented);
|
||||
body.Position.ShouldBeLessThanOrEqualTo(HttpRemoteImageFetcher.MaxImageBytes + rented.Length);
|
||||
}
|
||||
|
||||
// a host that accepts the connection and then hangs must not stall stream startup forever
|
||||
[Test]
|
||||
public async Task Should_Time_Out_A_Hanging_Host()
|
||||
{
|
||||
var fetcher = new HttpRemoteImageFetcher(
|
||||
new StubHttpClientFactory(new HangingHttpMessageHandler()),
|
||||
new RecyclableMemoryStreamManager(),
|
||||
Substitute.For<ILogger<HttpRemoteImageFetcher>>(),
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
await Should.ThrowAsync<TimeoutException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
|
||||
}
|
||||
|
||||
|
||||
// THE headline claim: under ResponseHeadersRead the body read falls outside HttpClient.Timeout,
|
||||
// so a host that returns headers promptly and then drips the body must still hit our deadline.
|
||||
// the hanging-host test above only covers the pre-headers case, which a plain HttpClient.Timeout
|
||||
// would already bound -- this is the one that pins the actual design.
|
||||
[Test]
|
||||
public async Task Should_Time_Out_A_Slow_Drip_Body()
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StreamContent(new SlowDripStream())
|
||||
};
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
|
||||
|
||||
var fetcher = new HttpRemoteImageFetcher(
|
||||
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
|
||||
new RecyclableMemoryStreamManager(),
|
||||
Substitute.For<ILogger<HttpRemoteImageFetcher>>(),
|
||||
TimeSpan.FromMilliseconds(200));
|
||||
|
||||
await Should.ThrowAsync<TimeoutException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Throw_On_Transport_Failure()
|
||||
{
|
||||
var fetcher = new HttpRemoteImageFetcher(
|
||||
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new HttpRequestException("no route to host"))),
|
||||
new RecyclableMemoryStreamManager(),
|
||||
Substitute.For<ILogger<HttpRemoteImageFetcher>>());
|
||||
|
||||
await Should.ThrowAsync<HttpRequestException>(() => fetcher.Fetch(ImageUri, CancellationToken.None));
|
||||
}
|
||||
|
||||
// caller cancellation (shutdown / client disconnect) is a genuine signal and must stay an
|
||||
// OperationCanceledException rather than being relabelled as our timeout
|
||||
[Test]
|
||||
public async Task Should_Propagate_Caller_Cancellation()
|
||||
{
|
||||
var fetcher = new HttpRemoteImageFetcher(
|
||||
new StubHttpClientFactory(new HangingHttpMessageHandler()),
|
||||
new RecyclableMemoryStreamManager(),
|
||||
Substitute.For<ILogger<HttpRemoteImageFetcher>>());
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => fetcher.Fetch(ImageUri, cts.Token));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Ok(byte[] payload, string mediaType)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(payload) };
|
||||
response.Content.Headers.ContentType = mediaType is null ? null : new MediaTypeHeaderValue(mediaType);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static HttpRemoteImageFetcher FetcherReturning(HttpResponseMessage response) =>
|
||||
new(
|
||||
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
|
||||
new RecyclableMemoryStreamManager(),
|
||||
Substitute.For<ILogger<HttpRemoteImageFetcher>>());
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
using System.Net;
|
||||
using ErsatzTV.Infrastructure.Streaming;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Streaming;
|
||||
|
||||
[TestFixture]
|
||||
public class HttpRemoteStreamProberTests
|
||||
{
|
||||
private const string Url = "http://localhost:8409/media/jellyfin/abc123";
|
||||
|
||||
[Test]
|
||||
public async Task Should_Report_Unavailable_On_404_From_The_Media_Server()
|
||||
{
|
||||
// a media-server 404 arrives after our /media/... endpoint redirected, so the response's
|
||||
// final request uri is the media server's, not the probe url
|
||||
HttpRemoteStreamProber prober = ProberReturning(
|
||||
HttpStatusCode.NotFound,
|
||||
finalUri: "http://jellyfin:8096/Videos/abc123/stream?static=true");
|
||||
|
||||
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
||||
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ersatztv#473 review finding: our OWN /media/{provider}/... endpoint 404s when the media source
|
||||
// is unconfigured or momentarily missing. Failing closed there would blank every item on that
|
||||
// source, which is exactly what the fail-open contract exists to prevent.
|
||||
[Test]
|
||||
public async Task Should_Fail_Open_On_404_That_Was_Not_Redirected()
|
||||
{
|
||||
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound, finalUri: Url);
|
||||
|
||||
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// a plex key can contain spaces/unicode; pin that an un-redirected 404 on such a url still fails
|
||||
// OPEN. (This passes against a naive string comparison too - Uri.ToString() unescapes - so it
|
||||
// guards the behaviour, not the implementation choice.)
|
||||
[Test]
|
||||
public async Task Should_Fail_Open_On_404_For_An_Unredirected_Url_Needing_Escaping()
|
||||
{
|
||||
const string plexUrl = "http://localhost:8409/media/plex/1/library/parts/1/a file.mkv";
|
||||
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound, finalUri: plexUrl);
|
||||
|
||||
bool result = await prober.IsAvailable(plexUrl, CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[TestCase(HttpStatusCode.OK)]
|
||||
[TestCase(HttpStatusCode.PartialContent)]
|
||||
[TestCase(HttpStatusCode.NoContent)]
|
||||
public async Task Should_Report_Available_On_Success(HttpStatusCode statusCode)
|
||||
{
|
||||
HttpRemoteStreamProber prober = ProberReturning(statusCode);
|
||||
|
||||
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// the fail-open contract: a probe that cannot answer must never block a tune that would
|
||||
// otherwise have worked. these cases exist so a future refactor can't silently invert it.
|
||||
[TestCase(HttpStatusCode.InternalServerError)]
|
||||
[TestCase(HttpStatusCode.BadGateway)]
|
||||
[TestCase(HttpStatusCode.Unauthorized)]
|
||||
[TestCase(HttpStatusCode.Forbidden)]
|
||||
public async Task Should_Fail_Open_On_Other_Status_Codes(HttpStatusCode statusCode)
|
||||
{
|
||||
HttpRemoteStreamProber prober = ProberReturning(statusCode);
|
||||
|
||||
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// a server that ignores `Range: bytes=0-0` answers 200 with the WHOLE FILE. The probe must not
|
||||
// read it -- buffering a video on the streaming hot path would be far worse than the aborted
|
||||
// socket the drain was added to avoid. (Review finding against the first fix commit.)
|
||||
[Test]
|
||||
public async Task Should_Not_Read_The_Body_When_The_Server_Ignores_The_Range_Request()
|
||||
{
|
||||
var body = new TrackingStream(64 * 1024 * 1024);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) };
|
||||
|
||||
var prober = new HttpRemoteStreamProber(
|
||||
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
|
||||
Substitute.For<ILogger<HttpRemoteStreamProber>>());
|
||||
|
||||
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
body.BytesRead.ShouldBe(0);
|
||||
}
|
||||
|
||||
// the counterpart: when the server DID honour the range, the one byte is read so the connection
|
||||
// goes back to the pool rather than being aborted
|
||||
[Test]
|
||||
public async Task Should_Drain_The_Single_Byte_When_The_Server_Honours_The_Range_Request()
|
||||
{
|
||||
var body = new TrackingStream(1);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
|
||||
{
|
||||
Content = new StreamContent(body)
|
||||
};
|
||||
|
||||
var prober = new HttpRemoteStreamProber(
|
||||
new StubHttpClientFactory(new FixedResponseHttpMessageHandler(response)),
|
||||
Substitute.For<ILogger<HttpRemoteStreamProber>>());
|
||||
|
||||
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
body.BytesRead.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Fail_Open_On_Transport_Failure()
|
||||
{
|
||||
var prober = new HttpRemoteStreamProber(
|
||||
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new HttpRequestException("no route to host"))),
|
||||
Substitute.For<ILogger<HttpRemoteStreamProber>>());
|
||||
|
||||
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Fail_Open_On_Timeout()
|
||||
{
|
||||
var prober = new HttpRemoteStreamProber(
|
||||
new StubHttpClientFactory(new ThrowingHttpMessageHandler(new TaskCanceledException("timed out"))),
|
||||
Substitute.For<ILogger<HttpRemoteStreamProber>>());
|
||||
|
||||
bool result = await prober.IsAvailable(Url, CancellationToken.None);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// caller cancellation (shutdown / client disconnect) is a genuine signal, NOT a probe failure --
|
||||
// swallowing it would let the handler go on building an ffmpeg command on a dead token.
|
||||
[Test]
|
||||
public async Task Should_Propagate_Caller_Cancellation()
|
||||
{
|
||||
HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.OK);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => prober.IsAvailable(Url, cts.Token));
|
||||
}
|
||||
|
||||
private static HttpRemoteStreamProber ProberReturning(HttpStatusCode statusCode, string finalUri = null) =>
|
||||
new(
|
||||
new StubHttpClientFactory(new StatusCodeHttpMessageHandler(statusCode, finalUri)),
|
||||
Substitute.For<ILogger<HttpRemoteStreamProber>>());
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fakes for the HTTP-backed streaming services. Extracted from
|
||||
/// <see cref="HttpRemoteStreamProberTests" /> when <see cref="HttpRemoteImageFetcherTests" />
|
||||
/// needed the same harness. (ersatztv#511)
|
||||
/// </summary>
|
||||
internal sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
internal sealed class StatusCodeHttpMessageHandler(HttpStatusCode statusCode, string finalUri = null)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// HttpClient rewrites RequestMessage.RequestUri to the final hop when it follows a
|
||||
// redirect; finalUri lets a test stand in for "the media server answered this".
|
||||
if (finalUri is not null)
|
||||
{
|
||||
request.RequestUri = new Uri(finalUri);
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(statusCode) { RequestMessage = request });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FixedResponseHttpMessageHandler(HttpResponseMessage response) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
response.RequestMessage = request;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A readable stream that records how many bytes were actually pulled from it.</summary>
|
||||
internal sealed class TrackingStream(long length) : Stream
|
||||
{
|
||||
public int BytesRead { get; private set; }
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => BytesRead;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (BytesRead >= length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int toRead = (int)Math.Min(count, length - BytesRead);
|
||||
Array.Clear(buffer, offset, toRead);
|
||||
BytesRead += toRead;
|
||||
return toRead;
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A readable stream that returns headers-worth of data instantly and then drips forever —
|
||||
/// the case that <see cref="HttpCompletionOption.ResponseHeadersRead" /> leaves outside
|
||||
/// <see cref="HttpClient.Timeout" />.
|
||||
/// </summary>
|
||||
internal sealed class SlowDripStream : Stream
|
||||
{
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => 0;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(
|
||||
Memory<byte> buffer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
throw new UnreachableException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) =>
|
||||
ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
internal sealed class ThrowingHttpMessageHandler(Exception exception) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromException<HttpResponseMessage>(exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A handler that never completes until the request is cancelled — stands in for a host that
|
||||
/// accepts the connection and then hangs.
|
||||
/// </summary>
|
||||
internal sealed class HangingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
throw new UnreachableException();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A readable stream that yields <paramref name="length" /> bytes but only ever a little at a
|
||||
/// time, so a size cap has to be enforced during the copy rather than from Content-Length.
|
||||
/// </summary>
|
||||
internal sealed class ChunkedZeroStream(long length, int chunkSize = 4096) : Stream
|
||||
{
|
||||
private long _position;
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_position >= length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int toRead = (int)Math.Min(Math.Min(count, chunkSize), length - _position);
|
||||
Array.Clear(buffer, offset, toRead);
|
||||
_position += toRead;
|
||||
return toRead;
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -24,8 +24,5 @@ public class FFmpegProfileConfiguration : IEntityTypeConfiguration<FFmpegProfile
|
||||
|
||||
builder.Property(p => p.NormalizeColors)
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Property(p => p.QsvPreferNativeDecoder)
|
||||
.HasDefaultValue(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data;
|
||||
@@ -132,17 +131,13 @@ public static class DbInitializer
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
int? channelBugWatermarkId = await SeedChannelBugWatermark(context, cancellationToken);
|
||||
await SeedChannelTemplates(context, cancellationToken, channelBugWatermarkId);
|
||||
await SeedChannelTemplates(context, cancellationToken);
|
||||
|
||||
// TODO: create looping static image that mentions configuring via web
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task SeedChannelTemplates(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken,
|
||||
int? channelBugWatermarkId)
|
||||
private static async Task SeedChannelTemplates(TvContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken) &&
|
||||
await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken))
|
||||
@@ -167,8 +162,7 @@ public static class DbInitializer
|
||||
ChannelMusicVideoCreditsMode.None,
|
||||
ChannelSongVideoMode.Default,
|
||||
shuffleScheduleItems: false,
|
||||
randomStartPoint: false,
|
||||
channelBugWatermarkId),
|
||||
randomStartPoint: false),
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -183,8 +177,7 @@ public static class DbInitializer
|
||||
ChannelMusicVideoCreditsMode.GenerateSubtitles,
|
||||
ChannelSongVideoMode.WithProgress,
|
||||
shuffleScheduleItems: true,
|
||||
randomStartPoint: true,
|
||||
channelBugWatermarkId),
|
||||
randomStartPoint: true),
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -192,60 +185,6 @@ public static class DbInitializer
|
||||
await EnsureDefaultChannelTemplateConfig(context, cancellationToken);
|
||||
}
|
||||
|
||||
// A single shared preset is all that's needed: ImageSource.ChannelLogo resolves each channel's
|
||||
// own logo artwork at render time (WatermarkSelector), so one row makes every channel use its
|
||||
// own logo as its on-screen bug.
|
||||
//
|
||||
// Guarded by a ConfigElement marker rather than by name alone: ChannelWatermark has no IsSystem
|
||||
// flag, and Initialize runs on every startup, so a name-only guard would resurrect the row
|
||||
// forever after a deliberate delete. Adopting an existing same-name row (an operator's tuned
|
||||
// one) also sets the marker — adopt, never overwrite.
|
||||
private static async Task<int?> SeedChannelBugWatermark(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string seededKey = ConfigElementKey.WatermarkChannelBugSeeded.Key;
|
||||
bool alreadySeeded = await context.ConfigElements
|
||||
.AnyAsync(c => c.Key == seededKey, cancellationToken);
|
||||
|
||||
ChannelWatermark existing = await context.ChannelWatermarks
|
||||
.FirstOrDefaultAsync(w => w.Name == "Channel Bug", cancellationToken);
|
||||
|
||||
if (alreadySeeded)
|
||||
{
|
||||
return existing?.Id;
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new ChannelWatermark
|
||||
{
|
||||
Name = "Channel Bug",
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
|
||||
Image = null,
|
||||
Location = WatermarkLocation.TopLeft,
|
||||
Size = WatermarkSize.Scaled,
|
||||
WidthPercent = 5.0,
|
||||
HorizontalMarginPercent = 1.0,
|
||||
VerticalMarginPercent = 1.0,
|
||||
FrequencyMinutes = 0,
|
||||
DurationSeconds = 0,
|
||||
Opacity = 80,
|
||||
PlaceWithinSourceContent = false,
|
||||
ZIndex = 0
|
||||
};
|
||||
await context.ChannelWatermarks.AddAsync(existing, cancellationToken);
|
||||
}
|
||||
|
||||
await context.ConfigElements.AddAsync(
|
||||
new ConfigElement { Key = seededKey, Value = "true" },
|
||||
cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return existing.Id;
|
||||
}
|
||||
|
||||
private static async Task<FFmpegProfile> GetDefaultFFmpegProfile(
|
||||
TvContext context,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -298,8 +237,7 @@ public static class DbInitializer
|
||||
ChannelMusicVideoCreditsMode musicVideoCreditsMode,
|
||||
ChannelSongVideoMode songVideoMode,
|
||||
bool shuffleScheduleItems,
|
||||
bool randomStartPoint,
|
||||
int? watermarkId) =>
|
||||
bool randomStartPoint) =>
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
@@ -322,7 +260,6 @@ public static class DbInitializer
|
||||
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
|
||||
ShuffleScheduleItems = shuffleScheduleItems,
|
||||
RandomStartPoint = randomStartPoint,
|
||||
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
|
||||
WatermarkId = watermarkId
|
||||
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dapper;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
@@ -387,53 +387,6 @@ public class EmbyTelevisionRepository(
|
||||
return ids;
|
||||
}
|
||||
|
||||
// #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the
|
||||
// already-scoped show ids needs no provider join.
|
||||
public async Task<List<int>> FlagFileNotFoundSeasonsForShows(
|
||||
List<int> showIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Season WHERE ShowId IN @ParentIds",
|
||||
showIds,
|
||||
cancellationToken);
|
||||
|
||||
// #476: Episode.SeasonId is on the base Episode table.
|
||||
public async Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
|
||||
List<int> seasonIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
|
||||
seasonIds,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<List<int>> FlagFileNotFoundByParent(
|
||||
string selectSql,
|
||||
List<int> parentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
new CommandDefinition(
|
||||
selectSql,
|
||||
parameters: new { ParentIds = parentIds },
|
||||
cancellationToken: cancellationToken))
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
|
||||
parameters: new { Ids = ids },
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundEpisodes(
|
||||
EmbyLibrary library,
|
||||
List<string> episodeItemIds,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Dapper;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -421,53 +421,6 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
|
||||
return ids;
|
||||
}
|
||||
|
||||
// #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the
|
||||
// already-scoped show ids needs no provider join.
|
||||
public async Task<List<int>> FlagFileNotFoundSeasonsForShows(
|
||||
List<int> showIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Season WHERE ShowId IN @ParentIds",
|
||||
showIds,
|
||||
cancellationToken);
|
||||
|
||||
// #476: Episode.SeasonId is on the base Episode table.
|
||||
public async Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
|
||||
List<int> seasonIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
|
||||
seasonIds,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<List<int>> FlagFileNotFoundByParent(
|
||||
string selectSql,
|
||||
List<int> parentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
new CommandDefinition(
|
||||
selectSql,
|
||||
parameters: new { ParentIds = parentIds },
|
||||
cancellationToken: cancellationToken))
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
|
||||
parameters: new { Ids = ids },
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundEpisodes(
|
||||
JellyfinLibrary library,
|
||||
List<string> episodeItemIds,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -169,16 +169,11 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvConte
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
// load from db or create new folder. Look the folder up by (LibraryPathId, Path) rather than
|
||||
// reading libraryPath.LibraryFolders: that navigation collection is only eager-loaded on the
|
||||
// local scan path (via GetLibrary) and is null on the remote (Jellyfin) sync path, which used
|
||||
// to NRE every Jellyfin music-video scan here (ersatztv#488). The local scanners already hit
|
||||
// the db once per folder via GetParentFolderId, so this adds no new query pattern.
|
||||
LibraryFolder knownFolder = await dbContext.LibraryFolders
|
||||
.AsNoTracking()
|
||||
.Filter(f => f.LibraryPathId == libraryPath.Id && f.Path == folder)
|
||||
.FirstOrDefaultAsync()
|
||||
?? CreateNewFolder(libraryPath, maybeParentFolder, folder);
|
||||
// load from db or create new folder
|
||||
LibraryFolder knownFolder = await libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == folder && f.LibraryPathId == libraryPath.Id)
|
||||
.HeadOrNone()
|
||||
.IfNoneAsync(CreateNewFolder(libraryPath, maybeParentFolder, folder));
|
||||
|
||||
// update parent folder if not present
|
||||
foreach (int parentFolder in maybeParentFolder)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -465,53 +465,6 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
|
||||
return ids;
|
||||
}
|
||||
|
||||
// #476: provider-agnostic — Season.ShowId is on the base Season table, so a cascade from the
|
||||
// already-scoped show ids needs no provider join.
|
||||
public async Task<List<int>> FlagFileNotFoundSeasonsForShows(
|
||||
List<int> showIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Season WHERE ShowId IN @ParentIds",
|
||||
showIds,
|
||||
cancellationToken);
|
||||
|
||||
// #476: Episode.SeasonId is on the base Episode table.
|
||||
public async Task<List<int>> FlagFileNotFoundEpisodesForSeasons(
|
||||
List<int> seasonIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await FlagFileNotFoundByParent(
|
||||
"SELECT Id FROM Episode WHERE SeasonId IN @ParentIds",
|
||||
seasonIds,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<List<int>> FlagFileNotFoundByParent(
|
||||
string selectSql,
|
||||
List<int> parentIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
new CommandDefinition(
|
||||
selectSql,
|
||||
parameters: new { ParentIds = parentIds },
|
||||
cancellationToken: cancellationToken))
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
|
||||
parameters: new { Ids = ids },
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundEpisodes(
|
||||
PlexLibrary library,
|
||||
List<string> episodeItemIds,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Health;
|
||||
using ErsatzTV.Core.Health;
|
||||
using ErsatzTV.Core.Health.Checks;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -8,14 +8,7 @@ namespace ErsatzTV.Infrastructure.Health;
|
||||
|
||||
public class HealthCheckService : IHealthCheckService
|
||||
{
|
||||
private const string SummaryCacheKey = "healthcheck.summary";
|
||||
private const string ResultsCacheKey = "healthcheck.results";
|
||||
|
||||
// Health checks shell out to ffmpeg/ffprobe (4 of the 14 checks) on every run, so a bare
|
||||
// GET /api/v1/health spawns ~4 subprocesses per request. Cache the full result list for a
|
||||
// short window so repeated polls (a status widget, an MCP client, monitoring) reuse it; an
|
||||
// explicit refresh (forceRefresh) bypasses and repopulates. See docs/decisions.md 2026-07-19 (#431).
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
|
||||
private const string CacheKey = "healthcheck.summary";
|
||||
|
||||
private readonly List<IHealthCheck> _checks; // ReSharper disable SuggestBaseTypeForParameterInConstructor
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
@@ -63,13 +56,8 @@ public class HealthCheckService : IHealthCheckService
|
||||
];
|
||||
}
|
||||
|
||||
public async Task<List<HealthCheckResult>> PerformHealthChecks(bool forceRefresh, CancellationToken cancellationToken)
|
||||
public async Task<List<HealthCheckResult>> PerformHealthChecks(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!forceRefresh && _memoryCache.TryGetValue(ResultsCacheKey, out List<HealthCheckResult> cached) && cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
List<HealthCheckResult> result = await _checks.Map(c =>
|
||||
{
|
||||
var failedResult = new HealthCheckResult(
|
||||
@@ -87,8 +75,7 @@ public class HealthCheckService : IHealthCheckService
|
||||
result.Count(x => x.Status is HealthCheckStatus.Warning),
|
||||
result.Count(x => x.Status is HealthCheckStatus.Fail));
|
||||
|
||||
_memoryCache.Set(ResultsCacheKey, result, CacheTtl);
|
||||
_memoryCache.Set(SummaryCacheKey, summary);
|
||||
_memoryCache.Set(CacheKey, summary);
|
||||
|
||||
await _mediator.Publish(summary, cancellationToken);
|
||||
|
||||
@@ -96,7 +83,7 @@ public class HealthCheckService : IHealthCheckService
|
||||
}
|
||||
|
||||
public HealthCheckSummary GetHealthCheckSummary() =>
|
||||
_memoryCache.Get<HealthCheckSummary>(SummaryCacheKey) ?? new HealthCheckSummary(0, 0);
|
||||
_memoryCache.Get<HealthCheckSummary>(CacheKey) ?? new HealthCheckSummary(0, 0);
|
||||
|
||||
private HealthCheckResult LogAndReturn(Exception ex, HealthCheckResult failedResult)
|
||||
{
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Images;
|
||||
|
||||
public class RemoteImageValidator : IRemoteImageValidator
|
||||
{
|
||||
public async Task Validate(Stream stream, Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
using Image _ = await DecodeAndValidate(stream, uri, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a remote image only after the header says decoding it is affordable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The fetcher's byte cap does NOT bound this: a decompression bomb is small on the wire and
|
||||
/// huge in memory. A 4 KB PNG can declare 30000x30000 (~3.6 GB), and a 60 KiB GIF can
|
||||
/// declare 2500x2500 across 600 frames (~14 GiB). The budget is therefore on the PRODUCT of
|
||||
/// dimensions and frames, read from the header before the decoder allocates.
|
||||
/// Local images are deliberately not checked — they are files an operator put on disk, not
|
||||
/// bytes an arbitrary host returned. (ersatztv#511)
|
||||
/// </remarks>
|
||||
public static async Task<Image> DecodeAndValidate(Stream stream, Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!stream.CanSeek)
|
||||
{
|
||||
// Identify consumes the stream, so the decode below needs to rewind it. Fail with the
|
||||
// real reason rather than letting Position throw NotSupportedException, which the
|
||||
// caller's blanket catch would report as a generic initialization failure.
|
||||
throw new InvalidOperationException(
|
||||
$"Remote image {uri} was returned on a non-seekable stream; IRemoteImageFetcher must "
|
||||
+ "return a fully buffered, seekable stream");
|
||||
}
|
||||
|
||||
// MaxFrames = 1 on the IDENTIFY is not a limit, it is a workaround: a default Identify
|
||||
// throws InvalidImageContentException on most APNGs — including files ImageSharp's own
|
||||
// PngEncoder wrote, which Image.Load then reads back perfectly (measured: 13 of 16 shapes).
|
||||
// Without this, adding the header pre-pass would silently disable every animated-PNG logo
|
||||
// that worked before this change. Only Width/Height are read below, and those stay correct.
|
||||
ImageInfo info = await Image.IdentifyAsync(
|
||||
new DecoderOptions { MaxFrames = 1 },
|
||||
stream,
|
||||
cancellationToken);
|
||||
|
||||
// DIMENSIONS from the header are trustworthy; the FRAME COUNT is not, and is deliberately
|
||||
// not used as a budget input. Measured on ImageSharp 3.1.12: an APNG reports
|
||||
// FrameMetadataCollection.Count == 0 while the decoder happily produces 600 frames, so a
|
||||
// header-derived frame budget is enforced on a number the decoder does not honor — a
|
||||
// 134 KiB file decodes to ~36 GiB. (Second adversarial re-review; ersatztv#511.)
|
||||
RemoteImageDecodeBudget.EnsureDimensionsAffordable(info.Width, info.Height, uri);
|
||||
|
||||
int affordableFrames = RemoteImageDecodeBudget.AffordableFrames(info.Width, info.Height);
|
||||
|
||||
stream.Position = 0;
|
||||
|
||||
// MaxFrames is enforced BY THE DECODER, so it holds whatever the header claimed — measured
|
||||
// as honored by every animated decoder here (APNG, GIF, WebP, TIFF). Ask for two more than
|
||||
// the budget allows so that an animation exactly AT the limit still decodes in full, while
|
||||
// anything over it is present in the decoded image for the post-decode check below to
|
||||
// reject. Slop is at most two frames: MaxFrames = N yields N frames for GIF/WebP/TIFF but
|
||||
// N-1 for APNG, so the exact count varies by format and only the upper bound matters.
|
||||
var decoderOptions = new DecoderOptions { MaxFrames = (uint)(affordableFrames + 2) };
|
||||
|
||||
Image image = await Image.LoadAsync(decoderOptions, stream, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// re-verify against REALITY rather than against the header. this is the check that
|
||||
// actually holds; everything above it only avoids decoding when we can tell in advance.
|
||||
RemoteImageDecodeBudget.EnsureDecodeAffordable(image.Width, image.Height, image.Frames.Count, uri);
|
||||
return image;
|
||||
}
|
||||
catch
|
||||
{
|
||||
image.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Images;
|
||||
|
||||
public class RemoteLogoCacher(
|
||||
IRemoteImageFetcher fetcher,
|
||||
IRemoteImageValidator validator,
|
||||
IImageCache imageCache) : IRemoteLogoCacher
|
||||
{
|
||||
public async Task<Either<BaseError, string>> CacheFromUrl(Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using Stream stream = await fetcher.Fetch(uri, cancellationToken);
|
||||
|
||||
// validate by decoding under the budget (throws if unsafe); we cache the raw bytes
|
||||
await validator.Validate(stream, uri, cancellationToken);
|
||||
|
||||
stream.Position = 0;
|
||||
return await imageCache.SaveArtworkToCache(stream, ArtworkKind.Logo);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New($"Could not download logo from {uri}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
@@ -435,9 +435,7 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
}
|
||||
|
||||
private Option<JellyfinLibrary> Project(JellyfinLibraryResponse response) =>
|
||||
// normalize "no content type" to null: Jellyfin serializes a mixed library's collection type
|
||||
// as absent, empty or whitespace depending on server version, and all three mean the same thing
|
||||
(string.IsNullOrWhiteSpace(response.CollectionType) ? null : response.CollectionType.ToLowerInvariant()) switch
|
||||
response.CollectionType?.ToLowerInvariant() switch
|
||||
{
|
||||
"tvshows" => new JellyfinLibrary
|
||||
{
|
||||
@@ -468,21 +466,6 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
},
|
||||
// TODO: ??? for music libraries
|
||||
"boxsets" => CacheCollectionLibraryId(response.ItemId),
|
||||
|
||||
// A "mixed content" library. Jellyfin reports these as either the literal "mixed" or with
|
||||
// no collection type at all, depending on server version. Its items are read per type via
|
||||
// includeItemTypes, so the mix is resolved authoritatively by Jellyfin rather than guessed.
|
||||
"mixed" or null => new JellyfinLibrary
|
||||
{
|
||||
ItemId = response.ItemId,
|
||||
Name = response.Name,
|
||||
MediaKind = LibraryMediaKind.Mixed,
|
||||
ShouldSyncItems = false,
|
||||
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
|
||||
PathInfos = GetPathInfos(response)
|
||||
},
|
||||
|
||||
// anything else (notably "music" audio libraries) stays unsupported
|
||||
_ => None
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly IRemoteStreamProber _remoteStreamProber;
|
||||
|
||||
public ExternalJsonPlayoutItemProvider(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
@@ -35,7 +34,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IRemoteStreamProber remoteStreamProber,
|
||||
ILogger<ExternalJsonPlayoutItemProvider> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -44,7 +42,6 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_remoteStreamProber = remoteStreamProber;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -220,29 +217,15 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
|
||||
|
||||
foreach (PlexServerAuthToken token in maybeToken)
|
||||
{
|
||||
var plexUrl =
|
||||
$"http://localhost:{Settings.StreamingPort}/media/plex/{server.Id}/{program.PlexFile}";
|
||||
|
||||
// #480: probe the remote-stream URL before handing it to ffmpeg, exactly as the
|
||||
// generated-playout path does in
|
||||
// GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath (#473). Without
|
||||
// this, an item that is gone from the media server 404s under ffmpeg (exit 8) and the
|
||||
// same dead item is re-selected for its whole slot. The fail-open contract (only a
|
||||
// *redirected* 404 fails closed) lives inside IRemoteStreamProber, so this call site
|
||||
// only owns the decision to probe, not the policy. Probing first also skips the Plex
|
||||
// metadata round-trip when the item is already gone.
|
||||
if (!await _remoteStreamProber.IsAvailable(plexUrl, cancellationToken))
|
||||
{
|
||||
return new PlayoutItemNotAvailableFromMediaServer(plexUrl);
|
||||
}
|
||||
|
||||
MediaItem mediaItem = program.Type switch
|
||||
{
|
||||
"episode" => await GetPlexEpisode(server, connection, token, program),
|
||||
_ => await GetPlexMovie(server, connection, token, program)
|
||||
};
|
||||
|
||||
return new PlayoutItemWithPath(GetPlayoutItem(startTime, mediaItem, program), plexUrl);
|
||||
return new PlayoutItemWithPath(
|
||||
GetPlayoutItem(startTime, mediaItem, program),
|
||||
$"http://localhost:{Settings.StreamingPort}/media/plex/{server.Id}/{program.PlexFile}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ public class GraphicsEngine(
|
||||
ITempFilePool tempFilePool,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IRemoteImageFetcher remoteImageFetcher,
|
||||
ILogger<GraphicsEngine> logger)
|
||||
: IGraphicsEngine
|
||||
{
|
||||
@@ -34,10 +33,7 @@ public class GraphicsEngine(
|
||||
switch (element)
|
||||
{
|
||||
case WatermarkElementContext watermarkElementContext:
|
||||
var watermark = new WatermarkElement(
|
||||
watermarkElementContext.Options,
|
||||
remoteImageFetcher,
|
||||
logger);
|
||||
var watermark = new WatermarkElement(watermarkElementContext.Options, logger);
|
||||
if (watermark.IsValid)
|
||||
{
|
||||
elements.Add(watermark);
|
||||
@@ -46,8 +42,7 @@ public class GraphicsEngine(
|
||||
break;
|
||||
|
||||
case ImageElementContext imageElementContext:
|
||||
elements.Add(
|
||||
new ImageElement(imageElementContext.ImageElement, remoteImageFetcher, logger));
|
||||
elements.Add(new ImageElement(imageElementContext.ImageElement, logger));
|
||||
break;
|
||||
|
||||
case TextElementDataContext textElementContext:
|
||||
|
||||
@@ -6,10 +6,7 @@ using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public class ImageElement(
|
||||
ImageGraphicsElement imageGraphicsElement,
|
||||
IRemoteImageFetcher remoteImageFetcher,
|
||||
ILogger logger) : ImageElementBase(remoteImageFetcher)
|
||||
public class ImageElement(ImageGraphicsElement imageGraphicsElement, ILogger logger) : ImageElementBase
|
||||
{
|
||||
private Option<Expression> _maybeOpacityExpression;
|
||||
private float _opacity;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats;
|
||||
using SixLabors.ImageSharp.Formats.Gif;
|
||||
@@ -17,18 +14,8 @@ using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) : GraphicsElement, IDisposable
|
||||
public abstract class ImageElementBase : GraphicsElement, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Ceiling on total pixels RETAINED after scaling — frames x scaled width x scaled height.
|
||||
/// Independent of the source decode budget in <see cref="RemoteImageDecodeBudget" />: a
|
||||
/// 100x100 source is trivial to decode but, at 600
|
||||
/// frames scaled to 1920x1080, retains ~5 GB of <see cref="SKBitmap" />. At 4 bytes per
|
||||
/// pixel this bounds retention at ~800 MB, which still allows ~96 full-frame 1080p frames
|
||||
/// (~3s at 30fps) or 600 frames of a 577x577 logo.
|
||||
/// </summary>
|
||||
internal const long MaxRemoteScaledPixels = 200_000_000;
|
||||
|
||||
private readonly List<double> _frameDelays = [];
|
||||
private readonly List<SKBitmap> _scaledFrames = [];
|
||||
private double _animatedDurationSeconds;
|
||||
@@ -62,8 +49,9 @@ public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) :
|
||||
|
||||
if (isRemoteUri)
|
||||
{
|
||||
await using Stream imageStream = await remoteImageFetcher.Fetch(uriResult, cancellationToken);
|
||||
_sourceImage = await DecodeRemoteImage(imageStream, uriResult, cancellationToken);
|
||||
using var client = new HttpClient();
|
||||
await using Stream imageStream = await client.GetStreamAsync(uriResult, cancellationToken);
|
||||
_sourceImage = await Image.LoadAsync(imageStream, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -79,11 +67,6 @@ public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) :
|
||||
scaledHeight = (int)(scaledWidth * aspectRatio);
|
||||
}
|
||||
|
||||
if (isRemoteUri)
|
||||
{
|
||||
EnsureScaledFramesAffordable(_sourceImage.Frames.Count, scaledWidth, scaledHeight, uriResult);
|
||||
}
|
||||
|
||||
(int horizontalMargin, int verticalMargin) = placeWithinSourceContent
|
||||
? SourceContentMargins(
|
||||
squarePixelFrameSize,
|
||||
@@ -120,36 +103,6 @@ public abstract class ImageElementBase(IRemoteImageFetcher remoteImageFetcher) :
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a remote image only after the header says decoding it is affordable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The fetcher's byte cap does NOT bound this: a decompression bomb is small on the wire and
|
||||
/// huge in memory. A 4 KB PNG can declare 30000x30000 (~3.6 GB), and a 60 KiB GIF can
|
||||
/// declare 2500x2500 across 600 frames (~14 GiB). The budget is therefore on the PRODUCT of
|
||||
/// dimensions and frames, read from the header before the decoder allocates.
|
||||
/// Local images are deliberately not checked — they are files an operator put on disk, not
|
||||
/// bytes an arbitrary host returned. (ersatztv#511)
|
||||
/// </remarks>
|
||||
internal static async Task<Image> DecodeRemoteImage(Stream stream, Uri uri, CancellationToken cancellationToken)
|
||||
=> await RemoteImageValidator.DecodeAndValidate(stream, uri, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bounds what is RETAINED after scaling. Separate from the source budget because the two
|
||||
/// are independent: a cheap-to-decode 100x100 source scaled to 1920x1080 across 600 frames
|
||||
/// retains ~5 GB. Only applied to remote images, matching the rest of this guard.
|
||||
/// </summary>
|
||||
internal static void EnsureScaledFramesAffordable(int frameCount, int scaledWidth, int scaledHeight, Uri uri)
|
||||
{
|
||||
long retainedPixels = (long)Math.Max(frameCount, 1) * scaledWidth * scaledHeight;
|
||||
if (retainedPixels > MaxRemoteScaledPixels)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote image {uri} scales to {frameCount} frames of {scaledWidth}x{scaledHeight} "
|
||||
+ $"({retainedPixels} pixels), over the {MaxRemoteScaledPixels} pixel limit");
|
||||
}
|
||||
}
|
||||
|
||||
protected static SKBitmap ToSkiaBitmap(Image image)
|
||||
{
|
||||
using Image<Rgba32> rgbaImage = image.CloneAs<Rgba32>();
|
||||
|
||||
@@ -17,8 +17,7 @@ public class WatermarkElement : ImageElementBase
|
||||
private Option<Expression> _maybeOpacityExpression;
|
||||
private float _opacity;
|
||||
|
||||
public WatermarkElement(WatermarkOptions watermarkOptions, IRemoteImageFetcher remoteImageFetcher, ILogger logger)
|
||||
: base(remoteImageFetcher)
|
||||
public WatermarkElement(WatermarkOptions watermarkOptions, ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
// TODO: better model coming in here?
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
using System.Buffers;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a remote graphics-engine image over HTTP with a bounded timeout and a bounded size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This runs during graphics-engine element initialization, which happens inside stream startup
|
||||
/// while ffmpeg waits on the pipe — so an unbounded fetch stalls the tune. Every failure here is
|
||||
/// surfaced as an exception and the calling element disables itself. (ersatztv#511)
|
||||
/// </remarks>
|
||||
public class HttpRemoteImageFetcher : IRemoteImageFetcher
|
||||
{
|
||||
/// <summary>Named <see cref="HttpClient" /> configured with the redirect cap in Startup.</summary>
|
||||
public const string HttpClientName = "RemoteImage";
|
||||
|
||||
/// <summary>
|
||||
/// Covers the whole exchange — connect, headers AND body — because the body read happens
|
||||
/// outside <see cref="HttpClient.Timeout" /> under <see cref="HttpCompletionOption.ResponseHeadersRead" />,
|
||||
/// so a slow-drip host would otherwise hang forever. (docs/decisions.md, #289)
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Wire-size cap. A channel logo or overlay is orders of magnitude smaller than this.
|
||||
/// This bounds transfer and buffering only — it is NOT decode-bomb protection, since a bomb
|
||||
/// is by definition small on the wire. That check lives in
|
||||
/// <c>ImageElementBase.DecodeRemoteImage</c>, which reads the declared dimensions and frame
|
||||
/// count from the header before the decoder allocates.
|
||||
/// </summary>
|
||||
internal const long MaxImageBytes = 10 * 1024 * 1024;
|
||||
|
||||
private readonly TimeSpan _fetchTimeout;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<HttpRemoteImageFetcher> _logger;
|
||||
private readonly RecyclableMemoryStreamManager _memoryStreamManager;
|
||||
|
||||
public HttpRemoteImageFetcher(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
RecyclableMemoryStreamManager memoryStreamManager,
|
||||
ILogger<HttpRemoteImageFetcher> logger)
|
||||
: this(httpClientFactory, memoryStreamManager, logger, DefaultFetchTimeout)
|
||||
{
|
||||
}
|
||||
|
||||
// the timeout is only parameterized so a test can prove the deadline fires without waiting the
|
||||
// real ten seconds; production always goes through the constructor above.
|
||||
internal HttpRemoteImageFetcher(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
RecyclableMemoryStreamManager memoryStreamManager,
|
||||
ILogger<HttpRemoteImageFetcher> logger,
|
||||
TimeSpan fetchTimeout)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_memoryStreamManager = memoryStreamManager;
|
||||
_logger = logger;
|
||||
_fetchTimeout = fetchTimeout;
|
||||
}
|
||||
|
||||
public async Task<Stream> Fetch(Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(_fetchTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
using HttpClient client = _httpClientFactory.CreateClient(HttpClientName);
|
||||
|
||||
// set here, not only in the DI registration, so the deadline cannot silently widen to
|
||||
// HttpClient's 100s default if that registration is ever dropped or reordered. the
|
||||
// factory hands back a fresh wrapper each call, so mutating it is safe.
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
using HttpResponseMessage response = await client.GetAsync(
|
||||
uri,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
timeoutCts.Token);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string mediaType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (!IsAcceptableMediaType(mediaType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote image {uri} returned content type '{mediaType}', which is not an image");
|
||||
}
|
||||
|
||||
// the advertised length is a cheap early reject; it is not trusted, because it can be
|
||||
// absent or a lie. the copy below is what actually enforces the cap.
|
||||
long? contentLength = response.Content.Headers.ContentLength;
|
||||
if (contentLength > MaxImageBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote image {uri} advertises {contentLength} bytes, over the {MaxImageBytes} byte limit");
|
||||
}
|
||||
|
||||
await using Stream source = await response.Content.ReadAsStreamAsync(timeoutCts.Token);
|
||||
return await CopyCapped(source, uri, timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException canceled) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// OUR deadline fired, not the caller's. re-thrown as a TimeoutException so the element's
|
||||
// warning names the real cause; caller cancellation (shutdown / client disconnect) still
|
||||
// propagates as OperationCanceledException.
|
||||
_logger.LogWarning(
|
||||
"Timed out after {Seconds}s fetching remote image {Uri}",
|
||||
_fetchTimeout.TotalSeconds,
|
||||
uri);
|
||||
|
||||
throw new TimeoutException($"Timed out fetching remote image {uri}", canceled);
|
||||
}
|
||||
}
|
||||
|
||||
// a missing content type is allowed (some hosts omit it) and octet-stream is allowed (a common
|
||||
// default for statically served files). anything else that is positively NOT an image -- an html
|
||||
// error page, say -- is rejected before it reaches the decoder.
|
||||
private static bool IsAcceptableMediaType(string mediaType) =>
|
||||
string.IsNullOrWhiteSpace(mediaType)
|
||||
|| mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|
||||
|| mediaType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<Stream> CopyCapped(Stream source, Uri uri, CancellationToken cancellationToken)
|
||||
{
|
||||
MemoryStream buffer = _memoryStreamManager.GetStream(nameof(HttpRemoteImageFetcher));
|
||||
byte[] chunk = ArrayPool<byte>.Shared.Rent(81920);
|
||||
|
||||
try
|
||||
{
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(chunk.AsMemory(), cancellationToken)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > MaxImageBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Remote image {uri} exceeds the {MaxImageBytes} byte limit");
|
||||
}
|
||||
|
||||
await buffer.WriteAsync(chunk.AsMemory(0, read), cancellationToken);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await buffer.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(chunk);
|
||||
}
|
||||
|
||||
buffer.Position = 0;
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Probes a media-server remote-stream URL over HTTP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately fail-open: the only outcome that reports the media as gone is a 404 that came
|
||||
/// from the media server itself (i.e. arrived after our <c>/media/{provider}/...</c> endpoint
|
||||
/// redirected). A timeout, a transport failure, any other status, or a 404 raised by ErsatzTV's
|
||||
/// own endpoint all report available, so a probe that cannot answer never turns a tune that
|
||||
/// would have worked into an error card. (ersatztv#473)
|
||||
/// </remarks>
|
||||
public class HttpRemoteStreamProber(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<HttpRemoteStreamProber> logger) : IRemoteStreamProber
|
||||
{
|
||||
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
public async Task<bool> IsAvailable(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(ProbeTimeout);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// ask for a single byte; media servers vary in their HEAD support, and this exercises the
|
||||
// same redirect chain ffmpeg will follow
|
||||
request.Headers.Range = new RangeHeaderValue(0, 0);
|
||||
|
||||
using HttpClient client = httpClientFactory.CreateClient();
|
||||
using HttpResponseMessage response = await client.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
timeoutCts.Token);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.NotFound)
|
||||
{
|
||||
// only the MEDIA SERVER's 404 is evidence that the item is gone. our own
|
||||
// /media/{provider}/... endpoint also returns 404 when the media source is
|
||||
// unconfigured or momentarily missing (InternalController maps a failed
|
||||
// connection-parameter lookup to NotFound), and treating that as "gone" would fail
|
||||
// CLOSED for every item on that source. A media-server 404 always arrives after a
|
||||
// redirect, so an un-redirected 404 came from us and must fail open.
|
||||
if (WasRedirected(response, url))
|
||||
{
|
||||
logger.LogWarning("Media server reported 404 for remote stream {Url}", url);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Probe of {Url} returned 404 without redirecting to a media server; assuming the "
|
||||
+ "item is available rather than failing closed on our own endpoint",
|
||||
url);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// return the connection to the pool instead of aborting it by disposing an unread
|
||||
// stream - but ONLY where the server honoured the range, i.e. the body really is one
|
||||
// byte. A server that ignores `Range` answers 200 with the WHOLE FILE, and draining that
|
||||
// would download at line rate into memory on the streaming hot path, defeating the
|
||||
// ResponseHeadersRead above. There, abort the socket - much the cheaper evil.
|
||||
if (response.StatusCode is HttpStatusCode.PartialContent)
|
||||
{
|
||||
var singleByte = new byte[1];
|
||||
Stream body = await response.Content.ReadAsStreamAsync(timeoutCts.Token);
|
||||
await body.ReadAsync(singleByte, timeoutCts.Token);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// the CALLER cancelled (shutdown / client disconnect). that is a genuine signal, not a
|
||||
// probe failure, so it must propagate rather than be swallowed as fail-open.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// fail open - a probe failure is not evidence that the media is gone
|
||||
logger.LogDebug(ex, "Unable to probe remote stream {Url}; assuming it is available", url);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool WasRedirected(HttpResponseMessage response, string probeUrl)
|
||||
{
|
||||
Uri finalUri = response.RequestMessage?.RequestUri;
|
||||
if (finalUri is null || !Uri.TryCreate(probeUrl, UriKind.Absolute, out Uri requestedUri))
|
||||
{
|
||||
// can't tell where the 404 came from; fail open rather than guess
|
||||
return false;
|
||||
}
|
||||
|
||||
// compare parsed Uris rather than strings. Uri.Equals compares normalized components, so it
|
||||
// can't mistake an escaping/casing difference for a redirect and fail CLOSED - the exact
|
||||
// failure this check exists to prevent. (A string compare on Uri.ToString() happens to agree
|
||||
// for our machine-generated URLs, since ToString unescapes; this is defense in depth, not a
|
||||
// fix for an observed bug.)
|
||||
return !Uri.Equals(finalUri, requestedUri);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
using System.Text;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class BoundedLineReaderTests
|
||||
{
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Return_Line_Without_Trailing_Newline()
|
||||
{
|
||||
using var reader = new StringReader("hello world\n");
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
|
||||
|
||||
line.EndOfStream.ShouldBeFalse();
|
||||
line.Overflowed.ShouldBeFalse();
|
||||
line.Text.ShouldBe("hello world");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Return_Line_Of_Exactly_Cap_Length_Intact()
|
||||
{
|
||||
// The cap is the inclusive max: a line of exactly `cap` chars is returned, not overflowed.
|
||||
using var reader = new StringReader("abcdefgh\n");
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 8);
|
||||
|
||||
line.Overflowed.ShouldBeFalse();
|
||||
line.Text.ShouldBe("abcdefgh");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Strip_Carriage_Return_In_Crlf()
|
||||
{
|
||||
using var reader = new StringReader("hello\r\n");
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
|
||||
|
||||
line.Text.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Signal_End_Of_Stream()
|
||||
{
|
||||
using var reader = new StringReader(string.Empty);
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
|
||||
|
||||
line.EndOfStream.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Overflow_And_Not_Buffer_Oversized_Line()
|
||||
{
|
||||
// A line far longer than the cap must be reported overflowed with no buffered text —
|
||||
// the memory-exhaustion guard.
|
||||
string oversized = new string('x', 10_000) + "\n";
|
||||
using var reader = new StringReader(oversized);
|
||||
|
||||
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 16);
|
||||
|
||||
line.EndOfStream.ShouldBeFalse();
|
||||
line.Overflowed.ShouldBeTrue();
|
||||
line.Text.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReadLineAsync_Should_Keep_Subsequent_Lines_Aligned_After_Overflow()
|
||||
{
|
||||
// After draining an oversized line, the next line must still be read intact.
|
||||
var content = new StringBuilder()
|
||||
.Append(new string('x', 100)).Append('\n')
|
||||
.Append("good\n")
|
||||
.ToString();
|
||||
using var reader = new StringReader(content);
|
||||
|
||||
BoundedLineReader.Line first = await BoundedLineReader.ReadLineAsync(reader, 16);
|
||||
BoundedLineReader.Line second = await BoundedLineReader.ReadLineAsync(reader, 16);
|
||||
|
||||
first.Overflowed.ShouldBeTrue();
|
||||
second.Overflowed.ShouldBeFalse();
|
||||
second.Text.ShouldBe("good");
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,485 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ErsatzTvApiClientTests
|
||||
{
|
||||
private static ToolDefinition GetChannel() => new(
|
||||
"ersatztv_get_channel",
|
||||
"Get channel",
|
||||
HttpMethod.Get,
|
||||
"/api/v1/channels/{id}",
|
||||
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true)));
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Substitute_Path_Parameters_And_Send_Api_Key()
|
||||
{
|
||||
CapturingHandler handler = new("""{"id":12,"name":"Kids"}""");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost:8409/"), "secret"));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
GetChannel(),
|
||||
JsonDocument.Parse("""{"id":12}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeFalse();
|
||||
result.Text.ShouldBe("""{"id":12,"name":"Kids"}""");
|
||||
handler.RequestUri.ShouldBe(new Uri("http://localhost:8409/api/v1/channels/12"));
|
||||
handler.ApiKey.ShouldBe("secret");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Url_Encode_Path_Parameters()
|
||||
{
|
||||
CapturingHandler handler = new("""{"id":1}""");
|
||||
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_resolution_by_name",
|
||||
"Get resolution",
|
||||
HttpMethod.Get,
|
||||
"/api/v1/ffmpeg/resolution/by-name/{name}",
|
||||
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Resolution name", Required: true))),
|
||||
JsonDocument.Parse("""{"name":"1920 x 1080"}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/ffmpeg/resolution/by-name/1920%20x%201080"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Return_Error_Result_For_Non_Success_Status()
|
||||
{
|
||||
CapturingHandler handler = new("""{"status":404,"title":"Resource not found"}""", HttpStatusCode.NotFound);
|
||||
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
GetChannel(),
|
||||
JsonDocument.Parse("""{"id":404}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeTrue();
|
||||
result.Text.ShouldContain("404");
|
||||
result.Text.ShouldContain("Resource not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Refuse_Non_Get_Tool_When_Read_Only()
|
||||
{
|
||||
CapturingHandler handler = new("{}");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_delete_channel",
|
||||
"Delete channel",
|
||||
HttpMethod.Delete,
|
||||
"/api/v1/channels/{id}",
|
||||
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true))),
|
||||
JsonDocument.Parse("""{"id":12}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeTrue();
|
||||
result.Text.ShouldContain("read-only");
|
||||
// The request must never reach the API.
|
||||
handler.RequestUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Allow_Non_Get_Tool_When_Writes_Enabled()
|
||||
{
|
||||
CapturingHandler handler = new("", HttpStatusCode.NoContent);
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, AllowWrites: true));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_delete_channel",
|
||||
"Delete channel",
|
||||
HttpMethod.Delete,
|
||||
"/api/v1/channels/{id}",
|
||||
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true))),
|
||||
JsonDocument.Parse("""{"id":12}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeFalse();
|
||||
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/channels/12"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Serialize_Non_Path_Args_As_Json_Body_For_Post()
|
||||
{
|
||||
CapturingHandler handler = new("""{"id":9,"name":"Kids"}""", HttpStatusCode.Created);
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_create_collection",
|
||||
"Create collection",
|
||||
HttpMethod.Post,
|
||||
"/api/v1/collections",
|
||||
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Name", Required: true))),
|
||||
JsonDocument.Parse("""{"name":"Kids"}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/collections"));
|
||||
handler.ContentType.ShouldBe("application/json");
|
||||
handler.Body.ShouldBe("""{"name":"Kids"}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Keep_Path_Args_Out_Of_The_Body_And_Preserve_Array_Values()
|
||||
{
|
||||
CapturingHandler handler = new("", HttpStatusCode.NoContent);
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_add_collection_items",
|
||||
"Add items",
|
||||
HttpMethod.Post,
|
||||
"/api/v1/collections/{id}/items",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
||||
new SchemaProperty("artistIds", "array", "Artist ids", Required: false, ItemType: "integer"))),
|
||||
JsonDocument.Parse("""{"id":20,"artistIds":[1,2,3]}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/collections/20/items"));
|
||||
// The path id must not leak into the body; array values are preserved verbatim.
|
||||
handler.Body.ShouldBe("""{"artistIds":[1,2,3]}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Send_IfMatch_Header_And_Keep_It_Out_Of_The_Body()
|
||||
{
|
||||
CapturingHandler handler = new("[]");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_update_collection_custom_order",
|
||||
"Reorder",
|
||||
HttpMethod.Put,
|
||||
"/api/v1/collections/{id}/custom-order",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
||||
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
|
||||
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
|
||||
JsonDocument.Parse("""{"id":20,"mediaItemIds":[3,1,2],"ifMatch":"\"5\""}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.IfMatch.ShouldBe("\"5\"");
|
||||
// ifMatch is a header, not a body field; the path id is also excluded.
|
||||
handler.Body.ShouldBe("""{"mediaItemIds":[3,1,2]}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Pass_Through_IfMatch_Wildcard_Force_Write()
|
||||
{
|
||||
CapturingHandler handler = new("[]");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_update_collection_custom_order",
|
||||
"Reorder",
|
||||
HttpMethod.Put,
|
||||
"/api/v1/collections/{id}/custom-order",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
||||
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
|
||||
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
|
||||
JsonDocument.Parse("""{"id":20,"mediaItemIds":[1],"ifMatch":"*"}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.IfMatch.ShouldBe("*");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Reject_IfMatch_With_Control_Characters()
|
||||
{
|
||||
CapturingHandler handler = new("[]");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
||||
|
||||
// A CR/LF in ifMatch would smuggle a second header via TryAddWithoutValidation — must be
|
||||
// rejected before the request is sent (SocketsHttpHandler does not strip it).
|
||||
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_update_collection_custom_order",
|
||||
"Reorder",
|
||||
HttpMethod.Put,
|
||||
"/api/v1/collections/{id}/custom-order",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
||||
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
|
||||
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
|
||||
JsonDocument.Parse("{\"id\":20,\"mediaItemIds\":[1],\"ifMatch\":\"\\\"5\\\"\\r\\nX-Evil: 1\"}").RootElement,
|
||||
CancellationToken.None));
|
||||
|
||||
handler.RequestUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Serialize_Explicit_Null_Body_Field()
|
||||
{
|
||||
CapturingHandler handler = new("{}");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
||||
|
||||
// An explicit null on an optional field is preserved in the body (clears a nullable API field).
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_update_playout",
|
||||
"Update playout",
|
||||
HttpMethod.Put,
|
||||
"/api/v1/playouts/{id}",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("id", "integer", "Playout id", Required: true),
|
||||
new SchemaProperty("dailyRebuildTime", "string", "Daily rebuild time; null clears", Required: false))),
|
||||
JsonDocument.Parse("""{"id":1,"dailyRebuildTime":null}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.Body.ShouldBe("""{"dailyRebuildTime":null}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Append_Declared_Query_Parameters_On_Get()
|
||||
{
|
||||
CapturingHandler handler = new("[]");
|
||||
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k"));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_search_all_items",
|
||||
"Search",
|
||||
HttpMethod.Get,
|
||||
"/api/v1/search/all-items",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("query", "string", "Query", Required: false),
|
||||
new SchemaProperty("pageNum", "integer", "Page", Required: false)),
|
||||
new HashSet<string>(StringComparer.Ordinal) { "query", "pageNum" }),
|
||||
JsonDocument.Parse("""{"query":"genre:jazz","pageNum":2}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.RequestUri!.PathAndQuery.ShouldBe("/api/v1/search/all-items?query=genre%3Ajazz&pageNum=2");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Put_Query_Params_In_Query_Not_Body_For_Post()
|
||||
{
|
||||
CapturingHandler handler = new("", HttpStatusCode.Accepted);
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
|
||||
|
||||
await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_scan_library",
|
||||
"Scan",
|
||||
HttpMethod.Post,
|
||||
"/api/v1/libraries/{id}/scan",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("id", "integer", "Library id", Required: true),
|
||||
new SchemaProperty("deep", "boolean", "Deep", Required: false)),
|
||||
new HashSet<string>(StringComparer.Ordinal) { "deep" }),
|
||||
JsonDocument.Parse("""{"id":3,"deep":true}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.RequestUri!.PathAndQuery.ShouldBe("/api/v1/libraries/3/scan?deep=true");
|
||||
// deep is a query param; there is no JSON body.
|
||||
handler.Body.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Surface_Response_ETag()
|
||||
{
|
||||
CapturingHandler handler = new("""{"items":[]}""", HttpStatusCode.OK, etag: "\"3\"");
|
||||
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k"));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_collection_items",
|
||||
"Items",
|
||||
HttpMethod.Get,
|
||||
"/api/v1/collections/{id}/items",
|
||||
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Collection id", Required: true))),
|
||||
JsonDocument.Parse("""{"id":20}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeFalse();
|
||||
result.Text.ShouldContain("[etag: \"3\"]");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Truncate_Oversized_Response()
|
||||
{
|
||||
CapturingHandler handler = new(new string('x', 500));
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 16));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
|
||||
JsonDocument.Parse("{}").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Text.ShouldStartWith(new string('x', 16));
|
||||
result.Text.ShouldContain("truncated");
|
||||
result.Text.Length.ShouldBeLessThan(500);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Preserve_Reverse_Proxy_Path_Prefix()
|
||||
{
|
||||
CapturingHandler handler = new("""{"id":12}""");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://host/etv/"), null));
|
||||
|
||||
await client.CallToolAsync(
|
||||
GetChannel(),
|
||||
JsonDocument.Parse("""{"id":12}""").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
handler.RequestUri.ShouldBe(new Uri("http://host/etv/api/v1/channels/12"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Reject_Unknown_Argument()
|
||||
{
|
||||
CapturingHandler handler = new("{}");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
|
||||
GetChannel(),
|
||||
JsonDocument.Parse("""{"id":12,"evil":"drop"}""").RootElement,
|
||||
CancellationToken.None));
|
||||
|
||||
handler.RequestUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Reject_Dot_Segment_Path_Parameter()
|
||||
{
|
||||
CapturingHandler handler = new("{}");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
|
||||
|
||||
// ".." would canonicalize the URL onto a different route — must be rejected pre-flight.
|
||||
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
|
||||
new ToolDefinition(
|
||||
"ersatztv_get_resolution_by_name",
|
||||
"Get resolution",
|
||||
HttpMethod.Get,
|
||||
"/api/v1/ffmpeg/resolution/by-name/{name}",
|
||||
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Resolution name", Required: true))),
|
||||
JsonDocument.Parse("""{"name":".."}""").RootElement,
|
||||
CancellationToken.None));
|
||||
|
||||
handler.RequestUri.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Fall_Back_To_Default_Cap_On_Overflowing_Configured_Cap()
|
||||
{
|
||||
CapturingHandler handler = new("""{"ok":true}""");
|
||||
// int.MaxValue would overflow `cap + 1` to a negative array length; the client must
|
||||
// clamp to the default instead of crashing.
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: int.MaxValue));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
|
||||
JsonDocument.Parse("{}").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsError.ShouldBeFalse();
|
||||
result.Text.ShouldBe("""{"ok":true}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CallToolAsync_Should_Not_Emit_Replacement_Char_When_Truncating_Mid_Codepoint()
|
||||
{
|
||||
// "ab😀" — the emoji is a 4-byte sequence starting at byte index 2; a 4-byte cap cuts it
|
||||
// mid-sequence. The truncated text must end cleanly, not with a U+FFFD replacement char.
|
||||
CapturingHandler handler = new("ab\U0001F600");
|
||||
var client = new ErsatzTvApiClient(
|
||||
new HttpClient(handler),
|
||||
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 4));
|
||||
|
||||
ToolCallResult result = await client.CallToolAsync(
|
||||
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/v1/channels", ToolInputSchemas.Empty),
|
||||
JsonDocument.Parse("{}").RootElement,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Text.ShouldStartWith("ab");
|
||||
result.Text.ShouldNotContain("�");
|
||||
result.Text.ShouldContain("truncated");
|
||||
}
|
||||
|
||||
private sealed class CapturingHandler(string response, HttpStatusCode statusCode = HttpStatusCode.OK, string? etag = null)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public Uri? RequestUri { get; private set; }
|
||||
public string? ApiKey { get; private set; }
|
||||
public string? Body { get; private set; }
|
||||
public string? ContentType { get; private set; }
|
||||
public string? IfMatch { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestUri = request.RequestUri;
|
||||
ApiKey = request.Headers.TryGetValues("X-Api-Key", out IEnumerable<string>? values)
|
||||
? values.Single()
|
||||
: null;
|
||||
IfMatch = request.Headers.TryGetValues("If-Match", out IEnumerable<string>? ifMatch)
|
||||
? ifMatch.Single()
|
||||
: null;
|
||||
if (request.Content is not null)
|
||||
{
|
||||
Body = request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
|
||||
ContentType = request.Content.Headers.ContentType?.MediaType;
|
||||
}
|
||||
|
||||
var message = new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(response)
|
||||
};
|
||||
if (etag is not null)
|
||||
{
|
||||
message.Headers.ETag = new EntityTagHeaderValue(etag);
|
||||
}
|
||||
|
||||
return Task.FromResult(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class McpServerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Server_Capabilities_For_Initialize()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""");
|
||||
|
||||
JsonElement result = response.RootElement.GetProperty("result");
|
||||
result.GetProperty("protocolVersion").GetString().ShouldBe("2024-11-05");
|
||||
result.GetProperty("serverInfo").GetProperty("name").GetString().ShouldBe("ersatztv-mcp");
|
||||
result.GetProperty("capabilities").TryGetProperty("tools", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_List_Tools()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""");
|
||||
|
||||
string[] toolNames = response.RootElement
|
||||
.GetProperty("result")
|
||||
.GetProperty("tools")
|
||||
.EnumerateArray()
|
||||
.Select(t => t.GetProperty("name").GetString())
|
||||
.OfType<string>()
|
||||
.ToArray();
|
||||
|
||||
toolNames.ShouldContain("ersatztv_list_channels");
|
||||
toolNames.ShouldContain("ersatztv_get_version");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Call_Tool_And_Return_Text_Content()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""",
|
||||
new StubToolExecutor("""{"apiVersion":3,"appVersion":"develop"}"""));
|
||||
|
||||
JsonElement content = response.RootElement.GetProperty("result").GetProperty("content").EnumerateArray().Single();
|
||||
content.GetProperty("type").GetString().ShouldBe("text");
|
||||
content.GetProperty("text").GetString().ShouldBe("""{"apiVersion":3,"appVersion":"develop"}""");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Method_Not_Found_For_Unknown_Tool()
|
||||
{
|
||||
using JsonDocument response = await HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"missing","arguments":{}}}""");
|
||||
|
||||
response.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32602);
|
||||
string message = response.RootElement.GetProperty("error").GetProperty("message").GetString().ShouldNotBeNull();
|
||||
message.ShouldContain("Unknown tool");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Parse_Error_For_Malformed_Json()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
// A malformed line must be answered with a JSON-RPC parse error, never crash the loop.
|
||||
string? response = await server.HandleAsync("{ this is not json", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32700);
|
||||
document.RootElement.GetProperty("id").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Invalid_Request_For_Missing_Method()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("""{"jsonrpc":"2.0","id":7}""", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600);
|
||||
document.RootElement.GetProperty("id").GetInt32().ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Invalid_Request_For_Non_Object_Request()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("5", CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Ignore_Notifications()
|
||||
{
|
||||
McpServer server = new(new StubToolExecutor("{}"));
|
||||
|
||||
string? response = await server.HandleAsync("""{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", CancellationToken.None);
|
||||
|
||||
response.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task HandleAsync_Should_Return_Internal_Error_When_Executor_Throws_Transport_Error()
|
||||
{
|
||||
// A network/transport failure must still yield a JSON-RPC error for the id, not escape
|
||||
// HandleAsync (which would leave a compliant client hanging).
|
||||
McpServer server = new(new ThrowingToolExecutor(new HttpRequestException("connection refused")));
|
||||
|
||||
string? response = await server.HandleAsync(
|
||||
"""{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""",
|
||||
CancellationToken.None);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
|
||||
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32603);
|
||||
document.RootElement.GetProperty("id").GetInt32().ShouldBe(9);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> HandleAsync(string request, IToolExecutor? executor = null)
|
||||
{
|
||||
McpServer server = new(executor ?? new StubToolExecutor("{}"));
|
||||
string? response = await server.HandleAsync(request, CancellationToken.None);
|
||||
response.ShouldNotBeNull();
|
||||
return JsonDocument.Parse(response);
|
||||
}
|
||||
|
||||
private sealed class StubToolExecutor(string response) : IToolExecutor
|
||||
{
|
||||
public Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new ToolCallResult(false, response));
|
||||
}
|
||||
|
||||
private sealed class ThrowingToolExecutor(Exception exception) : IToolExecutor
|
||||
{
|
||||
public Task<ToolCallResult> CallToolAsync(
|
||||
ToolDefinition tool,
|
||||
JsonElement arguments,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ToolArgumentValidatorTests
|
||||
{
|
||||
private static ToolDefinition IdTool() => new(
|
||||
"ersatztv_get_channel",
|
||||
"Get channel",
|
||||
HttpMethod.Get,
|
||||
"/api/v1/channels/{id}",
|
||||
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true)));
|
||||
|
||||
private static ToolDefinition ArrayTool() => new(
|
||||
"ersatztv_add_collection_items",
|
||||
"Add items",
|
||||
HttpMethod.Post,
|
||||
"/api/v1/collections/{id}/items",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("id", "integer", "Collection id", Required: true),
|
||||
new SchemaProperty("artistIds", "array", "Artist ids", Required: false, ItemType: "integer")));
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Accept_Well_Formed_Arguments()
|
||||
{
|
||||
Should.NotThrow(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Accept_Empty_Arguments_For_No_Param_Tool()
|
||||
{
|
||||
ToolDefinition tool = new("ersatztv_get_version", "Version", HttpMethod.Get, "/api/v1/version", ToolInputSchemas.Empty);
|
||||
|
||||
Should.NotThrow(() =>
|
||||
ToolArgumentValidator.Validate(tool, JsonDocument.Parse("{}").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Unknown_Argument()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12,"extra":1}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Missing_Required_Argument()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("{}").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Wrong_Type()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":"twelve"}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Non_Object_Arguments()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("[]").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Accept_Array_Argument()
|
||||
{
|
||||
Should.NotThrow(() =>
|
||||
ToolArgumentValidator.Validate(ArrayTool(), JsonDocument.Parse("""{"id":1,"artistIds":[3,4,5]}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Reject_Non_Array_For_Array_Argument()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
ToolArgumentValidator.Validate(ArrayTool(), JsonDocument.Parse("""{"id":1,"artistIds":7}""").RootElement));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_Should_Accept_Explicit_Null_For_Optional_Property()
|
||||
{
|
||||
ToolDefinition tool = new(
|
||||
"ersatztv_update_playout",
|
||||
"Update playout",
|
||||
HttpMethod.Put,
|
||||
"/api/v1/playouts/{id}",
|
||||
ToolInputSchemas.Object(
|
||||
new SchemaProperty("id", "integer", "Playout id", Required: true),
|
||||
new SchemaProperty("dailyRebuildTime", "string", "null clears", Required: false)));
|
||||
|
||||
// An explicit JSON null clears a nullable API field; the validator must not reject it on type.
|
||||
Should.NotThrow(() =>
|
||||
ToolArgumentValidator.Validate(tool, JsonDocument.Parse("""{"id":1,"dailyRebuildTime":null}""").RootElement));
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Mcp;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Mcp.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ToolCatalogTests
|
||||
{
|
||||
[Test]
|
||||
public void All_Should_Expose_Read_Tools_For_The_Six_Families_And_Discovery()
|
||||
{
|
||||
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
|
||||
|
||||
names.ShouldContain("ersatztv_list_channels");
|
||||
names.ShouldContain("ersatztv_get_channel");
|
||||
names.ShouldContain("ersatztv_list_collections");
|
||||
names.ShouldContain("ersatztv_get_collection");
|
||||
names.ShouldContain("ersatztv_get_collection_items");
|
||||
names.ShouldContain("ersatztv_list_smart_collections");
|
||||
names.ShouldContain("ersatztv_list_schedules");
|
||||
names.ShouldContain("ersatztv_get_schedule_items");
|
||||
names.ShouldContain("ersatztv_list_playouts");
|
||||
names.ShouldContain("ersatztv_get_playout");
|
||||
names.ShouldContain("ersatztv_list_ffmpeg_profiles");
|
||||
names.ShouldContain("ersatztv_get_version");
|
||||
names.ShouldContain("ersatztv_list_media_sources");
|
||||
// Discovery reads for populating collections (#487).
|
||||
names.ShouldContain("ersatztv_search_all_items");
|
||||
names.ShouldContain("ersatztv_search_artists");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_Should_Expose_Cautious_Write_Tools()
|
||||
{
|
||||
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
|
||||
|
||||
names.ShouldContain("ersatztv_create_collection");
|
||||
names.ShouldContain("ersatztv_update_collection");
|
||||
names.ShouldContain("ersatztv_delete_collection");
|
||||
names.ShouldContain("ersatztv_add_collection_items");
|
||||
names.ShouldContain("ersatztv_remove_collection_item");
|
||||
names.ShouldContain("ersatztv_update_collection_custom_order");
|
||||
names.ShouldContain("ersatztv_create_smart_collection");
|
||||
names.ShouldContain("ersatztv_create_schedule");
|
||||
names.ShouldContain("ersatztv_create_playout");
|
||||
names.ShouldContain("ersatztv_delete_playout");
|
||||
names.ShouldContain("ersatztv_create_channel");
|
||||
names.ShouldContain("ersatztv_update_channel");
|
||||
names.ShouldContain("ersatztv_delete_channel");
|
||||
names.ShouldContain("ersatztv_enable_jellyfin_library_sync");
|
||||
names.ShouldContain("ersatztv_scan_library");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_And_Update_Channel_Should_Share_The_Body_Fields_And_Require_Essentials()
|
||||
{
|
||||
ToolDefinition create = ToolCatalog.Find("ersatztv_create_channel").ShouldNotBeNull();
|
||||
ToolDefinition update = ToolCatalog.Find("ersatztv_update_channel").ShouldNotBeNull();
|
||||
|
||||
create.HttpMethod.ShouldBe(HttpMethod.Post);
|
||||
update.HttpMethod.ShouldBe(HttpMethod.Put);
|
||||
update.PathTemplate.ShouldBe("/api/v1/channels/{id}");
|
||||
|
||||
JsonElement createProps = create.InputSchema.RootElement.GetProperty("properties");
|
||||
createProps.TryGetProperty("name", out _).ShouldBeTrue();
|
||||
createProps.TryGetProperty("ffmpegProfileId", out _).ShouldBeTrue();
|
||||
createProps.TryGetProperty("streamingMode", out _).ShouldBeTrue();
|
||||
|
||||
var createRequired = create.InputSchema.RootElement.GetProperty("required")
|
||||
.EnumerateArray().Select(e => e.GetString()).ToHashSet();
|
||||
createRequired.ShouldContain("name");
|
||||
createRequired.ShouldContain("number");
|
||||
createRequired.ShouldContain("ffmpegProfileId");
|
||||
// Enums must NOT be forced required (they have server-side defaults).
|
||||
createRequired.ShouldNotContain("streamingMode");
|
||||
|
||||
// Update carries the same body fields plus the route id.
|
||||
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
|
||||
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("showInEpg", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_Should_Not_Expose_Deferred_Redesign_Workflow_Tools()
|
||||
{
|
||||
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
|
||||
|
||||
names.ShouldNotContain("ersatztv_create_channel_from_lineup");
|
||||
names.ShouldNotContain("ersatztv_list_channel_templates");
|
||||
names.ShouldNotContain("ersatztv_browse_library");
|
||||
names.ShouldNotContain("ersatztv_upload_channel_logo");
|
||||
names.ShouldNotContain("ersatztv_resume_playback");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_Should_Not_Expose_Deferred_Large_Dto_Writes()
|
||||
{
|
||||
// Deferred as too-large for a cautious v0.1 (documented in docs/mcp.md): the ~40-field
|
||||
// replace-list writes.
|
||||
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
|
||||
|
||||
names.ShouldNotContain("ersatztv_replace_schedule_items");
|
||||
names.ShouldNotContain("ersatztv_replace_playout_templates");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Every_Tool_Path_Should_Be_Versioned()
|
||||
{
|
||||
foreach (ToolDefinition tool in ToolCatalog.All)
|
||||
{
|
||||
tool.PathTemplate.ShouldStartWith("/api/v1/", customMessage: tool.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Tool_Names_Should_Be_Unique()
|
||||
{
|
||||
ToolCatalog.All
|
||||
.GroupBy(t => t.Name, StringComparer.Ordinal)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Every_Path_Parameter_Should_Be_A_Required_Declared_Property()
|
||||
{
|
||||
foreach (ToolDefinition tool in ToolCatalog.All)
|
||||
{
|
||||
JsonElement schema = tool.InputSchema.RootElement;
|
||||
bool hasProps = schema.TryGetProperty("properties", out JsonElement properties);
|
||||
var required = schema.TryGetProperty("required", out JsonElement req)
|
||||
? req.EnumerateArray().Select(e => e.GetString()).ToHashSet()
|
||||
: new HashSet<string?>();
|
||||
|
||||
foreach (Match match in Regex.Matches(tool.PathTemplate, @"\{([^}]+)\}"))
|
||||
{
|
||||
string name = match.Groups[1].Value;
|
||||
hasProps.ShouldBeTrue($"{tool.Name}: path param {name} needs a properties block");
|
||||
properties.TryGetProperty(name, out _).ShouldBeTrue($"{tool.Name}: path param {name} not declared");
|
||||
required.ShouldContain(name, $"{tool.Name}: path param {name} must be required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Every_Query_Parameter_Should_Be_A_Declared_Property()
|
||||
{
|
||||
foreach (ToolDefinition tool in ToolCatalog.All.Where(t => t.QueryParameters is { Count: > 0 }))
|
||||
{
|
||||
JsonElement properties = tool.InputSchema.RootElement.GetProperty("properties");
|
||||
foreach (string name in tool.QueryParameters!)
|
||||
{
|
||||
properties.TryGetProperty(name, out _).ShouldBeTrue($"{tool.Name}: query param {name} not declared");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Every_Non_Empty_Schema_Should_Forbid_Additional_Properties()
|
||||
{
|
||||
foreach (ToolDefinition tool in ToolCatalog.All)
|
||||
{
|
||||
JsonElement schema = tool.InputSchema.RootElement;
|
||||
schema.GetProperty("additionalProperties").ValueKind.ShouldBe(JsonValueKind.False, tool.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_Channel_Tool_Should_Have_OpenApi_Aligned_Path_And_Id_Input()
|
||||
{
|
||||
ToolDefinition tool = ToolCatalog.Find("ersatztv_get_channel").ShouldNotBeNull();
|
||||
|
||||
tool.HttpMethod.ShouldBe(HttpMethod.Get);
|
||||
tool.PathTemplate.ShouldBe("/api/v1/channels/{id}");
|
||||
tool.InputSchema.RootElement.GetProperty("required").EnumerateArray().Single().GetString().ShouldBe("id");
|
||||
tool.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Add_Collection_Items_Tool_Should_Post_With_Array_Buckets()
|
||||
{
|
||||
ToolDefinition tool = ToolCatalog.Find("ersatztv_add_collection_items").ShouldNotBeNull();
|
||||
|
||||
tool.HttpMethod.ShouldBe(HttpMethod.Post);
|
||||
tool.PathTemplate.ShouldBe("/api/v1/collections/{id}/items");
|
||||
JsonElement artistIds = tool.InputSchema.RootElement.GetProperty("properties").GetProperty("artistIds");
|
||||
artistIds.GetProperty("type").GetString().ShouldBe("array");
|
||||
artistIds.GetProperty("items").GetProperty("type").GetString().ShouldBe("integer");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_Order_Tool_Should_Declare_IfMatch_Header_Argument()
|
||||
{
|
||||
ToolDefinition tool = ToolCatalog.Find("ersatztv_update_collection_custom_order").ShouldNotBeNull();
|
||||
|
||||
tool.HttpMethod.ShouldBe(HttpMethod.Put);
|
||||
tool.InputSchema.RootElement.GetProperty("properties").TryGetProperty("ifMatch", out _).ShouldBeTrue();
|
||||
// ifMatch is a header, not a query parameter.
|
||||
(tool.QueryParameters ?? new HashSet<string>()).ShouldNotContain("ifMatch");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scan_Library_Tool_Should_Register_Deep_As_A_Query_Parameter()
|
||||
{
|
||||
ToolDefinition tool = ToolCatalog.Find("ersatztv_scan_library").ShouldNotBeNull();
|
||||
|
||||
tool.HttpMethod.ShouldBe(HttpMethod.Post);
|
||||
tool.QueryParameters.ShouldNotBeNull();
|
||||
tool.QueryParameters!.ShouldContain("deep");
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
namespace ErsatzTV.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Reads newline-delimited lines from a <see cref="TextReader"/> with a hard character cap, so a
|
||||
/// hostile client cannot exhaust memory by sending an enormous line with no newline. A line longer
|
||||
/// than the cap is drained (not buffered) and reported as overflowed rather than returned.
|
||||
/// </summary>
|
||||
public static class BoundedLineReader
|
||||
{
|
||||
public readonly record struct Line(bool EndOfStream, bool Overflowed, string Text);
|
||||
|
||||
public const int DefaultMaxChars = 1024 * 1024;
|
||||
|
||||
public static async Task<Line> ReadLineAsync(TextReader reader, int maxChars = DefaultMaxChars)
|
||||
{
|
||||
int cap = maxChars > 0 ? maxChars : DefaultMaxChars;
|
||||
var builder = new System.Text.StringBuilder();
|
||||
var buffer = new char[1];
|
||||
bool sawAny = false;
|
||||
bool overflowed = false;
|
||||
|
||||
while (await reader.ReadAsync(buffer, 0, 1) == 1)
|
||||
{
|
||||
sawAny = true;
|
||||
char c = buffer[0];
|
||||
if (c == '\n')
|
||||
{
|
||||
return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString());
|
||||
}
|
||||
|
||||
if (c == '\r')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (overflowed || builder.Length >= cap)
|
||||
{
|
||||
// Past the cap: stop buffering and free what we have, but keep draining to the
|
||||
// newline so the next line stays aligned.
|
||||
overflowed = true;
|
||||
builder.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(c);
|
||||
}
|
||||
|
||||
if (!sawAny)
|
||||
{
|
||||
return new Line(true, false, string.Empty);
|
||||
}
|
||||
|
||||
// Final line with no trailing newline.
|
||||
return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString());
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user