Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0ab39ecb |
+116
-106
@@ -36,14 +36,6 @@ name: Build ErsatzTV Image
|
||||
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
|
||||
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
|
||||
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
|
||||
#
|
||||
# ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and
|
||||
# `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs
|
||||
# `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is
|
||||
# byte-identical to a PR head that already has a green Gitea combined status — i.e. the exact
|
||||
# source was already validated in the PR run. Every heavy step in those three jobs additionally
|
||||
# gates on `steps.revalidate.outputs.skip != 'true'`. `build` is untouched and always runs on
|
||||
# main, so the image is still built (from already-validated source) even when the skip fires.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -111,9 +103,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and,
|
||||
# here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit.
|
||||
fetch-depth: 2
|
||||
# only the test job's steps below need the working tree; git history/tags
|
||||
# are only needed by the `build` job's `git describe` (ersatztv#190)
|
||||
fetch-depth: 1
|
||||
|
||||
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
|
||||
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
|
||||
@@ -121,14 +113,9 @@ jobs:
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -136,13 +123,13 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
|
||||
# the SPA's package downloads are project deps, so they stay cached per lockfile.
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
@@ -150,55 +137,45 @@ jobs:
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != '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'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
@@ -209,7 +186,7 @@ jobs:
|
||||
# floor later"), so this step is purely informational — continue-on-error keeps a missing
|
||||
# report or a transient tool-install failure from ever blocking a build.
|
||||
- name: Coverage summary
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -233,26 +210,71 @@ 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.
|
||||
if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }}
|
||||
# `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' }}
|
||||
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)
|
||||
@@ -317,24 +339,17 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate
|
||||
# step's `HEAD^2` tree comparison can resolve on a main merge commit.
|
||||
fetch-depth: 2
|
||||
# default fetch-depth: 1 -- this job never runs git describe/log, only
|
||||
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
|
||||
|
||||
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
|
||||
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -342,11 +357,11 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
|
||||
@@ -354,7 +369,7 @@ jobs:
|
||||
|
||||
# SQLite is the prod provider; both checks validated locally.
|
||||
- name: SQLite — model drift + apply all migrations to a fresh DB
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
@@ -370,7 +385,7 @@ jobs:
|
||||
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
|
||||
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
|
||||
- name: MySql — model drift + apply all migrations to a fresh DB
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
env:
|
||||
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
|
||||
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
|
||||
@@ -425,22 +440,15 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree
|
||||
# comparison can resolve on a main merge commit.
|
||||
fetch-depth: 2
|
||||
fetch-depth: 1
|
||||
|
||||
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -448,11 +456,11 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
@@ -460,17 +468,17 @@ jobs:
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
|
||||
|
||||
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
|
||||
@@ -481,7 +489,7 @@ jobs:
|
||||
# the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent.
|
||||
|
||||
- name: Boot instance and run functional-E2E harness
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
@@ -850,26 +858,16 @@ 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:07048b8
|
||||
@@ -899,14 +897,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."
|
||||
|
||||
+6
-8
@@ -11,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,8 +35,8 @@ 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** `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`.
|
||||
|
||||
|
||||
@@ -52,6 +52,16 @@ public class UpdateChannelHandler(
|
||||
UpdateChannel update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// don't save mirror when playout exists
|
||||
if (c.Playouts.Count > 0)
|
||||
{
|
||||
update = update with
|
||||
{
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
MirrorSourceChannelId = null
|
||||
};
|
||||
}
|
||||
|
||||
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
|
||||
|
||||
c.Name = update.Name;
|
||||
@@ -130,8 +140,6 @@ public class UpdateChannelHandler(
|
||||
c.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
|
||||
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -139,6 +147,8 @@ public class UpdateChannelHandler(
|
||||
c.PlayoutOffset = null;
|
||||
}
|
||||
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
@@ -184,7 +194,7 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
@@ -259,7 +269,6 @@ public class UpdateChannelHandler(
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
|
||||
@@ -267,18 +276,6 @@ public class UpdateChannelHandler(
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// a channel with its own playout already built (Generated mode) cannot become a Mirror —
|
||||
// Mirror channels relay another channel's playout and never build one of their own, so
|
||||
// switching this transition on would strand the existing playout. This used to be
|
||||
// silently coerced back to Generated (issue #401); reject the transition instead so the
|
||||
// caller sees why the requested Mirror source was not applied. A round-trip that keeps
|
||||
// PlayoutSource as Generated never reaches this check.
|
||||
if (channel.Playouts.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
"Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first.");
|
||||
}
|
||||
|
||||
Option<Channel> maybeMirrorSource = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
@@ -105,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>>;
|
||||
|
||||
@@ -102,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) =>
|
||||
|
||||
@@ -74,12 +74,7 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
CancellationToken cancellationToken) =>
|
||||
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
|
||||
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
|
||||
.BindT(playlist => ValidateName(request).Map(_ => playlist));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
|
||||
request.NotEmpty(x => x.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
|
||||
|
||||
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
|
||||
ReplacePlaylistItems request,
|
||||
|
||||
@@ -10,16 +10,6 @@ public class GetAllMediaSourcesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
|
||||
{
|
||||
// A never-scanned library has a null LastScan at runtime, but historical DB rows still carry the
|
||||
// 0001-01-01 MinValue sentinel written by the old Reset_* migrations. Coerce any such residual
|
||||
// sentinel to null so the API/MCP surface reports "never scanned" as null (parity with the UI),
|
||||
// regardless of DB history or provider. Belt-and-suspenders alongside the NullOutNeverScannedLastScan
|
||||
// data migration.
|
||||
private static readonly DateTime NeverScannedThreshold = new(2000, 1, 1);
|
||||
|
||||
private static DateTime? NormalizeLastScan(DateTime? lastScan) =>
|
||||
lastScan is { } value && value < NeverScannedThreshold ? null : lastScan;
|
||||
|
||||
public async Task<List<MediaSourceResponseModel>> Handle(
|
||||
GetAllMediaSourcesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -46,7 +36,7 @@ public class GetAllMediaSourcesForApiHandler(
|
||||
l.Id,
|
||||
l.Name,
|
||||
l.MediaKind,
|
||||
NormalizeLastScan(l.LastScan),
|
||||
l.LastScan,
|
||||
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -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>()?
|
||||
@@ -119,22 +118,22 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
|
||||
{ VaapiDriver.iHD, VaapiDriver.i965, VaapiDriver.RadeonSI, VaapiDriver.Nouveau };
|
||||
|
||||
foreach (string display in vaapiDisplays)
|
||||
foreach (VaapiDriver activeDriver in allDrivers)
|
||||
foreach (string vaapiDevice in vaapiDevices)
|
||||
{
|
||||
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
|
||||
display,
|
||||
Optional(GetDriverName(activeDriver)),
|
||||
vaapiDevice))
|
||||
{
|
||||
vaapiCapabilities.AppendLine(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}");
|
||||
vaapiCapabilities.AppendLine();
|
||||
vaapiCapabilities.AppendLine(output);
|
||||
vaapiCapabilities.AppendLine();
|
||||
}
|
||||
}
|
||||
foreach (VaapiDriver activeDriver in allDrivers)
|
||||
foreach (string vaapiDevice in vaapiDevices)
|
||||
{
|
||||
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
|
||||
display,
|
||||
Optional(GetDriverName(activeDriver)),
|
||||
vaapiDevice))
|
||||
{
|
||||
vaapiCapabilities.AppendLine(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}");
|
||||
vaapiCapabilities.AppendLine();
|
||||
vaapiCapabilities.AppendLine(output);
|
||||
vaapiCapabilities.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_runtimeInfo.IsOSPlatform(OSPlatform.OSX))
|
||||
|
||||
@@ -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,93 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaybackOrderSupportTests
|
||||
{
|
||||
private static readonly PlaybackOrder[] AllOrders = Enum.GetValues<PlaybackOrder>();
|
||||
|
||||
// The tripwire (#403): every engine must classify every PlaybackOrder value as either supported or
|
||||
// explicitly unsupported. Adding a new order without classifying it here fails this test, which forces the
|
||||
// author to wire it into (or deliberately reject it from) each dispatch site instead of letting it degrade
|
||||
// silently.
|
||||
[Test]
|
||||
public void EveryOrder_IsClassified_ForEveryEngine()
|
||||
{
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
foreach (PlaybackOrder order in AllOrders)
|
||||
{
|
||||
PlaybackOrderSupport.IsClassified(engine, order).ShouldBeTrue(
|
||||
$"PlaybackOrder.{order} is not classified for {engine}. Add it to PlaybackOrderSupport " +
|
||||
"(supported or unsupported) AND wire it into that engine's dispatch switch (#403).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every SchedulingEngineKind must have a matrix entry, or Matrix[engine] throws KeyNotFoundException at
|
||||
// runtime instead of failing here. This is the engine-axis counterpart to the order tripwire.
|
||||
[Test]
|
||||
public void EveryEngineKind_HasAMatrixEntry()
|
||||
{
|
||||
var classified = PlaybackOrderSupport.Engines.ToHashSet();
|
||||
|
||||
foreach (SchedulingEngineKind engine in Enum.GetValues<SchedulingEngineKind>())
|
||||
{
|
||||
classified.ShouldContain(engine,
|
||||
$"SchedulingEngineKind.{engine} has no PlaybackOrderSupport matrix entry (#403).");
|
||||
}
|
||||
}
|
||||
|
||||
// The two sets must partition the enum: no order both supported and unsupported, and together they cover
|
||||
// exactly the enum (no stale entry for a removed value, no missing value).
|
||||
[Test]
|
||||
public void SupportedAndUnsupported_ArePartition_ForEveryEngine()
|
||||
{
|
||||
var all = AllOrders.ToHashSet();
|
||||
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
IReadOnlySet<PlaybackOrder> supported = PlaybackOrderSupport.SupportedBy(engine);
|
||||
IReadOnlySet<PlaybackOrder> unsupported = PlaybackOrderSupport.UnsupportedBy(engine);
|
||||
|
||||
supported.Intersect(unsupported).ShouldBeEmpty(
|
||||
$"{engine}: an order is listed as both supported and unsupported");
|
||||
|
||||
var union = supported.Concat(unsupported).ToHashSet();
|
||||
union.ShouldBe(all, ignoreOrder: true,
|
||||
$"{engine}: supported ∪ unsupported does not equal the PlaybackOrder enum");
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the specific fragility called out in #403: Random is in Block's allow-list, so it must be
|
||||
// supported by Block (it previously worked only via the switch's coincidental Random fallback).
|
||||
[Test]
|
||||
public void Block_Supports_Random()
|
||||
{
|
||||
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, PlaybackOrder.Random).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// WeightedShuffle (#70) is Classic-only; the other engines must classify it as unsupported so the
|
||||
// write-path guards and this matrix agree.
|
||||
[Test]
|
||||
public void WeightedShuffle_IsClassicOnly()
|
||||
{
|
||||
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, PlaybackOrder.WeightedShuffle)
|
||||
.ShouldBeTrue();
|
||||
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
if (engine == SchedulingEngineKind.Classic)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PlaybackOrderSupport.IsSupported(engine, PlaybackOrder.WeightedShuffle).ShouldBeFalse(
|
||||
$"{engine} must not support WeightedShuffle (#70 is Classic-only)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
@@ -434,67 +433,6 @@ public class PlaylistEnumeratorTests
|
||||
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
|
||||
}
|
||||
|
||||
// #403: an order the playlist engine doesn't handle must be dropped LOUDLY (a warning), not silently.
|
||||
[Test]
|
||||
public async Task Test_UnsupportedOrder_Drops_Item_And_Logs_Warning()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
var logger = new RecordingLogger();
|
||||
|
||||
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
|
||||
{
|
||||
{
|
||||
new PlaylistItem
|
||||
{
|
||||
Id = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
PlayAll = false,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 1
|
||||
},
|
||||
[FakeMovie(10), FakeMovie(11)]
|
||||
},
|
||||
{
|
||||
// WeightedShuffle (#70) is Classic-only; the playlist switch has no arm for it.
|
||||
new PlaylistItem
|
||||
{
|
||||
Id = 2,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
PlayAll = false,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 2
|
||||
},
|
||||
[FakeMovie(20), FakeMovie(21)]
|
||||
}
|
||||
};
|
||||
|
||||
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
|
||||
repo,
|
||||
playlistItemMap,
|
||||
new CollectionEnumeratorState(),
|
||||
shufflePlaylistItems: false,
|
||||
batchSize: Option<int>.None,
|
||||
CancellationToken.None,
|
||||
logger);
|
||||
|
||||
// the unsupported item (20, 21) is dropped; only the chronological item (10, 11) cycles
|
||||
var items = new List<int>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
items.AddRange(enumerator.Current.Map(mi => mi.Id));
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
items.ShouldContain(10);
|
||||
items.ShouldContain(11);
|
||||
items.ShouldNotContain(20);
|
||||
items.ShouldNotContain(21);
|
||||
|
||||
// and it said so, rather than dropping silently
|
||||
logger.Entries.ShouldContain(
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("not supported by playlist"));
|
||||
}
|
||||
|
||||
private static Movie FakeMovie(int id) => new()
|
||||
{
|
||||
Id = id,
|
||||
@@ -507,27 +445,4 @@ public class PlaylistEnumeratorTests
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception exception,
|
||||
Func<TState, Exception, string> formatter) =>
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -174,7 +174,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
foreach (Subtitle subtitle in maybeSubtitle)
|
||||
{
|
||||
if (subtitle.SubtitleKind == SubtitleKind.Sidecar || subtitle is
|
||||
{ SubtitleKind: SubtitleKind.Embedded, IsImage: false, IsExtracted: true })
|
||||
{ SubtitleKind: SubtitleKind.Embedded, IsImage: false, IsExtracted: true })
|
||||
{
|
||||
// proxy to avoid dealing with escaping
|
||||
subtitle.Path = $"http://localhost:{Settings.StreamingPort}/media/subtitle/{subtitle.Id}";
|
||||
@@ -287,20 +287,20 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
outputFormat = OutputFormatKind.Hls;
|
||||
break;
|
||||
case StreamingMode.HttpLiveStreamingDirect:
|
||||
{
|
||||
// use mpeg-ts by default
|
||||
outputFormat = OutputFormatKind.MpegTs;
|
||||
|
||||
// override with setting if applicable
|
||||
Option<OutputFormatKind> maybeOutputFormat = await _configElementRepository
|
||||
.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
|
||||
foreach (OutputFormatKind of in maybeOutputFormat)
|
||||
{
|
||||
// use mpeg-ts by default
|
||||
outputFormat = OutputFormatKind.MpegTs;
|
||||
|
||||
// override with setting if applicable
|
||||
Option<OutputFormatKind> maybeOutputFormat = await _configElementRepository
|
||||
.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
|
||||
foreach (OutputFormatKind of in maybeOutputFormat)
|
||||
{
|
||||
outputFormat = of;
|
||||
}
|
||||
|
||||
break;
|
||||
outputFormat = of;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Option<string> subtitleLanguage = Option<string>.None;
|
||||
@@ -445,7 +445,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string> hlsInitTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
|
||||
_ => Option<string>.None
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsSegmentOptions = Option<string>.None;
|
||||
@@ -587,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);
|
||||
|
||||
@@ -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,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,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);
|
||||
}
|
||||
@@ -51,6 +51,15 @@ public class BlockPlayoutBuilder(
|
||||
referenceData.Channel.Number,
|
||||
referenceData.Channel.Name);
|
||||
|
||||
List<PlaybackOrder> allowedPlaybackOrders =
|
||||
[
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.Shuffle,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.RandomRotation
|
||||
];
|
||||
|
||||
int daysToBuild = await GetDaysToBuild(cancellationToken);
|
||||
|
||||
// get blocks to schedule
|
||||
@@ -154,14 +163,8 @@ public class BlockPlayoutBuilder(
|
||||
foreach (BlockItem blockItem in effectiveBlock.Block.Items.OrderBy(i => i.Index))
|
||||
{
|
||||
// TODO: support other playback orders
|
||||
if (!PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, blockItem.PlaybackOrder))
|
||||
if (!allowedPlaybackOrders.Contains(blockItem.PlaybackOrder))
|
||||
{
|
||||
// Skipping the item silently means it never airs and nothing says why (#403).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by block scheduling; " +
|
||||
"block item {BlockItemId} will be skipped",
|
||||
blockItem.PlaybackOrder,
|
||||
blockItem.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -391,31 +394,14 @@ public class BlockPlayoutBuilder(
|
||||
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
|
||||
blockItem,
|
||||
historyKey),
|
||||
// Random is in Block's allow-list; give it an explicit arm rather than relying on the fallback
|
||||
// below (it previously worked only by coincidence -- #403).
|
||||
PlaybackOrder.Random => new RandomizedMediaCollectionEnumerator(
|
||||
_ => new RandomizedMediaCollectionEnumerator(
|
||||
collectionItems,
|
||||
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 }),
|
||||
_ => UnsupportedBlockOrderFallback(blockItem.PlaybackOrder, collectionItems)
|
||||
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 })
|
||||
};
|
||||
|
||||
return enumerator;
|
||||
}
|
||||
|
||||
// Defensive: the allow-list in Build already skips unsupported orders, so this should be unreachable. If a
|
||||
// supported-but-unhandled order ever lands here, be loud instead of silently rotating Random (#403).
|
||||
private IMediaCollectionEnumerator UnsupportedBlockOrderFallback(
|
||||
PlaybackOrder playbackOrder,
|
||||
List<MediaItem> collectionItems)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} reached the block enumerator without a handler; falling back to Random",
|
||||
playbackOrder);
|
||||
return new RandomizedMediaCollectionEnumerator(
|
||||
collectionItems,
|
||||
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 });
|
||||
}
|
||||
|
||||
private static PlayoutBuildResult CleanUpHistory(
|
||||
PlayoutReferenceData referenceData,
|
||||
DateTimeOffset start,
|
||||
|
||||
@@ -257,8 +257,7 @@ public class SchedulingEngine(
|
||||
state,
|
||||
shufflePlaylistItems: false,
|
||||
batchSize: Option<int>.None,
|
||||
CancellationToken.None,
|
||||
Optional((ILogger)logger));
|
||||
CancellationToken.None);
|
||||
|
||||
string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None);
|
||||
var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None);
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using OrderSet = System.Collections.Generic.HashSet<ErsatzTV.Core.Domain.PlaybackOrder>;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// The scheduling engine families that turn a <see cref="PlaybackOrder" /> into an enumerator, and which
|
||||
/// orders each one actually handles. This is the single declared support matrix for #403.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each engine keeps BOTH a <c>Supported</c> and an <c>Unsupported</c> set, maintained by hand ON PURPOSE:
|
||||
/// <c>PlaybackOrderSupportTests</c> asserts the two sets partition every <see cref="PlaybackOrder" /> value
|
||||
/// (union is total, intersection empty), so adding a new order fails that test until it is consciously
|
||||
/// classified here. Deriving <c>Unsupported</c> as "everything not supported" would let a new order fall
|
||||
/// through silently — the very defect #403 exists to kill — so it is spelled out instead.
|
||||
///
|
||||
/// Membership here mirrors the executable dispatch in each builder (<c>PlayoutBuilder</c>,
|
||||
/// <c>PlaylistEnumerator</c>, <c>BlockPlayoutBuilder</c>, <c>EnumeratorCache</c> [YAML],
|
||||
/// <c>SchedulingEngine.EnumeratorForContent</c> [Scripted]); the builders remain the real logic. Only
|
||||
/// <see cref="SchedulingEngineKind.Block" /> consumes this table at runtime (its allow-list), so the table
|
||||
/// is not merely test scaffolding. When you add a case to one of those switches, update the matching set
|
||||
/// here.
|
||||
/// </remarks>
|
||||
public static class PlaybackOrderSupport
|
||||
{
|
||||
private sealed record EngineSupport(
|
||||
IReadOnlySet<PlaybackOrder> Supported,
|
||||
IReadOnlySet<PlaybackOrder> Unsupported);
|
||||
|
||||
private static readonly IReadOnlyDictionary<SchedulingEngineKind, EngineSupport> Matrix =
|
||||
new Dictionary<SchedulingEngineKind, EngineSupport>
|
||||
{
|
||||
// PlayoutBuilder.GetMediaCollectionEnumerator switch; default arm falls back to Random (now loud).
|
||||
[SchedulingEngineKind.Classic] = new EngineSupport(
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.Shuffle,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
},
|
||||
new OrderSet { PlaybackOrder.None, PlaybackOrder.RandomRotation }),
|
||||
|
||||
// PlaylistEnumerator.Create switch; no default arm meant a null enumerator -> silent drop (now loud).
|
||||
[SchedulingEngineKind.Playlist] = new EngineSupport(
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.Shuffle,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle
|
||||
},
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.None,
|
||||
PlaybackOrder.RandomRotation,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
}),
|
||||
|
||||
// BlockPlayoutBuilder allow-list (this very set) + the GetEnumerator switch.
|
||||
[SchedulingEngineKind.Block] = new EngineSupport(
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.Shuffle,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.RandomRotation
|
||||
},
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.None,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
}),
|
||||
|
||||
// EnumeratorCache (YAML, non-playlist path); unsupported orders LogWarning + None.
|
||||
[SchedulingEngineKind.Yaml] = new EngineSupport(
|
||||
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.None,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle,
|
||||
PlaybackOrder.RandomRotation,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
}),
|
||||
|
||||
// SchedulingEngine.EnumeratorForContent (Scripted); unsupported orders LogWarning + None.
|
||||
[SchedulingEngineKind.Scripted] = new EngineSupport(
|
||||
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.None,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle,
|
||||
PlaybackOrder.RandomRotation,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
})
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<SchedulingEngineKind> Engines => Matrix.Keys.ToList();
|
||||
|
||||
/// <summary>The orders <paramref name="engine" /> can turn into an enumerator.</summary>
|
||||
public static IReadOnlySet<PlaybackOrder> SupportedBy(SchedulingEngineKind engine) => Matrix[engine].Supported;
|
||||
|
||||
/// <summary>The orders explicitly known NOT to be handled by <paramref name="engine" />.</summary>
|
||||
public static IReadOnlySet<PlaybackOrder> UnsupportedBy(SchedulingEngineKind engine) => Matrix[engine].Unsupported;
|
||||
|
||||
public static bool IsSupported(SchedulingEngineKind engine, PlaybackOrder order) =>
|
||||
Matrix[engine].Supported.Contains(order);
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="order" /> appears in either set for <paramref name="engine" />. A new enum
|
||||
/// value is classified in neither until a human adds it — which is what the tripwire test asserts.
|
||||
/// </summary>
|
||||
public static bool IsClassified(SchedulingEngineKind engine, PlaybackOrder order) =>
|
||||
Matrix[engine].Supported.Contains(order) || Matrix[engine].Unsupported.Contains(order);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
@@ -122,8 +121,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
|
||||
CollectionEnumeratorState state,
|
||||
bool shufflePlaylistItems,
|
||||
Option<int> batchSize,
|
||||
CancellationToken cancellationToken,
|
||||
Option<ILogger> logger = default)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new PlaylistEnumerator
|
||||
{
|
||||
@@ -205,19 +203,6 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
|
||||
break;
|
||||
case PlaybackOrder.Random:
|
||||
enumerator = new RandomizedMediaCollectionEnumerator(items, initState);
|
||||
break;
|
||||
default:
|
||||
// An order the playlist engine doesn't handle (#403). Leaving the enumerator null drops
|
||||
// this item from the playlist silently -- and null is a legitimate state above
|
||||
// (SeasonEpisode with Count == 0), so nothing downstream can flag it. Say so here.
|
||||
foreach (ILogger log in logger)
|
||||
{
|
||||
log.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by playlist scheduling; " +
|
||||
"this item will be dropped from the playlist",
|
||||
playlistItem.PlaybackOrder);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,8 +1324,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
state,
|
||||
marathonShuffleGroups,
|
||||
batchSize: Option<int>.None,
|
||||
cancellationToken,
|
||||
Optional((ILogger)_logger));
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1459,23 +1458,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
goto default;
|
||||
|
||||
default:
|
||||
// Say so instead of silently substituting a different, arbitrary-looking order; keep airing on
|
||||
// the Random fallback so the channel does not go dark on one misconfigured item (#403). A
|
||||
// supported order can also land here via `goto default` (Marathon that couldn't build its
|
||||
// enumerator) -- distinguish the two so the log doesn't claim a supported order is unsupported.
|
||||
if (PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, playbackOrder))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Could not build a {PlaybackOrder} enumerator for classic scheduling; falling back to Random",
|
||||
playbackOrder);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by classic scheduling; falling back to Random",
|
||||
playbackOrder);
|
||||
}
|
||||
|
||||
// TODO: handle this error case differently?
|
||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// The scheduling engine families that dispatch a <see cref="ErsatzTV.Core.Domain.PlaybackOrder" /> into an
|
||||
/// enumerator. Used to key the <see cref="PlaybackOrderSupport" /> matrix (#403).
|
||||
/// </summary>
|
||||
public enum SchedulingEngineKind
|
||||
{
|
||||
/// <summary>Classic schedules — <c>PlayoutBuilder</c>.</summary>
|
||||
Classic,
|
||||
|
||||
/// <summary>Playlist items — <c>PlaylistEnumerator</c>.</summary>
|
||||
Playlist,
|
||||
|
||||
/// <summary>Block scheduling — <c>BlockPlayoutBuilder</c>.</summary>
|
||||
Block,
|
||||
|
||||
/// <summary>YAML (sequential) scheduling — <c>EnumeratorCache</c>.</summary>
|
||||
Yaml,
|
||||
|
||||
/// <summary>Scripted scheduling — <c>SchedulingEngine.EnumeratorForContent</c>.</summary>
|
||||
Scripted
|
||||
}
|
||||
@@ -175,8 +175,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
state,
|
||||
shufflePlaylistItems: false,
|
||||
batchSize: Option<int>.None,
|
||||
cancellationToken,
|
||||
Optional(logger));
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
|
||||
|
||||
-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,236 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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()
|
||||
{
|
||||
(VideoInputFile videoInputFile, AudioInputFile audioInputFile, FFmpegState ffmpegState, FrameState desiredState) =
|
||||
BuildQsvH264Pipeline(preferNativeDecoder: true, scanKind: ScanKind.Interlaced, deinterlace: true);
|
||||
|
||||
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);
|
||||
|
||||
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
private string BuildAndPrint(bool preferNativeDecoder)
|
||||
{
|
||||
(VideoInputFile videoInputFile, AudioInputFile audioInputFile, FFmpegState ffmpegState, FrameState desiredState) =
|
||||
BuildQsvH264Pipeline(preferNativeDecoder, ScanKind.Progressive, false);
|
||||
|
||||
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,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,8 +27,7 @@ public record FFmpegState(
|
||||
bool IsSongWithProgress,
|
||||
bool IsHdrTonemap,
|
||||
string TonemapAlgorithm,
|
||||
bool IsTroubleshooting,
|
||||
bool QsvPreferNativeDecoder = false)
|
||||
bool IsTroubleshooting)
|
||||
{
|
||||
public int QsvExtraHardwareFrames => MaybeQsvExtraHardwareFrames.IfNone(64);
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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(
|
||||
@@ -103,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
|
||||
@@ -140,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)
|
||||
};
|
||||
@@ -213,7 +202,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
|
||||
// need to download for any sort of overlay (and always for setpts)
|
||||
if (currentState.FrameDataLocation == FrameDataLocation.Hardware) //&&
|
||||
//(context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine))
|
||||
//(context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine))
|
||||
{
|
||||
var hardwareDownload = new HardwareDownloadFilter(currentState);
|
||||
currentState = hardwareDownload.NextState(currentState);
|
||||
@@ -640,7 +629,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
||||
DecoderHardwareAccelerationMode: HardwareAccelerationMode.None,
|
||||
EncoderHardwareAccelerationMode: HardwareAccelerationMode.None
|
||||
} && context is
|
||||
{ HasGraphicsEngine: false, HasWatermark: false, HasSubtitleOverlay: false, ShouldDeinterlace: false };
|
||||
{ HasGraphicsEngine: false, HasWatermark: false, HasSubtitleOverlay: false, ShouldDeinterlace: false };
|
||||
|
||||
// auto_scale filter seems to muck up 10-bit software decode => hardware scale, so use software scale in that case
|
||||
useSoftwareFilter = useSoftwareFilter ||
|
||||
|
||||
Generated
-7260
File diff suppressed because it is too large
Load Diff
-23
@@ -1,23 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NullOutNeverScannedLastScan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
|
||||
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// irreversible data migration; there is no way to recover the original sentinel values
|
||||
}
|
||||
}
|
||||
}
|
||||
-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");
|
||||
|
||||
|
||||
Generated
-7085
File diff suppressed because it is too large
Load Diff
-23
@@ -1,23 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NullOutNeverScannedLastScan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
|
||||
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// irreversible data migration; there is no way to recover the original sentinel values
|
||||
}
|
||||
}
|
||||
}
|
||||
-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");
|
||||
|
||||
|
||||
@@ -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,247 +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>>());
|
||||
|
||||
private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private 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 });
|
||||
}
|
||||
}
|
||||
|
||||
private 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>
|
||||
private 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();
|
||||
}
|
||||
|
||||
private sealed class ThrowingHttpMessageHandler(Exception exception) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromException<HttpResponseMessage>(exception);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -198,7 +198,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
|
||||
await UpdateEpisode(dbContext, existing, item, cancellationToken);
|
||||
|
||||
result = new MediaItemScanResult<JellyfinEpisode>(existing)
|
||||
{ IsAdded = false, IsUpdated = true };
|
||||
{ IsAdded = false, IsUpdated = true };
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -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,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
-341
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
@@ -96,345 +95,5 @@ public class SynchronizeJellyfinLibraryByIdHandlerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
await libraryRepository.Received(1).UpdateLastScan(library);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Log_Error_When_Scan_Is_Canceled()
|
||||
{
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
|
||||
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
|
||||
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
|
||||
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
var logger = Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>();
|
||||
|
||||
var library = new JellyfinLibrary
|
||||
{
|
||||
Id = 42,
|
||||
Name = "Concerts",
|
||||
MediaKind = LibraryMediaKind.MusicVideos,
|
||||
MediaSourceId = 7
|
||||
};
|
||||
var mediaSource = new JellyfinMediaSource
|
||||
{
|
||||
Id = 7,
|
||||
Connections =
|
||||
[
|
||||
new JellyfinConnection
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
JellyfinMediaSourceId = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
|
||||
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
|
||||
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
ApiKey = "abc"
|
||||
});
|
||||
configElementRepository.GetValue<int>(
|
||||
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Option<int>>(Some(0)));
|
||||
jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(),
|
||||
library,
|
||||
true,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ScanCanceled()).AsTask());
|
||||
|
||||
var handler = new SynchronizeJellyfinLibraryByIdHandler(
|
||||
scannerProxy,
|
||||
mediaSourceRepository,
|
||||
jellyfinSecretStore,
|
||||
jellyfinMovieLibraryScanner,
|
||||
jellyfinTelevisionLibraryScanner,
|
||||
jellyfinMusicVideoLibraryScanner,
|
||||
libraryRepository,
|
||||
configElementRepository,
|
||||
logger);
|
||||
|
||||
await handler.Handle(
|
||||
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
|
||||
CancellationToken.None);
|
||||
|
||||
// a user-initiated cancellation is not a failure and must not be logged at ERROR (#410)
|
||||
await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
|
||||
// prefix match, matching the mixed-library test below: equality would still be exact for
|
||||
// this single-kind path, but a prefix cannot be quietly defeated by a reworded error
|
||||
logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
|
||||
?.StartsWith("Error synchronizing jellyfin library:", StringComparison.Ordinal) == true)
|
||||
.ShouldBeFalse();
|
||||
logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
|
||||
== "Scan of jellyfin library Concerts was canceled")
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Scan_All_Three_Kinds_For_Mixed_Libraries()
|
||||
{
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
|
||||
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
|
||||
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
|
||||
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
var library = new JellyfinLibrary
|
||||
{
|
||||
Id = 42,
|
||||
Name = "Music Videos",
|
||||
MediaKind = LibraryMediaKind.Mixed,
|
||||
MediaSourceId = 7
|
||||
};
|
||||
var mediaSource = new JellyfinMediaSource
|
||||
{
|
||||
Id = 7,
|
||||
Connections =
|
||||
[
|
||||
new JellyfinConnection
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
JellyfinMediaSourceId = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
|
||||
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
|
||||
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
ApiKey = "abc"
|
||||
});
|
||||
configElementRepository.GetValue<int>(
|
||||
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Option<int>>(Some(0)));
|
||||
|
||||
jellyfinMovieLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
|
||||
jellyfinTelevisionLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
|
||||
jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
|
||||
|
||||
var handler = new SynchronizeJellyfinLibraryByIdHandler(
|
||||
scannerProxy,
|
||||
mediaSourceRepository,
|
||||
jellyfinSecretStore,
|
||||
jellyfinMovieLibraryScanner,
|
||||
jellyfinTelevisionLibraryScanner,
|
||||
jellyfinMusicVideoLibraryScanner,
|
||||
libraryRepository,
|
||||
configElementRepository,
|
||||
Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());
|
||||
|
||||
Either<BaseError, string> result = await handler.Handle(
|
||||
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.LeftToSeq().ShouldBeEmpty();
|
||||
result.IsRight.ShouldBeTrue();
|
||||
result.RightToSeq().Single().ShouldBe("Music Videos");
|
||||
|
||||
await jellyfinMovieLibraryScanner.Received(1).ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
|
||||
await jellyfinTelevisionLibraryScanner.Received(1).ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
|
||||
await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
|
||||
await libraryRepository.Received(1).UpdateLastScan(library);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Run_Remaining_Scanners_When_One_Fails_For_Mixed_Libraries()
|
||||
{
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
|
||||
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
|
||||
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
|
||||
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
var library = new JellyfinLibrary
|
||||
{
|
||||
Id = 42,
|
||||
Name = "Music Videos",
|
||||
MediaKind = LibraryMediaKind.Mixed,
|
||||
MediaSourceId = 7
|
||||
};
|
||||
var mediaSource = new JellyfinMediaSource
|
||||
{
|
||||
Id = 7,
|
||||
Connections =
|
||||
[
|
||||
new JellyfinConnection
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
JellyfinMediaSourceId = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
|
||||
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
|
||||
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
ApiKey = "abc"
|
||||
});
|
||||
configElementRepository.GetValue<int>(
|
||||
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Option<int>>(Some(0)));
|
||||
|
||||
jellyfinMovieLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("movie scan blew up")).AsTask());
|
||||
jellyfinTelevisionLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
|
||||
jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
|
||||
|
||||
var handler = new SynchronizeJellyfinLibraryByIdHandler(
|
||||
scannerProxy,
|
||||
mediaSourceRepository,
|
||||
jellyfinSecretStore,
|
||||
jellyfinMovieLibraryScanner,
|
||||
jellyfinTelevisionLibraryScanner,
|
||||
jellyfinMusicVideoLibraryScanner,
|
||||
libraryRepository,
|
||||
configElementRepository,
|
||||
Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());
|
||||
|
||||
Either<BaseError, string> result = await handler.Handle(
|
||||
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
|
||||
CancellationToken.None);
|
||||
|
||||
// the movie arm failed, so the library as a whole failed...
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
|
||||
// ...but the other two kinds were still ingested
|
||||
await jellyfinTelevisionLibraryScanner.Received(1).ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
|
||||
await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
|
||||
|
||||
// and LastScan is not stamped, because the scan was not fully successful
|
||||
await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Stop_Scanning_Mixed_Library_When_Canceled()
|
||||
{
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
|
||||
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
|
||||
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
|
||||
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
var library = new JellyfinLibrary
|
||||
{
|
||||
Id = 42,
|
||||
Name = "Music Videos",
|
||||
MediaKind = LibraryMediaKind.Mixed,
|
||||
MediaSourceId = 7
|
||||
};
|
||||
var mediaSource = new JellyfinMediaSource
|
||||
{
|
||||
Id = 7,
|
||||
Connections =
|
||||
[
|
||||
new JellyfinConnection
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
JellyfinMediaSourceId = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
|
||||
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
|
||||
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
ApiKey = "abc"
|
||||
});
|
||||
configElementRepository.GetValue<int>(
|
||||
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Option<int>>(Some(0)));
|
||||
|
||||
var logger = Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>();
|
||||
|
||||
jellyfinMovieLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ScanCanceled()).AsTask());
|
||||
|
||||
var handler = new SynchronizeJellyfinLibraryByIdHandler(
|
||||
scannerProxy,
|
||||
mediaSourceRepository,
|
||||
jellyfinSecretStore,
|
||||
jellyfinMovieLibraryScanner,
|
||||
jellyfinTelevisionLibraryScanner,
|
||||
jellyfinMusicVideoLibraryScanner,
|
||||
libraryRepository,
|
||||
configElementRepository,
|
||||
logger);
|
||||
|
||||
Either<BaseError, string> result = await handler.Handle(
|
||||
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
|
||||
// a user-initiated cancellation aborts the whole library; later kinds must not run
|
||||
await jellyfinTelevisionLibraryScanner.DidNotReceive().ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(),
|
||||
Arg.Any<JellyfinLibrary>(),
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
await jellyfinMusicVideoLibraryScanner.DidNotReceive().ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(),
|
||||
Arg.Any<JellyfinLibrary>(),
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
|
||||
|
||||
// ScanMixedLibrary must return the ScanCanceled INSTANCE unwrapped, not folded into an
|
||||
// aggregate BaseError -- otherwise `error is ScanCanceled` in the caller fails and a user
|
||||
// cancellation is demoted to an ERROR log (#410).
|
||||
logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
|
||||
== "Scan of jellyfin library Music Videos was canceled")
|
||||
.ShouldBeTrue();
|
||||
// prefix match, not equality: if ScanCanceled were folded into the aggregate error the
|
||||
// rendered message becomes "...: Mixed library X had 1 scan error(s): Scan was canceled",
|
||||
// which an equality assertion would NOT catch -- making the check vacuous
|
||||
logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
|
||||
?.StartsWith("Error synchronizing jellyfin library:", StringComparison.Ordinal) == true)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Scanner.Application.MediaSources;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
@@ -97,27 +96,6 @@ public class ScanLocalLibraryHandlerTests
|
||||
ShouldHaveLogged("Error scanning local library path /movies: scan failed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Log_Error_When_A_Path_Scan_Is_Canceled()
|
||||
{
|
||||
ScanResult(Left<BaseError, Unit>(new ScanCanceled()));
|
||||
|
||||
await Handler().Handle(
|
||||
new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
|
||||
CancellationToken.None);
|
||||
|
||||
// a user-initiated cancellation is not a failure and must not be logged at ERROR (#410);
|
||||
// it's still correctly excluded from the "last scan" stamp, same as any other failure
|
||||
_library.LastScan.ShouldBeNull();
|
||||
await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
|
||||
|
||||
_logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
|
||||
== "Error scanning local library path /movies: Scan was canceled")
|
||||
.ShouldBeFalse();
|
||||
ShouldHaveLogged("Scan of local library path /movies was canceled");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Set_Library_LastScan_When_A_Later_Path_Fails()
|
||||
{
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Scanner.Tests.Core.Metadata;
|
||||
|
||||
// #477: a successful-but-empty media-server fetch must not flag a non-empty movie library FileNotFound
|
||||
// (which EmptyTrash could then permanently delete). Assert the sweep is skipped when nothing came in.
|
||||
public class MediaServerMovieLibraryScannerTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class CleanupFileNotFoundItems
|
||||
{
|
||||
[Test]
|
||||
public async Task Empty_Incoming_With_Existing_Movies_Does_Not_Flag()
|
||||
{
|
||||
var movieRepository = Substitute.For<IJellyfinMovieRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
|
||||
var library = new JellyfinLibrary { Id = 3, Name = "Movies" };
|
||||
|
||||
movieRepository.GetExistingMovies(library)
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "movie-1", State = MediaItemState.Normal },
|
||||
new() { ItemId = "movie-2", State = MediaItemState.Normal }
|
||||
});
|
||||
|
||||
var scanner = new TestMovieLibraryScanner(scannerProxy);
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.Scan(
|
||||
movieRepository,
|
||||
new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
|
||||
library);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await movieRepository.DidNotReceive().FlagFileNotFound(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>());
|
||||
await scannerProxy.DidNotReceive().ReindexMediaItems(
|
||||
Arg.Any<int[]>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal concrete subclass exposing the cleanup path. Driven with an empty incoming list, so none of
|
||||
// the per-item metadata members below are ever invoked — they exist only to satisfy the contract.
|
||||
private sealed class TestMovieLibraryScanner : MediaServerMovieLibraryScanner<
|
||||
JellyfinConnectionParameters, JellyfinLibrary, JellyfinMovie, JellyfinItemEtag>
|
||||
{
|
||||
public TestMovieLibraryScanner(IScannerProxy scannerProxy)
|
||||
: base(
|
||||
scannerProxy,
|
||||
Substitute.For<IFileSystem>(),
|
||||
Substitute.For<ILocalChaptersProvider>(),
|
||||
Substitute.For<IMetadataRepository>(),
|
||||
Substitute.For<ILogger>())
|
||||
{
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Scan(
|
||||
IJellyfinMovieRepository movieRepository,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
ScanLibrary(
|
||||
movieRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
_ => string.Empty,
|
||||
false,
|
||||
CancellationToken.None);
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library) => EmptyMovies();
|
||||
|
||||
protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId;
|
||||
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
|
||||
|
||||
protected override Task<Option<MovieMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinMovie> result, JellyfinMovie incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<Tuple<MovieMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinMovie> result, JellyfinMovie incoming) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinMovie> result, MovieMetadata fullMetadata,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<JellyfinMovie, int>> EmptyMovies()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Plex;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Scanner.Tests.Core.Metadata;
|
||||
|
||||
// #477: a successful-but-empty media-server fetch must not flag a non-empty other-video library
|
||||
// FileNotFound (which EmptyTrash could then permanently delete). Assert the sweep is skipped.
|
||||
public class MediaServerOtherVideoLibraryScannerTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class CleanupFileNotFoundItems
|
||||
{
|
||||
[Test]
|
||||
public async Task Empty_Incoming_With_Existing_OtherVideos_Does_Not_Flag()
|
||||
{
|
||||
var otherVideoRepository = Substitute.For<IPlexOtherVideoRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
|
||||
var library = new PlexLibrary { Id = 9, Name = "Other Videos" };
|
||||
|
||||
otherVideoRepository.GetExistingOtherVideos(library)
|
||||
.Returns(new List<PlexItemEtag>
|
||||
{
|
||||
new() { Key = "ov-1", State = MediaItemState.Normal },
|
||||
new() { Key = "ov-2", State = MediaItemState.Normal }
|
||||
});
|
||||
|
||||
var scanner = new TestOtherVideoLibraryScanner(scannerProxy);
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.Scan(otherVideoRepository, library);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await otherVideoRepository.DidNotReceive().FlagFileNotFound(
|
||||
Arg.Any<PlexLibrary>(), Arg.Any<List<string>>());
|
||||
await scannerProxy.DidNotReceive().ReindexMediaItems(
|
||||
Arg.Any<int[]>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal concrete subclass exposing the cleanup path. Driven with an empty incoming list, so none of
|
||||
// the per-item metadata members below are ever invoked — they exist only to satisfy the contract. The
|
||||
// connection parameters are likewise never dereferenced on the empty path (passed null! below).
|
||||
private sealed class TestOtherVideoLibraryScanner : MediaServerOtherVideoLibraryScanner<
|
||||
PlexConnectionParameters, PlexLibrary, PlexOtherVideo, PlexItemEtag>
|
||||
{
|
||||
public TestOtherVideoLibraryScanner(IScannerProxy scannerProxy)
|
||||
: base(
|
||||
scannerProxy,
|
||||
Substitute.For<IFileSystem>(),
|
||||
Substitute.For<ILocalChaptersProvider>(),
|
||||
Substitute.For<IMetadataRepository>(),
|
||||
Substitute.For<ILogger>())
|
||||
{
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Scan(
|
||||
IPlexOtherVideoRepository otherVideoRepository,
|
||||
PlexLibrary library) =>
|
||||
ScanLibrary(
|
||||
otherVideoRepository,
|
||||
null!,
|
||||
library,
|
||||
_ => string.Empty,
|
||||
false,
|
||||
CancellationToken.None);
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<PlexOtherVideo, int>> GetOtherVideoLibraryItems(
|
||||
PlexConnectionParameters connectionParameters, PlexLibrary library) => EmptyOtherVideos();
|
||||
|
||||
protected override string MediaServerItemId(PlexOtherVideo otherVideo) => otherVideo.Key;
|
||||
protected override string MediaServerEtag(PlexOtherVideo otherVideo) => otherVideo.Etag;
|
||||
|
||||
protected override Task<Option<OtherVideoMetadata>> GetFullMetadata(
|
||||
PlexConnectionParameters connectionParameters, PlexLibrary library,
|
||||
MediaItemScanResult<PlexOtherVideo> result, PlexOtherVideo incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<Tuple<OtherVideoMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
|
||||
PlexConnectionParameters connectionParameters, PlexLibrary library,
|
||||
MediaItemScanResult<PlexOtherVideo> result, PlexOtherVideo incoming) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<PlexOtherVideo>>> UpdateMetadata(
|
||||
MediaItemScanResult<PlexOtherVideo> result, OtherVideoMetadata fullMetadata,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<PlexOtherVideo, int>> EmptyOtherVideos()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using ErsatzTV.Scanner.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Scanner.Tests.Core.Metadata;
|
||||
|
||||
// #477: the deterministic policy behind the media-server anti-nuke guard. An empty incoming set with
|
||||
// existing items present is the only case that skips the sweep (and logs); every other combination
|
||||
// reconciles normally.
|
||||
public class MediaServerReconciliationGuardTests
|
||||
{
|
||||
[Test]
|
||||
public void Empty_Incoming_With_Existing_Items_Skips_And_Warns()
|
||||
{
|
||||
var logger = Substitute.For<ILogger>();
|
||||
|
||||
bool shouldFlag = MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 5);
|
||||
|
||||
shouldFlag.ShouldBeFalse();
|
||||
logger.Received(1).Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Partial_Deletion_Still_Flags()
|
||||
{
|
||||
var logger = Substitute.For<ILogger>();
|
||||
|
||||
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 5).ShouldBeTrue();
|
||||
|
||||
logger.DidNotReceive().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Empty_Incoming_With_No_Existing_Items_Is_A_Noop_Sweep()
|
||||
{
|
||||
var logger = Substitute.For<ILogger>();
|
||||
|
||||
// nothing exists, so an empty incoming set flags nothing either way — allow the (empty) sweep
|
||||
// rather than special-casing it, and do not emit the scary warning.
|
||||
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 0, 0).ShouldBeTrue();
|
||||
|
||||
logger.DidNotReceive().Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Any<object>(),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception?, string>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Full_Fetch_Into_Empty_Library_Still_Flags()
|
||||
{
|
||||
var logger = Substitute.For<ILogger>();
|
||||
|
||||
MediaServerReconciliationGuard.ShouldFlagMissing(logger, "Movies", 3, 0).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Scanner.Tests.Core.Metadata;
|
||||
|
||||
// #476: a show/season gone from the media server is absent from the incoming list, so the per-parent
|
||||
// loop never visits it and the descendant sweeps never run for it. These tests assert the scanner
|
||||
// cascades the FileNotFound flag to descendants via the repository, using a substituted repository.
|
||||
// #477: an empty incoming list is treated as a suspect (mid-restore / emptied) fetch and the sweep is
|
||||
// skipped instead of nuking the whole library — so the #476 cascade is now exercised with a survivor
|
||||
// present (a genuine partial deletion), and the empty case asserts nothing is flagged.
|
||||
public class MediaServerTelevisionLibraryScannerTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class CleanupFileNotFoundItems
|
||||
{
|
||||
[Test]
|
||||
public async Task Removed_Show_Cascades_FileNotFound_To_Seasons_And_Episodes()
|
||||
{
|
||||
var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
|
||||
var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };
|
||||
|
||||
// two shows exist; the survivor is still in the (non-empty) incoming list, so this is a
|
||||
// genuine partial deletion — "show-6366" is gone upstream and must be flagged + cascaded.
|
||||
// (#477: an EMPTY incoming would instead skip the sweep — see the guard test below.)
|
||||
televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "show-keep", State = MediaItemState.Normal },
|
||||
new() { ItemId = "show-6366", State = MediaItemState.FileNotFound }
|
||||
});
|
||||
|
||||
// the survivor short-circuits to Left so the per-item metadata path (unsupported in this
|
||||
// harness) is never entered; it is still recorded as incoming, so it is not swept.
|
||||
televisionRepository.GetOrAdd(library, Arg.Any<JellyfinShow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MediaItemScanResult<JellyfinShow>>(BaseError.New("skip metadata in test")));
|
||||
|
||||
// the per-item loop reports progress; an unstubbed substitute returns false => ScanCanceled
|
||||
scannerProxy.UpdateProgress(Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
televisionRepository
|
||||
.FlagFileNotFoundShows(library, Arg.Is<List<string>>(l => l.Contains("show-6366")),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 100 });
|
||||
televisionRepository
|
||||
.FlagFileNotFoundSeasonsForShows(Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 100 })),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 200, 201 });
|
||||
televisionRepository
|
||||
.FlagFileNotFoundEpisodesForSeasons(Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 200, 201 })),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new List<int> { 300, 301, 302 });
|
||||
scannerProxy.ReindexMediaItems(Arg.Any<int[]>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
var scanner = new TestTelevisionLibraryScanner(scannerProxy);
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.Scan(
|
||||
televisionRepository,
|
||||
new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
|
||||
library,
|
||||
Shows(new JellyfinShow
|
||||
{
|
||||
ItemId = "show-keep",
|
||||
ShowMetadata = new List<ShowMetadata> { new() { Title = "Keeper" } }
|
||||
}));
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await televisionRepository.Received(1).FlagFileNotFoundShows(
|
||||
library,
|
||||
Arg.Is<List<string>>(l => l.Count == 1 && l.Contains("show-6366")),
|
||||
Arg.Any<CancellationToken>());
|
||||
await televisionRepository.Received(1).FlagFileNotFoundSeasonsForShows(
|
||||
Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 100 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
await televisionRepository.Received(1).FlagFileNotFoundEpisodesForSeasons(
|
||||
Arg.Is<List<int>>(l => l.SequenceEqual(new[] { 200, 201 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
// every affected item (show + seasons + episodes) is reindexed so search reflects the new state
|
||||
await scannerProxy.Received(1).ReindexMediaItems(
|
||||
Arg.Is<int[]>(a => new[] { 100, 200, 201, 300, 301, 302 }.All(id => a.Contains(id))),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Incoming_With_Existing_Shows_Does_Not_Flag()
|
||||
{
|
||||
var televisionRepository = Substitute.For<IJellyfinTelevisionRepository>();
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
|
||||
var library = new JellyfinLibrary { Id = 42, Name = "TV Shows" };
|
||||
|
||||
// shows exist locally, but a successful fetch returned ZERO items (server mid-restore or the
|
||||
// library was emptied upstream). #477: flagging here would nuke the entire library, so the
|
||||
// sweep must be skipped and nothing flagged or reindexed.
|
||||
televisionRepository.GetExistingShows(library, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinItemEtag>
|
||||
{
|
||||
new() { ItemId = "show-1", State = MediaItemState.Normal },
|
||||
new() { ItemId = "show-2", State = MediaItemState.Normal }
|
||||
});
|
||||
|
||||
var scanner = new TestTelevisionLibraryScanner(scannerProxy);
|
||||
|
||||
Either<BaseError, Unit> result = await scanner.Scan(
|
||||
televisionRepository,
|
||||
new JellyfinConnectionParameters("http://jellyfin.example", "abc", 7),
|
||||
library,
|
||||
EmptyShows());
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await televisionRepository.DidNotReceive().FlagFileNotFoundShows(
|
||||
Arg.Any<JellyfinLibrary>(), Arg.Any<List<string>>(), Arg.Any<CancellationToken>());
|
||||
await televisionRepository.DidNotReceive().FlagFileNotFoundSeasonsForShows(
|
||||
Arg.Any<List<int>>(), Arg.Any<CancellationToken>());
|
||||
await televisionRepository.DidNotReceive().FlagFileNotFoundEpisodesForSeasons(
|
||||
Arg.Any<List<int>>(), Arg.Any<CancellationToken>());
|
||||
await scannerProxy.DidNotReceive().ReindexMediaItems(
|
||||
Arg.Any<int[]>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<JellyfinShow, int>> EmptyShows()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<JellyfinShow, int>> Shows(params JellyfinShow[] shows)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
foreach (JellyfinShow show in shows)
|
||||
{
|
||||
yield return new Tuple<JellyfinShow, int>(show, shows.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal concrete subclass that exposes the abstract scanner's cleanup path. The incoming show list
|
||||
// is supplied directly (empty = "all shows removed"), so none of the per-item metadata members below
|
||||
// are ever invoked — they exist only to satisfy the abstract contract.
|
||||
private sealed class TestTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
JellyfinConnectionParameters, JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
||||
JellyfinItemEtag>
|
||||
{
|
||||
public TestTelevisionLibraryScanner(IScannerProxy scannerProxy)
|
||||
: base(
|
||||
scannerProxy,
|
||||
Substitute.For<IFileSystem>(),
|
||||
Substitute.For<ILocalChaptersProvider>(),
|
||||
Substitute.For<IMetadataRepository>(),
|
||||
Substitute.For<ILogger>())
|
||||
{
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Scan(
|
||||
IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow, JellyfinSeason, JellyfinEpisode,
|
||||
JellyfinItemEtag> televisionRepository,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
IAsyncEnumerable<Tuple<JellyfinShow, int>> showEntries) =>
|
||||
ScanLibrary(
|
||||
televisionRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
_ => string.Empty,
|
||||
showEntries,
|
||||
false,
|
||||
CancellationToken.None);
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
|
||||
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
|
||||
protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId;
|
||||
protected override string MediaServerEtag(JellyfinShow show) => show.Etag;
|
||||
protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
|
||||
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
JellyfinLibrary library, JellyfinConnectionParameters connectionParameters, JellyfinShow show,
|
||||
JellyfinSeason season, bool isNewSeason) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinShow> result, JellyfinShow incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinSeason> result, JellyfinSeason incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinEpisode> result, JellyfinEpisode incoming, bool deepScan) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Option<Tuple<EpisodeMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
|
||||
JellyfinConnectionParameters connectionParameters, JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinEpisode> result, JellyfinEpisode incoming) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinShow>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinShow> result, ShowMetadata fullMetadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinSeason>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinSeason> result, SeasonMetadata fullMetadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinEpisode> result, EpisodeMetadata fullMetadata,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
@@ -87,14 +86,7 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
_logger.LogInformation("Scan of emby library {Name} was canceled", parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Error synchronizing emby library: {Error}", error);
|
||||
}
|
||||
_logger.LogError("Error synchronizing emby library: {Error}", error);
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
|
||||
+3
-83
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
@@ -85,10 +84,7 @@ public class
|
||||
parameters.Library,
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.Mixed =>
|
||||
await ScanMixedLibrary(parameters, cancellationToken),
|
||||
_ => BaseError.New(
|
||||
$"Jellyfin library {parameters.Library.Name} has unsupported media kind {parameters.Library.MediaKind}")
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
if (result.IsRight)
|
||||
@@ -99,16 +95,7 @@ public class
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Scan of jellyfin library {Name} was canceled",
|
||||
parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Error synchronizing jellyfin library: {Error}", error);
|
||||
}
|
||||
_logger.LogError("Error synchronizing jellyfin library: {Error}", error);
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
@@ -119,73 +106,6 @@ public class
|
||||
return parameters.Library.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a mixed-content library by running each per-kind scanner against it in turn. Jellyfin
|
||||
/// resolves the mix server-side - each scanner queries with its own includeItemTypes - so the
|
||||
/// passes see disjoint item sets, and their reconciliation is type-scoped and cannot
|
||||
/// cross-delete.
|
||||
/// </summary>
|
||||
private async Task<Either<BaseError, Unit>> ScanMixedLibrary(
|
||||
RequestParameters parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Scanning mixed-content jellyfin library {Name}",
|
||||
parameters.Library.Name);
|
||||
|
||||
var scans = new Func<Task<Either<BaseError, Unit>>>[]
|
||||
{
|
||||
() => _jellyfinMovieLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters,
|
||||
parameters.Library,
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
() => _jellyfinTelevisionLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters,
|
||||
parameters.Library,
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
() => _jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters,
|
||||
parameters.Library,
|
||||
parameters.DeepScan,
|
||||
cancellationToken)
|
||||
};
|
||||
|
||||
var errors = new List<BaseError>();
|
||||
|
||||
foreach (Func<Task<Either<BaseError, Unit>>> scan in scans)
|
||||
{
|
||||
Either<BaseError, Unit> result = await scan();
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
// a cancellation aborts the whole library immediately; it is not one kind failing
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
// one kind failing must not stop the others from being ingested
|
||||
_logger.LogWarning(
|
||||
"Error scanning one media kind of mixed jellyfin library {Name}: {Error}",
|
||||
parameters.Library.Name,
|
||||
error.Value);
|
||||
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Mixed library {parameters.Library.Name} had {errors.Count} scan error(s): " +
|
||||
string.Join("; ", errors.Map(e => e.Value)));
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
SynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
|
||||
+1
-2
@@ -48,8 +48,7 @@ public class
|
||||
RequestParameters parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// a mixed library legitimately contains shows alongside movies and music videos
|
||||
if (parameters.Library.MediaKind is not (LibraryMediaKind.Shows or LibraryMediaKind.Mixed))
|
||||
if (parameters.Library.MediaKind != LibraryMediaKind.Shows)
|
||||
{
|
||||
return BaseError.New($"Library {parameters.Library.Name} is not a TV show library");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
|
||||
@@ -139,11 +138,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken),
|
||||
|
||||
// returning success here would stamp LastScan as though the library had been
|
||||
// scanned; a local library has no scanner for Mixed and never should
|
||||
_ => BaseError.New(
|
||||
$"Local library {localLibrary.Name} has unsupported media kind {localLibrary.MediaKind}")
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
if (result.IsRight)
|
||||
@@ -157,23 +152,13 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
|
||||
|
||||
// a failed path now suppresses the library-level scan time below, so without this the
|
||||
// user sees "Never scanned" with nothing explaining why. The remote scanners log the
|
||||
// same way. A cancellation is user-initiated, not a failure, so it's logged separately
|
||||
// at a lower level; genuine errors still log at ERROR.
|
||||
// same way.
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Scan of local library path {Path} was canceled",
|
||||
libraryPath.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Error scanning local library path {Path}: {Error}",
|
||||
libraryPath.Path,
|
||||
error);
|
||||
}
|
||||
_logger.LogError(
|
||||
"Error scanning local library path {Path}: {Error}",
|
||||
libraryPath.Path,
|
||||
error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Plex;
|
||||
@@ -97,14 +96,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
_logger.LogInformation("Scan of plex library {Name} was canceled", parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Error synchronizing plex library: {Error}", error);
|
||||
}
|
||||
_logger.LogError("Error synchronizing plex library: {Error}", error);
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -7,7 +7,6 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using ErsatzTV.Scanner.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Scanner.Core.Jellyfin;
|
||||
@@ -78,7 +77,6 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
var processed = 0;
|
||||
var incomingPaths = new List<string>();
|
||||
await foreach ((MusicVideo incoming, int totalCount) in _jellyfinApiClient
|
||||
.GetMusicVideoLibraryItems(
|
||||
connectionParameters.Address,
|
||||
@@ -98,8 +96,6 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
incomingPaths.Add(GetLocalPath(pathReplacements, incoming));
|
||||
|
||||
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
|
||||
await ProcessMusicVideo(library, libraryPath, pathReplacements, incoming, cancellationToken);
|
||||
|
||||
@@ -118,62 +114,9 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
|
||||
}
|
||||
}
|
||||
|
||||
await TrashMissingMusicVideos(library, libraryPath, incomingPaths, cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// ersatztv#494: remove music videos (and now-empty artists) that Jellyfin no longer reports.
|
||||
//
|
||||
// Identity is LIBRARY-SCOPED by (LibraryPathId, path): FindMusicVideoPaths and DeleteByPath both filter
|
||||
// LibraryPathId AND join the concrete MusicVideo table, so this can never touch a Movie/Show that shares
|
||||
// the same LibraryPath (a mixed library) — the cross-delete safety is a property of those queries, not of
|
||||
// the media kind. Unlike the MediaServer{Movie,Television,OtherVideo} base scanners, music videos carry no
|
||||
// server ItemId/Etag (there is no JellyfinMusicVideo entity), so we diff on the local path instead of the
|
||||
// server item id, and hard-delete rather than soft-trash (there is no per-item FileNotFound seam here).
|
||||
//
|
||||
// Known limitation: MusicVideoRepository.GetOrAdd dedups a path GLOBALLY (no LibraryPathId predicate), so a
|
||||
// file served by two libraries with overlapping local paths is a single row owned by whichever library
|
||||
// scanned it first. If that owning library later stops reporting the file while another library still
|
||||
// serves it, this sweep removes the shared row. A proper fix needs per-library music-video identity (a
|
||||
// JellyfinMusicVideo etag entity + migration) — the issue's deferred "option 2"; tracked as a follow-up.
|
||||
private async Task TrashMissingMusicVideos(
|
||||
JellyfinLibrary library,
|
||||
LibraryPath libraryPath,
|
||||
List<string> incomingPaths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingPaths = (await _musicVideoRepository.FindMusicVideoPaths(libraryPath)).ToList();
|
||||
|
||||
// #477: refuse the sweep when a successful fetch returned zero items but rows exist locally — an empty
|
||||
// incoming set is indistinguishable from a transient error and would otherwise wipe the whole library.
|
||||
if (!MediaServerReconciliationGuard.ShouldFlagMissing(
|
||||
_logger,
|
||||
library.Name,
|
||||
incomingPaths.Count,
|
||||
existingPaths.Count))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string path in existingPaths.Except(incomingPaths))
|
||||
{
|
||||
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
|
||||
if (musicVideoIds.Count > 0 &&
|
||||
!await _scannerProxy.RemoveMediaItems(musicVideoIds.ToArray(), cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to remove media items from scanner process");
|
||||
}
|
||||
}
|
||||
|
||||
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
|
||||
if (artistIds.Count > 0 &&
|
||||
!await _scannerProxy.RemoveMediaItems(artistIds.ToArray(), cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to remove empty artists from scanner process");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> ProcessMusicVideo(
|
||||
JellyfinLibrary library,
|
||||
LibraryPath libraryPath,
|
||||
@@ -263,112 +206,11 @@ public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanne
|
||||
existing.DateUpdated = DateTime.UtcNow;
|
||||
existing.MetadataKind = MetadataKind.External;
|
||||
|
||||
bool updated = await _metadataRepository.Update(existing);
|
||||
|
||||
// ersatztv#497: the scalar Update above marks only the metadata row Modified; it does NOT touch
|
||||
// child collections, and MusicVideoRepository.GetOrAdd loads them AsNoTracking — so tag/genre/studio/
|
||||
// artist edits made in Jellyfin never reached an EXISTING music video (only the Add path persisted
|
||||
// them). Reconcile the collections that BOTH the Add path persists AND GetOrAdd eager-loads:
|
||||
// Genres, Tags, Studios, Artists. (Guids are add-persisted but not eager-loaded here — reconciling
|
||||
// them would see an empty `existing` and duplicate-insert every scan; Directors are eager-loaded
|
||||
// but not add-persisted for music videos — both are deliberately out of scope.) Mirrors the
|
||||
// remove-stale + add-new idiom PlexMovieLibraryScanner.UpdateMetadata uses.
|
||||
updated = await ReconcileGenres(existing, incoming) || updated;
|
||||
updated = await ReconcileTags(existing, incoming) || updated;
|
||||
updated = await ReconcileStudios(existing, incoming) || updated;
|
||||
updated = await ReconcileArtists(existing, incoming) || updated;
|
||||
|
||||
return updated;
|
||||
return await _metadataRepository.Update(existing);
|
||||
}
|
||||
|
||||
incoming.MusicVideoId = musicVideo.Id;
|
||||
musicVideo.MusicVideoMetadata = [incoming];
|
||||
return await _metadataRepository.Add(incoming);
|
||||
}
|
||||
|
||||
private async Task<bool> ReconcileGenres(MusicVideoMetadata existing, MusicVideoMetadata incoming)
|
||||
{
|
||||
existing.Genres ??= [];
|
||||
List<Genre> incomingGenres = incoming.Genres ?? [];
|
||||
var updated = false;
|
||||
|
||||
foreach (Genre genre in existing.Genres.Filter(g => incomingGenres.All(g2 => g2.Name != g.Name)).ToList())
|
||||
{
|
||||
existing.Genres.Remove(genre);
|
||||
updated = await _metadataRepository.RemoveGenre(genre) || updated;
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingGenres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name)).ToList())
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
updated = await _musicVideoRepository.AddGenre(existing, genre) || updated;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async Task<bool> ReconcileTags(MusicVideoMetadata existing, MusicVideoMetadata incoming)
|
||||
{
|
||||
existing.Tags ??= [];
|
||||
List<Tag> incomingTags = incoming.Tags ?? [];
|
||||
var updated = false;
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => incomingTags.All(t2 => t2.Name != t.Name)).ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
updated = await _metadataRepository.RemoveTag(tag) || updated;
|
||||
}
|
||||
|
||||
foreach (Tag tag in incomingTags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name)).ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
updated = await _musicVideoRepository.AddTag(existing, tag) || updated;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async Task<bool> ReconcileStudios(MusicVideoMetadata existing, MusicVideoMetadata incoming)
|
||||
{
|
||||
existing.Studios ??= [];
|
||||
List<Studio> incomingStudios = incoming.Studios ?? [];
|
||||
var updated = false;
|
||||
|
||||
foreach (Studio studio in existing.Studios.Filter(s => incomingStudios.All(s2 => s2.Name != s.Name)).ToList())
|
||||
{
|
||||
existing.Studios.Remove(studio);
|
||||
updated = await _metadataRepository.RemoveStudio(studio) || updated;
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingStudios.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name)).ToList())
|
||||
{
|
||||
existing.Studios.Add(studio);
|
||||
updated = await _musicVideoRepository.AddStudio(existing, studio) || updated;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async Task<bool> ReconcileArtists(MusicVideoMetadata existing, MusicVideoMetadata incoming)
|
||||
{
|
||||
existing.Artists ??= [];
|
||||
List<MusicVideoArtist> incomingArtists = incoming.Artists ?? [];
|
||||
var updated = false;
|
||||
|
||||
foreach (MusicVideoArtist artist in existing.Artists
|
||||
.Filter(a => incomingArtists.All(a2 => a2.Name != a.Name)).ToList())
|
||||
{
|
||||
existing.Artists.Remove(artist);
|
||||
updated = await _musicVideoRepository.RemoveArtist(artist) || updated;
|
||||
}
|
||||
|
||||
foreach (MusicVideoArtist artist in incomingArtists
|
||||
.Filter(a => existing.Artists.All(a2 => a2.Name != a.Name)).ToList())
|
||||
{
|
||||
existing.Artists.Add(artist);
|
||||
updated = await _musicVideoRepository.AddArtist(existing, artist) || updated;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -203,15 +203,11 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
}
|
||||
|
||||
// trash movies that are no longer present on the media server
|
||||
if (MediaServerReconciliationGuard.ShouldFlagMissing(
|
||||
_logger, library.Name, incomingItemIds.Count, existingMovies.Count))
|
||||
var fileNotFoundItemIds = existingMovies.Keys.Except(incomingItemIds).ToList();
|
||||
List<int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
||||
{
|
||||
var fileNotFoundItemIds = existingMovies.Keys.Except(incomingItemIds).ToList();
|
||||
List<int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -210,15 +210,11 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
|
||||
// trash OtherVideo that are no longer present on the media server
|
||||
if (MediaServerReconciliationGuard.ShouldFlagMissing(
|
||||
_logger, library.Name, incomingItemIds.Count, existingOtherVideos.Count))
|
||||
var fileNotFoundItemIds = existingOtherVideos.Keys.Except(incomingItemIds).ToList();
|
||||
List<int> ids = await otherVideoRepository.FlagFileNotFound(library, fileNotFoundItemIds);
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
||||
{
|
||||
var fileNotFoundItemIds = existingOtherVideos.Keys.Except(incomingItemIds).ToList();
|
||||
List<int> ids = await otherVideoRepository.FlagFileNotFound(library, fileNotFoundItemIds);
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Scanner.Core.Metadata;
|
||||
|
||||
// #477: a media-server library sweep computes "gone upstream" as existing.Except(incoming) and flags the
|
||||
// result FileNotFound. If a successful fetch returns ZERO items (the server is up but mid-restore /
|
||||
// mid-rebuild, or the library was emptied upstream) then existing.Except([]) is EVERY existing item, so
|
||||
// the whole library is flagged FileNotFound in one pass. That is data-loss-adjacent: EmptyTrashHandler
|
||||
// deletes state:FileNotFound rows permanently, and PlayoutSkipMissingItems empties every affected
|
||||
// collection. An empty incoming set is indistinguishable at scan time from a transient error (both report
|
||||
// zero), so the safe policy is to refuse the sweep — logged loudly — rather than nuke the library.
|
||||
//
|
||||
// This deliberately overrides the degenerate "last item removed => empty incoming => flag" case that a
|
||||
// partial-deletion sweep would otherwise handle (see #476's cascade, which still fires for the common
|
||||
// case where survivors are present and only some items are gone). The cost of not flagging a genuinely
|
||||
// emptied library (stale rows persist until an item returns or the library is removed) is far smaller
|
||||
// than a one-scan permanent wipe. Ratio-thresholds and projection-failure detection are deferred — see
|
||||
// docs/decisions.md and the #477 follow-up.
|
||||
internal static class MediaServerReconciliationGuard
|
||||
{
|
||||
public static bool ShouldFlagMissing(
|
||||
ILogger logger,
|
||||
string libraryName,
|
||||
int incomingCount,
|
||||
int existingCount)
|
||||
{
|
||||
if (incomingCount == 0 && existingCount > 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Media server library {Library} returned zero items but {ExistingCount} exist locally; "
|
||||
+ "skipping the file-not-found sweep to avoid flagging the entire library as missing "
|
||||
+ "(expected if the server is mid-restore or the library was emptied upstream)",
|
||||
libraryName,
|
||||
existingCount);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.MediaServer;
|
||||
@@ -170,23 +170,12 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
ScanProfiler.LogStatistics(s => _logger.LogInformation("{Profile}", s));
|
||||
}
|
||||
|
||||
if (cleanupFileNotFoundItems &&
|
||||
MediaServerReconciliationGuard.ShouldFlagMissing(
|
||||
_logger, library.Name, incomingItemIds.Count, existingShows.Count))
|
||||
if (cleanupFileNotFoundItems)
|
||||
{
|
||||
// trash shows that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds, cancellationToken);
|
||||
|
||||
// #476: a show gone from the media server is absent from the incoming list, so the per-show
|
||||
// loop never visits it and the season/episode sweeps below never run for it. Cascade the flag
|
||||
// to its descendants so they don't linger (RemoteOnly on Jellyfin/Emby) and keep getting
|
||||
// scheduled as guaranteed tune-in failures.
|
||||
List<int> seasonIds = await televisionRepository.FlagFileNotFoundSeasonsForShows(ids, cancellationToken);
|
||||
List<int> episodeIds = await televisionRepository.FlagFileNotFoundEpisodesForSeasons(seasonIds, cancellationToken);
|
||||
|
||||
var reindexIds = ids.Concat(seasonIds).Concat(episodeIds).ToArray();
|
||||
if (!await _scannerProxy.ReindexMediaItems(reindexIds, cancellationToken))
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
@@ -380,13 +369,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
// trash seasons that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds, cancellationToken);
|
||||
|
||||
// #476: a season gone from the media server (while its show remains) is absent from the incoming
|
||||
// list, so the per-season loop never visits it and the episode sweep in ScanEpisodes never runs
|
||||
// for it. Cascade the flag to its episodes.
|
||||
List<int> episodeIds = await televisionRepository.FlagFileNotFoundEpisodesForSeasons(ids, cancellationToken);
|
||||
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.Concat(episodeIds).ToArray(), cancellationToken))
|
||||
if (!await _scannerProxy.ReindexMediaItems(ids.ToArray(), cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
|
||||
@@ -49,53 +49,5 @@ public class GetAllChannelsForApiHandlerTests
|
||||
channel.StreamingMode.ShouldBe("HLS Segmenter");
|
||||
channel.IsEnabled.ShouldBeFalse();
|
||||
channel.ShowInEpg.ShouldBeFalse();
|
||||
// No logo artwork -> null so the SPA renders its generated initials fallback.
|
||||
channel.Logo.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Root_Uploaded_Logo_Url()
|
||||
{
|
||||
IChannelRepository repository = Substitute.For<IChannelRepository>();
|
||||
repository.GetAll(Arg.Any<CancellationToken>())
|
||||
.Returns([NewChannel(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "abc123.png" })]);
|
||||
var handler = new GetAllChannelsForApiHandler(repository);
|
||||
|
||||
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
|
||||
|
||||
// Uploaded logos are addressed as "iptv/logos/{file}"; the browse DTO roots it (leading slash) so
|
||||
// the SPA's <img src> resolves against the site root regardless of the current SPA route.
|
||||
result.ShouldHaveSingleItem().Logo.ShouldBe("/iptv/logos/abc123.png");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Pass_Through_External_Logo_Url()
|
||||
{
|
||||
IChannelRepository repository = Substitute.For<IChannelRepository>();
|
||||
repository.GetAll(Arg.Any<CancellationToken>())
|
||||
.Returns([NewChannel(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "https://example.com/logo.png" })]);
|
||||
var handler = new GetAllChannelsForApiHandler(repository);
|
||||
|
||||
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
|
||||
|
||||
// An absolute external URL is directly usable and must pass through unchanged (no leading slash added).
|
||||
result.ShouldHaveSingleItem().Logo.ShouldBe("https://example.com/logo.png");
|
||||
}
|
||||
|
||||
private static Channel NewChannel(params Artwork[] artwork) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Id = 7,
|
||||
Number = "7.1",
|
||||
SortNumber = 7.1,
|
||||
Name = "Retro Cartoons",
|
||||
Group = "Kids",
|
||||
Categories = "animation",
|
||||
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
|
||||
PreferredAudioLanguageCode = "eng",
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
|
||||
IsEnabled = false,
|
||||
ShowInEpg = false,
|
||||
Artwork = [.. artwork]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,28 +240,6 @@ public class GetChannelGuideDataHandlerTests
|
||||
sourceItem.Finish.ShouldBe(BaseTime.AddHours(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Root_Uploaded_Logo_And_Null_When_Absent()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel withLogo = NewChannel("2", "WithLogo", showInEpg: true);
|
||||
withLogo.Artwork = [new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "abc123.png" }];
|
||||
DomainChannel withoutLogo = NewChannel("3", "NoLogo", showInEpg: true);
|
||||
context.Channels.AddRange(withLogo, withoutLogo);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
// Uploaded logo is rooted for the SPA's <img src>; a channel with no logo returns null so the SPA
|
||||
// renders its generated initials fallback.
|
||||
result.Channels.Single(c => c.Number == "2").Logo.ShouldBe("/iptv/logos/abc123.png");
|
||||
result.Channels.Single(c => c.Number == "3").Logo.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout()
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
@@ -115,77 +114,6 @@ public class UpdateChannelHandlerTests : ChannelHandlerTestBase
|
||||
error.Value.ShouldContain("FFmpegProfile");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Mirror_Transition_When_Channel_Has_Playout()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5"); // has a playout below
|
||||
await SeedChannel(2, "6"); // valid mirror source (Generated, no playouts of its own)
|
||||
await SeedPlayout(1, channelId: 1);
|
||||
|
||||
UpdateChannel update = MakeUpdate(1, number: "5") with
|
||||
{
|
||||
PlayoutSource = ChannelPlayoutSource.Mirror,
|
||||
MirrorSourceChannelId = 2
|
||||
};
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Mirror");
|
||||
|
||||
// the channel must NOT have been silently coerced/saved as Generated (issue #401: no
|
||||
// silent 200, the caller's requested transition is rejected outright)
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channel = await context.Channels.SingleAsync(c => c.Id == 1);
|
||||
channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated);
|
||||
channel.MirrorSourceChannelId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Allow_GetPut_Roundtrip_Of_Generated_Channel_With_Playout()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5");
|
||||
await SeedPlayout(1, channelId: 1);
|
||||
|
||||
// client GETs the channel (PlayoutSource: Generated) and PUTs the same value back
|
||||
// unchanged; this must still succeed even though the channel has a playout.
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Null_Mirror_Only_Fields_When_Saving_Generated_Channel_With_Playout()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5"); // has a playout below
|
||||
await SeedChannel(2, "6"); // stray reference target
|
||||
await SeedPlayout(1, channelId: 1);
|
||||
|
||||
// request keeps PlayoutSource: Generated but carries stray Mirror-only fields — e.g. a
|
||||
// client that never cleared the fields after flipping the UI back from Mirror. These
|
||||
// must never persist onto a Generated channel.
|
||||
UpdateChannel update = MakeUpdate(1, number: "5") with
|
||||
{
|
||||
MirrorSourceChannelId = 2,
|
||||
PlayoutOffset = TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channel = await context.Channels.SingleAsync(c => c.Id == 1);
|
||||
channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated);
|
||||
channel.MirrorSourceChannelId.ShouldBeNull();
|
||||
channel.PlayoutOffset.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
|
||||
@@ -98,45 +98,6 @@ public class FFmpegProfileHandlerTests
|
||||
LeftOf(result).ShouldBeAssignableTo<BaseError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Persist_QsvPreferNativeDecoder_False()
|
||||
{
|
||||
// Guards the EF nullable-bool gotcha: QsvPreferNativeDecoder is `bool?` on the domain
|
||||
// entity with null-means-ON semantics, so an explicit `false` must not get coerced back to
|
||||
// null/true anywhere between the command and the persisted row.
|
||||
await SeedResolution(1);
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result =
|
||||
await handler.Handle(MakeCreate(1, qsvPreferNativeDecoder: false), CancellationToken.None);
|
||||
|
||||
CreateFFmpegProfileResult created = RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(created.FFmpegProfileId);
|
||||
persisted.QsvPreferNativeDecoder.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Persist_QsvPreferNativeDecoder_False()
|
||||
{
|
||||
await SeedProfile(1);
|
||||
await SeedResolution(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result =
|
||||
await handler.Handle(MakeUpdate(1, qsvPreferNativeDecoder: false), CancellationToken.None);
|
||||
|
||||
RightOf(result);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
FFmpegProfile persisted = await context.FFmpegProfiles.FindAsync(1);
|
||||
persisted.QsvPreferNativeDecoder.ShouldBe(false);
|
||||
}
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e}"), Right: r => r);
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
|
||||
@@ -184,7 +145,7 @@ public class FFmpegProfileHandlerTests
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static CreateFFmpegProfile MakeCreate(int resolutionId, bool qsvPreferNativeDecoder = true) =>
|
||||
private static CreateFFmpegProfile MakeCreate(int resolutionId) =>
|
||||
new(
|
||||
"Default",
|
||||
1,
|
||||
@@ -215,13 +176,9 @@ public class FFmpegProfileHandlerTests
|
||||
48_000,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
qsvPreferNativeDecoder);
|
||||
false);
|
||||
|
||||
private static UpdateFFmpegProfile MakeUpdate(
|
||||
int id,
|
||||
int resolutionId = 1,
|
||||
bool qsvPreferNativeDecoder = true) =>
|
||||
private static UpdateFFmpegProfile MakeUpdate(int id, int resolutionId = 1) =>
|
||||
new(
|
||||
id,
|
||||
"Default",
|
||||
@@ -253,6 +210,5 @@ public class FFmpegProfileHandlerTests
|
||||
48_000,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
qsvPreferNativeDecoder);
|
||||
false);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
@@ -50,7 +50,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
@@ -72,7 +72,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
Option<HealthCheckLink>.Some(HealthCheckLink.ExternalDoc("https://example.com/docs")))
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
@@ -99,7 +99,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
Option<HealthCheckLink>.Some(HealthCheckLink.AppRoute("/app/trash")))
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
@@ -118,7 +118,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
@@ -135,7 +135,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
new("Blank Brief", HealthCheckStatus.Pass, "detail", string.Empty, Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(results);
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
@@ -146,7 +146,7 @@ public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
[Test]
|
||||
public async Task Should_Return_Empty_List_On_Cancellation()
|
||||
{
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>())
|
||||
.Returns<Task<List<HealthCheckResult>>>(_ => throw new TaskCanceledException());
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
@@ -154,26 +154,4 @@ public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
|
||||
response.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Force_Refresh_By_Default()
|
||||
{
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<HealthCheckResult>());
|
||||
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
|
||||
await _healthCheckService.Received(1).PerformHealthChecks(false, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Force_Refresh_When_Requested()
|
||||
{
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<HealthCheckResult>());
|
||||
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(Refresh: true), CancellationToken.None);
|
||||
|
||||
await _healthCheckService.Received(1).PerformHealthChecks(true, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,28 +42,6 @@ public class CreateLocalLibraryHandlerTests
|
||||
result.IfLeft(error => error.Value.ShouldContain("/media/movies"));
|
||||
}
|
||||
|
||||
// LibraryMediaKind.Mixed exists only for remote (Jellyfin) libraries, where the media server
|
||||
// classifies each item. No local folder scanner handles it, so a local Mixed library would fail
|
||||
// every scan forever and log at ERROR on every scheduler tick. The API takes a raw
|
||||
// LibraryMediaKind, so hiding it from the SPA dropdown is not enforcement (#489 review M1).
|
||||
[Test]
|
||||
public async Task Handle_Should_Reject_The_Mixed_Media_Kind_For_Local_Libraries()
|
||||
{
|
||||
await SeedLocalMediaSource();
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.CreateDirectory("/media/music");
|
||||
|
||||
CreateLocalLibraryHandler handler = CreateHandler(fileSystem);
|
||||
|
||||
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
|
||||
new CreateLocalLibrary("Music", LibraryMediaKind.Mixed, ["/media/music"]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.IfLeft(error => error.Value.ShouldContain("Mixed"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_List_Only_The_Missing_Paths_When_Mixed()
|
||||
{
|
||||
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #327: ReplacePlaylistItemsHandler (playlist rename) did no name validation, so a playlist
|
||||
/// could be renamed to an empty/whitespace or over-long name even though CreatePlaylistHandler
|
||||
/// already refuses those on create. Mirrors RenamePlaylistGroupHandler's ValidateName combinator
|
||||
/// (NotEmpty + NotLongerThan(50)) so create and rename enforce the same rule.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReplacePlaylistItemsHandlerNameValidationTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private async Task SeedPlaylistAsync()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
ctx.Playlists.Add(
|
||||
new Playlist
|
||||
{
|
||||
Id = 1,
|
||||
PlaylistGroupId = 1,
|
||||
Name = "Kids",
|
||||
IsSystem = false,
|
||||
Version = 1,
|
||||
Items = new List<PlaylistItem>()
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static ReplacePlaylistItems Command(string name) =>
|
||||
new(
|
||||
1,
|
||||
name,
|
||||
new List<ReplacePlaylistItem>
|
||||
{
|
||||
new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true)
|
||||
},
|
||||
None);
|
||||
|
||||
private async Task<string> ReadNameAsync()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
return await ctx.Playlists.Where(p => p.Id == 1).Select(p => p.Name).SingleAsync();
|
||||
}
|
||||
|
||||
private static BaseError? LeftOrNull<T>(Either<BaseError, T> result) =>
|
||||
result.Match<BaseError?>(Right: _ => null, Left: e => e);
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Name_Should_Be_Rejected_And_Not_Mutate()
|
||||
{
|
||||
await SeedPlaylistAsync();
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(string.Empty), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldNotBeNull();
|
||||
(await ReadNameAsync()).ShouldBe("Kids");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Whitespace_Name_Should_Be_Rejected_And_Not_Mutate()
|
||||
{
|
||||
await SeedPlaylistAsync();
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(" "), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldNotBeNull();
|
||||
(await ReadNameAsync()).ShouldBe("Kids");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Over_Long_Name_Should_Be_Rejected_And_Not_Mutate()
|
||||
{
|
||||
await SeedPlaylistAsync();
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
string tooLong = new string('a', 51);
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(tooLong), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldNotBeNull();
|
||||
(await ReadNameAsync()).ShouldBe("Kids");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Valid_Rename_Should_Succeed()
|
||||
{
|
||||
await SeedPlaylistAsync();
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
string maxLength = new string('a', 50);
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(maxLength), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadNameAsync()).ShouldBe(maxLength);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
@@ -67,54 +66,6 @@ public class GetAllMediaSourcesForApiHandlerTests
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Report_Sentinel_And_Null_LastScan_As_Null()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
var source = new LocalMediaSource
|
||||
{
|
||||
Libraries =
|
||||
[
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Never Scanned Sentinel",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
LastScan = new DateTime(1, 1, 1),
|
||||
Paths = [MakePath("/media/sentinel", 0)]
|
||||
},
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Never Scanned Null",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
LastScan = null,
|
||||
Paths = [MakePath("/media/nullscan", 0)]
|
||||
},
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Really Scanned",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Paths = [MakePath("/media/scanned", 0)]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
context.MediaSources.Add(source);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
|
||||
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
|
||||
|
||||
MediaSourceLibraryResponseModel[] libraries = result.Single().Libraries.ToArray();
|
||||
|
||||
libraries.Single(l => l.Name == "Never Scanned Sentinel").LastScan.ShouldBeNull();
|
||||
libraries.Single(l => l.Name == "Never Scanned Null").LastScan.ShouldBeNull();
|
||||
libraries.Single(l => l.Name == "Really Scanned").LastScan
|
||||
.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user