Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d891ba63 | ||
|
|
a3080ca42b | ||
|
|
1cd06395aa | ||
|
|
c4695b1095 | ||
|
|
a1b75c1f2c | ||
|
|
4345180a56 | ||
|
|
6c8c7feeba | ||
|
|
8a85f9ddb5 | ||
|
|
551366aa72 | ||
|
|
c57fbf9826 | ||
|
|
8a808e6205 | ||
|
|
ac7965dee4 | ||
|
|
2281f2e764 | ||
|
|
3a463db36a | ||
|
|
3bca000e27 | ||
|
|
e364b338e6 | ||
|
|
0a418a7c53 | ||
|
|
3b5a78a08f | ||
|
|
c91c34f113 | ||
|
|
ea6b509016 | ||
|
|
8483af41fc | ||
|
|
60e8655a00 | ||
|
|
2763f65b35 | ||
|
|
ceb9069bae | ||
|
|
7d553ef00c | ||
|
|
279c0d6ce5 | ||
|
|
30a8f67e8e | ||
|
|
03646a2ed9 | ||
|
|
5186bed910 | ||
|
|
218e1810cb | ||
|
|
0b5258533a | ||
|
|
bc524d0b53 | ||
|
|
7f7062729d | ||
|
|
fd786da649 | ||
|
|
ae22107d98 | ||
|
|
41df582032 | ||
|
|
aa4127fcef | ||
|
|
8c7af0e202 | ||
|
|
71159c1a8a | ||
|
|
6204e30c7c | ||
|
|
56fffacfd4 | ||
|
|
f44eee85c5 | ||
|
|
0a34d1a585 | ||
|
|
ed6c43065f | ||
|
|
1abc5df18c | ||
|
|
b9ab2d79f3 | ||
|
|
8f61ad6530 | ||
|
|
ba707f2e23 | ||
|
|
1df08e86bb | ||
|
|
5e0c53c339 | ||
|
|
eb1aba99c1 | ||
|
|
4818b07668 | ||
|
|
082124f706 | ||
|
|
74005cc952 | ||
|
|
1b355660c5 | ||
|
|
9c72aebab0 | ||
|
|
de63603aab | ||
|
|
5f8525eed7 | ||
|
|
b2059bd2a6 | ||
|
|
f7b97adce8 | ||
|
|
92bb63b6da | ||
|
|
5ba737bec7 | ||
|
|
ffa8e5a000 | ||
|
|
bf15677dc6 | ||
|
|
fa7e8d75c4 | ||
|
|
9752111240 | ||
|
|
caaae4cd00 | ||
|
|
7446ce0293 | ||
|
|
0295a8a9f6 | ||
|
|
ccef0ca88a | ||
|
|
c0da414a4c | ||
|
|
953481c177 | ||
|
|
ef9bba8a52 | ||
|
|
dd7c155b39 | ||
|
|
f394c721a8 | ||
|
|
07cb8c0287 | ||
|
|
68be319403 | ||
|
|
30415d12f9 | ||
|
|
fe6e2722f8 | ||
|
|
ac7c978824 | ||
|
|
1e35248edf | ||
|
|
d92e717ebb | ||
|
|
7e9c52b035 | ||
|
|
9b3121f0c2 | ||
|
|
6cd5471500 | ||
|
|
1a40b4a8e6 | ||
|
|
2c7330f273 | ||
|
|
6052626316 | ||
|
|
139bdaff5a | ||
|
|
b3dbbd0528 | ||
|
|
469d725559 | ||
|
|
7859510c78 | ||
|
|
04e3a5249d | ||
|
|
e33205e8eb | ||
|
|
76c710dfa5 | ||
|
|
5ac49e553e | ||
|
|
71708633f5 | ||
|
|
986ccfaf6c | ||
|
|
5c8dd64acf |
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# H13 (ersatztv#416 session) — refuse to push when a file in the pushed diff still has UNCOMMITTED
|
||||
# changes in the working tree or index. That is the "I left part of my intended change behind"
|
||||
# failure: a fix edited into the working file but never committed (e.g. after a `git reset --soft`
|
||||
# that re-staged a stale index) gets pushed WITHOUT the fix — while local tests and a working-tree
|
||||
# review both see the fix that never shipped. This bit the #416 session: a `--no-renames` review fix
|
||||
# lived only in the working tree, so the pushed commit, CI, and the first re-review each saw a
|
||||
# different tree, and a PR went out still carrying the bug the review had "confirmed" fixed.
|
||||
#
|
||||
# Scope is deliberately PRECISE to keep false positives near zero: it blocks only when a dirty
|
||||
# tracked file is ALSO part of this branch's diff vs origin/main. Unrelated uncommitted scratch in a
|
||||
# file the push doesn't touch is fine; untracked files are ignored.
|
||||
#
|
||||
# Fail-OPEN on anything we can't decide (a git pre-push hook has no "ask"): not a git repo, offline /
|
||||
# no origin/main, HEAD unresolved -> allow. Deliberate escape: ETV_ALLOW_DIRTY_PUSH=1.
|
||||
set -uo pipefail
|
||||
|
||||
[ "${ETV_ALLOW_DIRTY_PUSH:-}" = "1" ] && exit 0
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Files with uncommitted changes vs HEAD — unstaged AND staged-but-uncommitted, tracked only.
|
||||
dirty="$( { git diff --name-only; git diff --cached --name-only; } 2>/dev/null | sort -u )"
|
||||
[ -z "$dirty" ] && exit 0 # clean tree -> nothing to guard
|
||||
|
||||
# The set of files this branch introduces vs origin/main (the "pushed diff"). Best-effort fetch;
|
||||
# if origin/main is unavailable we cannot scope precisely -> fail open rather than over-block.
|
||||
git fetch origin main --quiet 2>/dev/null || exit 0
|
||||
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
|
||||
pushed="$( git diff --name-only "origin/main...HEAD" 2>/dev/null | sort -u )"
|
||||
[ -z "$pushed" ] && exit 0
|
||||
|
||||
# Intersection: dirty files that are part of the pushed diff.
|
||||
both="$( comm -12 <(printf '%s\n' "$dirty") <(printf '%s\n' "$pushed") )"
|
||||
[ -z "$both" ] && exit 0
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
|
||||
echo "husky - push blocked (H13): '$branch' has UNCOMMITTED changes to file(s) that are part of"
|
||||
echo " what you're pushing — the pushed commit does NOT match your working tree, so a local fix"
|
||||
echo " or review may be shipping without its change (the #416 index/worktree trap):"
|
||||
printf '%s\n' "$both" | sed 's/^/ /'
|
||||
echo " Commit them (or 'git checkout --' to discard), then push. If the difference is intentional"
|
||||
echo " and unrelated, bypass with: ETV_ALLOW_DIRTY_PUSH=1 git push"
|
||||
exit 1
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Bash — deny `git commit` / `git push` when a .cs file this branch touches carries a
|
||||
# UTF-8 BOM. `.editorconfig` sets charset=utf-8 (no BOM), and the #311 fix-as-you-touch gate
|
||||
# ("Formatting (changed .cs conform to .editorconfig)") FAILS THE PR for any touched file that has one.
|
||||
#
|
||||
# Why a hook and not a note: the ~2500 legacy .cs files carry a BOM, so it becomes *your* problem the
|
||||
# moment you touch one — and the usual ways of touching them re-add it silently. Python
|
||||
# `io.open(..., encoding='utf-8-sig')` WRITES a BOM back; perl/sed round-trips preserve it. On
|
||||
# 2026-07-17 this cost two separate sessions a red CI job on the same day (PR #405 x6 files;
|
||||
# #70/PR #402 x19), and a memory describing the trap did not prevent either — the second session
|
||||
# re-added a BOM an hour after writing that memory down. A check that runs is worth more than one you
|
||||
# have to remember.
|
||||
#
|
||||
# Generated files are excluded: dotnet format skips *.Designer.cs and TvContextModelSnapshot.cs as
|
||||
# generated code, and so does the CI verify, so `dotnet ef` may leave its BOM there.
|
||||
#
|
||||
# Fail-open by design: any parse/lookup trouble → allow (exit 0, no output). This gate must never be
|
||||
# the reason a commit can't happen; CI is still the backstop.
|
||||
set -uo pipefail
|
||||
|
||||
input=$(cat)
|
||||
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
|
||||
[ -n "$cmd" ] || exit 0
|
||||
|
||||
# Only gate real `git commit` / `git push` invocations (allowing global flags like `git -c x=y commit`).
|
||||
# Matched in command position so the words inside a commit message or an echo never false-trip.
|
||||
printf '%s' "$cmd" \
|
||||
| grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+(commit|push)([[:space:]]|$)' \
|
||||
|| exit 0
|
||||
|
||||
# Which tree does this act on? Commits here are typically `cd <worktree>` followed by git, and the
|
||||
# harness resets the shell cwd between calls, so an in-command `cd` is the most reliable signal.
|
||||
# Fall back to the payload cwd, then the project dir.
|
||||
dir=$(printf '%s' "$cmd" \
|
||||
| grep -oE '(^|[;&|(]|&&|\|\|)[[:space:]]*cd[[:space:]]+[^;&|)]+' \
|
||||
| tail -1 | sed -E 's/.*cd[[:space:]]+//; s/[[:space:]]+$//' | tr -d "\"'" || true)
|
||||
if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then
|
||||
dir=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then
|
||||
dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
fi
|
||||
|
||||
root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
|
||||
# Scoped to this repo — the .editorconfig rule it enforces is ours.
|
||||
case "$root" in
|
||||
*ersatztv*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# The touched set: what this branch changes vs origin/main, plus anything staged or dirty right now
|
||||
# (a commit can introduce a BOM that isn't in the pushed diff yet).
|
||||
base=$(git -C "$root" rev-parse --verify --quiet origin/main 2>/dev/null || true)
|
||||
{
|
||||
[ -n "$base" ] && git -C "$root" diff --name-only --diff-filter=ACM "$base"...HEAD -- '*.cs' 2>/dev/null
|
||||
git -C "$root" diff --name-only --diff-filter=ACM --cached -- '*.cs' 2>/dev/null
|
||||
git -C "$root" diff --name-only --diff-filter=ACM -- '*.cs' 2>/dev/null
|
||||
} | sort -u > /tmp/.bom-guard-files.$$ 2>/dev/null || { rm -f /tmp/.bom-guard-files.$$; exit 0; }
|
||||
|
||||
bad=""
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
case "$f" in
|
||||
*.Designer.cs|*TvContextModelSnapshot.cs) continue ;;
|
||||
esac
|
||||
p="$root/$f"
|
||||
[ -f "$p" ] || continue
|
||||
if [ "$(head -c3 "$p" 2>/dev/null | xxd -p 2>/dev/null)" = "efbbbf" ]; then
|
||||
bad="${bad} ${f}"$'\n'
|
||||
fi
|
||||
done < /tmp/.bom-guard-files.$$
|
||||
rm -f /tmp/.bom-guard-files.$$
|
||||
|
||||
[ -n "$bad" ] || exit 0
|
||||
|
||||
reason="Blocked: these .cs files carry a UTF-8 BOM, which .editorconfig forbids (charset=utf-8). The #311 Formatting CI job fails the PR for any file this branch touches that has one:
|
||||
|
||||
${bad}
|
||||
Strip it, then re-run this command:
|
||||
|
||||
python3 - <<'EOF'
|
||||
import subprocess
|
||||
def g(*a): return subprocess.run(['git','diff','--name-only',*a,'--','*.cs'],
|
||||
capture_output=True, text=True).stdout.split()
|
||||
# same detection set as the guard: branch diff + staged + dirty (a brand-new staged
|
||||
# file is exactly what fires the deny and is absent from origin/main...HEAD)
|
||||
fs = set(g('origin/main...HEAD')) | set(g('--cached')) | set(g())
|
||||
for f in sorted(fs):
|
||||
try: b = open(f,'rb').read()
|
||||
except OSError: continue
|
||||
if b[:3] == b'\xef\xbb\xbf':
|
||||
open(f,'wb').write(b[3:]); print('stripped', f)
|
||||
EOF
|
||||
|
||||
Usual cause: an edit that rewrote a legacy file preserved its BOM — Python io.open(..., encoding='utf-8-sig') WRITES one back; sed/perl round-trips keep it. Touching a legacy file makes its inherited BOM yours to remove (docs/contributing.md; ersatztv#311). Generated *.Designer.cs / TvContextModelSnapshot.cs are exempt and not listed here."
|
||||
|
||||
jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
|
||||
exit 0
|
||||
@@ -14,6 +14,11 @@
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-worktree-guard.sh\"",
|
||||
"timeout": 10
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-bom-guard.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -22,6 +22,17 @@ concurrency:
|
||||
group: ersatztv-depscan
|
||||
cancel-in-progress: true
|
||||
|
||||
# No persistent MSBuild/Roslyn servers (ersatztv#406). Workflow `env:` does not cross workflow
|
||||
# files, so docker-build.yml's copy of these does not apply here and this has to be repeated.
|
||||
# Smaller stakes than the build pipeline — `dotnet restore` + `dotnet list` are MSBuild-driven and
|
||||
# never invoke csc, so this is lingering worker nodes (hundreds of MiB), not a 7.8 GB VBCSCompiler.
|
||||
# Worth setting anyway: this runs unattended on a Monday 06:00 cron against the same host that runs
|
||||
# prod media, and node reuse keeps workers alive ~15 min after the job.
|
||||
env:
|
||||
UseSharedCompilation: "false"
|
||||
DOTNET_CLI_USE_MSBUILD_SERVER: "0"
|
||||
MSBUILDDISABLENODEREUSE: "1"
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: NuGet vulnerable packages
|
||||
|
||||
@@ -26,6 +26,16 @@ name: Build ErsatzTV Image
|
||||
# "CI toolchain image" for the two-step procedure.
|
||||
#
|
||||
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
#
|
||||
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
|
||||
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
|
||||
# `scripts/ci-detect-docs-only.sh` as their first post-checkout step (id: detect) and gate every
|
||||
# real step on `steps.detect.outputs.docs_only != 'true'`. Crucially they STILL RUN and STILL
|
||||
# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration
|
||||
# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT
|
||||
# `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".
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -60,6 +70,26 @@ env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
IMAGE: 192.168.1.95:3000/timothy/ersatztv
|
||||
|
||||
# --- CI build memory (ersatztv#406, server-management#604) ---
|
||||
# Roslyn's `VBCSCompiler` is a *persistent* compiler server: it outlives the `dotnet build` that
|
||||
# started it and keeps its managed heap warm for the next one. Locally that is a real speedup.
|
||||
# In CI it buys nothing — each job container is torn down at the end of the run, so there is
|
||||
# never a "next build" to warm — while costing a lot: 7.8 GB RSS was measured live on bumblebee,
|
||||
# the single largest consumer on a 25 GiB host that also runs prod media. Several of those, one
|
||||
# per concurrent job container, is what drove the host to load 340 with 21 GiB swapped.
|
||||
#
|
||||
# These are MSBuild properties/switches, set here as environment variables so they apply to every
|
||||
# dotnet invocation in every job (restore/build/test/format/api-docs) without touching each call
|
||||
# site. MSBuild surfaces environment variables as properties, and `UseSharedCompilation` is only
|
||||
# defaulted to true when empty, so setting it here wins.
|
||||
#
|
||||
# NOTE: this reaches the *runner-side* dotnet jobs only. The `build` job compiles inside
|
||||
# `docker build`, where these do not propagate — the same switches are set as ENV in the
|
||||
# Dockerfile's SDK stage (docker/Dockerfile) to cover it.
|
||||
UseSharedCompilation: "false" # no persistent VBCSCompiler; csc runs per-project and exits
|
||||
DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process
|
||||
MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & test (.NET)
|
||||
@@ -77,7 +107,15 @@ jobs:
|
||||
# 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
|
||||
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -85,11 +123,13 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
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'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
@@ -97,36 +137,45 @@ jobs:
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
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'
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
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'
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
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
|
||||
@@ -137,6 +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'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -160,6 +210,72 @@ jobs:
|
||||
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# 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.
|
||||
#
|
||||
# ⚠️ 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 — 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: |
|
||||
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
|
||||
peak=""; src=""
|
||||
for f in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory/memory.max_usage_in_bytes; do
|
||||
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
|
||||
done
|
||||
if [ -z "$peak" ]; then
|
||||
echo "No cgroup peak-memory file readable in this container -- skipping."
|
||||
exit 0
|
||||
fi
|
||||
anon=""; file=""
|
||||
if [ -r /sys/fs/cgroup/memory.stat ]; then
|
||||
anon=$(awk '/^anon /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
|
||||
file=$(awk '/^file /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "::group::Container memory (ersatztv#406 / server-management#604)"
|
||||
printf 'peak (incl. page cache): %s MiB [%s bytes, %s]\n' "$(mib "$peak")" "$peak" "$src"
|
||||
if [ -n "$anon" ]; then
|
||||
printf 'end-of-job anon (the part that OOMs): %s MiB\n' "$(mib "$anon")"
|
||||
printf 'end-of-job file (page cache, reclaimable): %s MiB\n' "$(mib "${file:-0}")"
|
||||
echo 'NOTE: peak counts reclaimable page cache. Size caps on anon, not on peak.'
|
||||
else
|
||||
echo 'NOTE: no memory.stat breakdown available; peak includes reclaimable page cache.'
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{
|
||||
printf '**Container memory (test job):** peak %s MiB *(incl. reclaimable page cache)*' \
|
||||
"$(mib "$peak")"
|
||||
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' \
|
||||
"$(mib "$anon")" "$(mib "${file:-0}")"
|
||||
printf '\n'
|
||||
} >> "$GITHUB_STEP_SUMMARY" || true
|
||||
fi
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -180,7 +296,42 @@ jobs:
|
||||
# No host-port binding: the job reaches this service as mysql:3306 on the shared
|
||||
# runner network. Publishing 3306 made concurrent runs collide ("port is already
|
||||
# allocated") whenever two migrations jobs overlapped.
|
||||
#
|
||||
# `--memory`/`--cpus` here because the runner's `container.options` (`--memory=10g`)
|
||||
# applies to the JOB container ONLY, not to `services:` — verified by inspecting a live
|
||||
# migrations job: the job container reported HostConfig.Memory=10737418240, its mysql
|
||||
# service reported `mem=0 nanocpus=0`, i.e. unbounded. So every migrations run was adding
|
||||
# an uncapped MySQL to an already-tight host (ersatztv#406, server-management#604).
|
||||
#
|
||||
# NOTE (ersatztv#416): a `services:` container starts whenever the JOB starts, regardless
|
||||
# of step `if:`. So a docs-only migrations run still spins this mysql (capped, seconds) even
|
||||
# though the DDL-replay steps below are skipped. Fully skipping the service would require an
|
||||
# `if:`-skipped job, which we deliberately do NOT do for a required context — the heavy cost
|
||||
# (the 787-migration replay) is what the step gating removes.
|
||||
#
|
||||
# `--memory-swap=2g` is NOT redundant with `--memory=2g` — it is the point. Docker defaults
|
||||
# an unset `--memory-swap` to *twice* `--memory`, so `--memory=2g` alone would grant 2g RAM
|
||||
# **plus 2g of swap** (verified on bumblebee: `--memory=2g` alone → memory.max=2147483648
|
||||
# AND memory.swap.max=2147483648; with `--memory-swap=2g` → memory.swap.max=0). Setting it
|
||||
# equal to --memory disables swap for this container. That matters more here than anywhere:
|
||||
# swap thrash on this host is the whole reason this cap exists, and a swapping mysqld mid-DDL
|
||||
# is precisely the pathology behind the known `Command Timeout expired` migrations flake. We
|
||||
# want a loud OOM over silent swapping — an OOM is a clear signal to raise the cap.
|
||||
#
|
||||
# 2g is sized on measurement rather than inheritance, but honestly: a mysql:8.4 container
|
||||
# with this exact env peaked at 543 MiB during init and settled at 481 MiB idle (probed on
|
||||
# bumblebee 2026-07-17). That is init+idle, NOT the 787-migration replay, which grows caches
|
||||
# idle never touches — so treat 2g as a measured floor with headroom, not a measured
|
||||
# ceiling. The migrations job going green is what validates it. If this OOM-kills the
|
||||
# service, raise it deliberately — do not remove the cap, and do not re-enable swap.
|
||||
#
|
||||
# `--cpus=2` is a ceiling, not a reservation, and is the one number here with no measurement
|
||||
# behind it: 787 sequential DDL statements on one connection are ~1-core-bound, so 2 is
|
||||
# judgement. Revisit if the apply step's tail latency grows.
|
||||
options: >-
|
||||
--memory=2g
|
||||
--memory-swap=2g
|
||||
--cpus=2
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
@@ -191,7 +342,14 @@ jobs:
|
||||
# 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: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -199,9 +357,11 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
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
|
||||
@@ -209,6 +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'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
@@ -224,6 +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'
|
||||
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
|
||||
@@ -261,12 +423,13 @@ jobs:
|
||||
name: Functional E2E (curl contracts)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
|
||||
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
|
||||
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
|
||||
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
|
||||
# the unit-test gate — promote it to a required check / build dependency once it's proven
|
||||
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
|
||||
# Runs on PRs and on main (regression net); skipped for v* tag builds.
|
||||
# flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
|
||||
# If-Match/412, and since ersatztv#363 two lock-contention 409s) that sessions have been
|
||||
# re-running by hand. Deliberately NOT a `needs:` of `build` and not (yet) a required check, so a
|
||||
# functional-E2E flake can't block image builds or the unit-test gate — promote it to a required
|
||||
# check / build dependency once it's proven reliable (same rollout the `migrations` job used).
|
||||
# SQLite default provider -> no DB service. Runs on PRs and on main (regression net); skipped for
|
||||
# v* tag builds.
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
@@ -279,7 +442,13 @@ jobs:
|
||||
with:
|
||||
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: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -287,9 +456,11 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
@@ -297,23 +468,28 @@ jobs:
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
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):
|
||||
# the toolchain image ships the same ffmpeg build prod runs, so the binary is already here.
|
||||
# That step also cost 110s of every run to install a dependency this harness never uses —
|
||||
# it boots the app (whose only ffmpeg touch at startup is a LogWarning from
|
||||
# FFmpegLocatorService) and drives curl-only contracts that never transcode.
|
||||
# That step also cost 110s of every run. The harness never *transcodes*, but since ersatztv#363
|
||||
# it does use ffmpeg to synthesize ~60 tiny testsrc clips to seed the scan-lock 409 flow (and
|
||||
# python3's stdlib sqlite3 to seed the DB rows the API can't create) — both already present in
|
||||
# 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'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
@@ -342,8 +518,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image),
|
||||
# so skip the build/push/smoke steps — the job still reports success. Tag builds force
|
||||
# docs_only=false in the script, so a release is never skipped.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
|
||||
- name: Compute version and tags
|
||||
id: meta
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
@@ -366,6 +550,7 @@ jobs:
|
||||
printf 'tag: %s\n' "${TAGS[@]}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
@@ -373,6 +558,7 @@ jobs:
|
||||
http = true
|
||||
|
||||
- name: Login to Gitea registry
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -380,6 +566,7 @@ jobs:
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
@@ -395,14 +582,16 @@ jobs:
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
|
||||
|
||||
- name: Smoke + IPTV E2E (assert key endpoints)
|
||||
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
NAME="etv-smoke-${{ github.run_id }}"
|
||||
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
docker run -d --name "$NAME" --memory 2g \
|
||||
# --memory-swap equal to --memory disables swap. Without it Docker defaults --memory-swap
|
||||
# to 2x --memory, so `--memory 2g` alone silently grants 2g RAM + 2g swap (ersatztv#406).
|
||||
docker run -d --name "$NAME" --memory 2g --memory-swap 2g \
|
||||
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
|
||||
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
|
||||
"$IMG"
|
||||
|
||||
@@ -15,6 +15,12 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
|
||||
./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
|
||||
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
|
||||
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
|
||||
# index/worktree trap: a review fix left in the working tree shipped without being committed).
|
||||
# Runs before the slow CI-parity checks so it fails fast. Fail-open; escape ETV_ALLOW_DIRTY_PUSH=1.
|
||||
./.claude/hooks/prepush-clean-worktree-check.sh || exit 1
|
||||
|
||||
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
|
||||
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
|
||||
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
|
||||
|
||||
@@ -38,6 +38,58 @@ public static class AutoTuneAxisMap
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// Per-source member query for a weighted auto-tune channel (#425). The discriminator identifies ONE
|
||||
// content source within the channel's axis:
|
||||
// * TV axes -> the show title. Episodes carry no parent-show id in the search index (only show_title
|
||||
// is denormalized onto them), so show_title is the only field that selects a show's episodes. It is
|
||||
// the same discriminator the TvShow axis already uses, so this introduces no new fragility class;
|
||||
// a post-create show rename empties the member (items fall through to the remainder) until re-tuned.
|
||||
// * MovieGenre -> the movie's media-item id (the stable, rename-proof `id` field; a movie IS the
|
||||
// played item, so its own id selects it exactly).
|
||||
// Deliberately discriminator-ONLY (no genre clause): membership is decided when the channel is tuned,
|
||||
// so a materialized show airs all its episodes and the remainder subtracts the whole source (below).
|
||||
public static string GenerateSourceQuery(AutoTuneAxis axis, string discriminator) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
|
||||
$"type:episode AND show_title:\"{EscapeLuceneValue(discriminator)}\"",
|
||||
AutoTuneAxis.MovieGenre => $"type:movie AND id:{discriminator}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// The bare clause used to subtract a materialized/excluded source from the remainder query (below).
|
||||
// Mirrors GenerateSourceQuery's discriminator field, minus the type prefix.
|
||||
public static string SourceDiscriminatorClause(AutoTuneAxis axis, string discriminator) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
|
||||
$"show_title:\"{EscapeLuceneValue(discriminator)}\"",
|
||||
AutoTuneAxis.MovieGenre => $"id:{discriminator}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// The catch-all remainder query: the base axis query minus every materialized/excluded source, so the
|
||||
// base set is partitioned across (member sources + remainder) with no item counted twice and none
|
||||
// dropped. Returns the plain base query when there is nothing to subtract. Emitted as valid classic
|
||||
// Lucene — `(base) AND NOT (d1 OR d2 ...)` — because a ParseException silently escapes the whole query
|
||||
// into a literal (SearchQueryParser.ParseQuery fallback).
|
||||
public static string GenerateRemainderQuery(
|
||||
AutoTuneAxis axis,
|
||||
string value,
|
||||
IReadOnlyCollection<string> subtractedDiscriminators)
|
||||
{
|
||||
string baseQuery = GenerateQuery(axis, value);
|
||||
if (subtractedDiscriminators is null || subtractedDiscriminators.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
string negated = string.Join(
|
||||
" OR ",
|
||||
subtractedDiscriminators.Select(d => SourceDiscriminatorClause(axis, d)));
|
||||
return $"({baseQuery}) AND NOT ({negated})";
|
||||
}
|
||||
|
||||
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
|
||||
public static string EscapeLuceneValue(string value) =>
|
||||
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
|
||||
@@ -42,6 +42,16 @@ public class BulkDeleteChannelsHandler(
|
||||
|
||||
dbContext.Channels.RemoveRange(channels);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Clean up the system-owned weighted-auto-tune artifacts these channels created (#425), inside the
|
||||
// same transaction — see DeleteChannelHandler for the cascade rationale.
|
||||
await dbContext.MultiCollections
|
||||
.Where(mc => mc.OwnedByChannelId != null && channelIds.Contains(mc.OwnedByChannelId.Value))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.SmartCollections
|
||||
.Where(sc => sc.OwnedByChannelId != null && channelIds.Contains(sc.OwnedByChannelId.Value))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
@@ -197,13 +197,25 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
bool multiItem = normalized.Count >= 2;
|
||||
|
||||
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
|
||||
// MultiCollection entries only support Shuffle / ShuffleInOrder / WeightedShuffle
|
||||
// (mirrors PlayoutModeMustBeValid -- keep the two lists in step).
|
||||
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
|
||||
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
|
||||
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder
|
||||
or PlaybackOrder.WeightedShuffle))
|
||||
{
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
|
||||
}
|
||||
|
||||
// A lineup of 2+ entries is persisted as a Playlist, and PlaylistEnumerator has no default arm: an
|
||||
// order it doesn't know leaves the enumerator null and the items are dropped from the playlist with
|
||||
// nothing reported. This is the second (and less obvious) persisting writer of
|
||||
// PlaylistItem.PlaybackOrder, alongside ReplacePlaylistItems (#70; the silent fallbacks are #403).
|
||||
if (multiItem && playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for a multi-item lineup; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
if (multiItem)
|
||||
{
|
||||
// The generated playlist cannot express rerun collections or nested playlists
|
||||
|
||||
@@ -57,9 +57,22 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
_fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
int channelId = channel.Id;
|
||||
dbContext.Channels.Remove(channel);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Clean up the system-owned weighted-auto-tune artifacts this channel created (#425): the
|
||||
// MultiCollection (its cascade removes the now-dangling flood schedule item) and its per-source
|
||||
// SmartCollections (cascade removes their join rows). Null OwnedByChannelId = a user collection, left
|
||||
// untouched. Non-weighted (#69 single-SmartCollection) auto-tune channels set no ownership, so their
|
||||
// pre-existing orphan-on-delete behavior is unchanged.
|
||||
await dbContext.MultiCollections
|
||||
.Where(mc => mc.OwnedByChannelId == channelId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.SmartCollections
|
||||
.Where(sc => sc.OwnedByChannelId == channelId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
@@ -52,16 +52,6 @@ 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;
|
||||
@@ -140,6 +130,8 @@ 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
|
||||
{
|
||||
@@ -147,8 +139,6 @@ 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;
|
||||
@@ -176,6 +166,13 @@ public class UpdateChannelHandler(
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
|
||||
}
|
||||
|
||||
// Deliberately NOT Mapper.GetPlayoutsCount: this handler's query (see Handle) doesn't include
|
||||
// MirrorSourceChannel, so the shared helper would read that navigation as null and return the
|
||||
// same own-playouts-only count anyway — with a false air of Mirror-awareness. Harmless today
|
||||
// because ChannelController discards this view model and re-projects through
|
||||
// GetChannelByIdForApi, so this count never reaches the wire. If you ever return it directly,
|
||||
// fix the QUERY first (add the MirrorSourceChannel ThenInclude) — swapping in the helper alone
|
||||
// would report 0 playouts for a working mirror channel.
|
||||
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
|
||||
}
|
||||
|
||||
@@ -187,7 +184,7 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
@@ -262,6 +259,7 @@ 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)
|
||||
@@ -269,6 +267,18 @@ 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(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using MediatR;
|
||||
|
||||
@@ -8,11 +9,27 @@ public record CreateAutoTunedChannels(
|
||||
string Group,
|
||||
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
|
||||
|
||||
// The batch-level TemplateId is the default; any per-channel field set here overrides it for that one
|
||||
// channel. Advanced/Logo/TemplateId are all optional so the older positional {axis, value, name, number}
|
||||
// form (and every existing caller/test) keeps compiling and behaving identically.
|
||||
public record AutoTuneChannelSelection(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number);
|
||||
string Number,
|
||||
int? TemplateId = null,
|
||||
ArtworkContentTypeModel Logo = null,
|
||||
CreateChannelFromLineupAdvancedOptions Advanced = null,
|
||||
List<AutoTuneSourceWeight> Sources = null);
|
||||
|
||||
// Per-content-source rotation weight + query correction for a weighted auto-tune channel (#425).
|
||||
// SourceId is the show id (TV axes) or movie media-item id (movie axis) from the members list (#384).
|
||||
// Weight is the relative share of airtime (weighted round-robin; 1 = fair-share). Excluded drops the
|
||||
// source entirely. A SourceId that is not in the axis's base set is an "add-untagged" source — materialized
|
||||
// like any other. When every entry is Weight 1 and not excluded (and adds nothing), the channel keeps the
|
||||
// single-SmartCollection fair-share shape; otherwise it is built as a MultiCollection of per-source
|
||||
// SmartCollections carrying the weights.
|
||||
public record AutoTuneSourceWeight(int SourceId, int Weight = 1, bool Excluded = false);
|
||||
|
||||
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
|
||||
{
|
||||
|
||||
@@ -4,17 +4,29 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class CreateAutoTunedChannelsHandler(ISender mediator)
|
||||
public class CreateAutoTunedChannelsHandler(
|
||||
ISender mediator,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
ISmartCollectionCache smartCollectionCache)
|
||||
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
|
||||
{
|
||||
private const string NumberTakenError = "Channel number must be unique";
|
||||
private const string DefaultGroup = "Auto-Tuned";
|
||||
|
||||
// The members enumeration caps its own search at 10k leaf items, so a channel's distinct source count is
|
||||
// already bounded (dozens/hundreds). One large page pulls them all.
|
||||
private const int MaxSources = 10_000;
|
||||
|
||||
public async Task<AutoTuneResult> Handle(
|
||||
CreateAutoTunedChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -42,8 +54,53 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name");
|
||||
}
|
||||
|
||||
// Per-channel template override falls back to the batch template.
|
||||
int effectiveTemplateId = selection.TemplateId ?? templateId;
|
||||
|
||||
// Per-channel uploaded channel image; None = generate the on-the-fly fallback logo at serve time.
|
||||
ArtworkContentTypeModel logo = selection.Logo ?? ArtworkContentTypeModel.None;
|
||||
|
||||
// Per-source rotation weights / query corrections (#425) turn the channel from one fair-share
|
||||
// SmartCollection into a MultiCollection of per-source SmartCollections carrying the weights. Only
|
||||
// when the caller actually customized a source (a non-default weight, an exclusion, or an added
|
||||
// out-of-axis source) — otherwise the single-SmartCollection fair-share shape is kept (cheaper, and
|
||||
// identical output for TV since the fake-collection path already groups per show).
|
||||
WeightedPlan plan = await BuildWeightedPlan(selection, cancellationToken);
|
||||
if (plan is not null)
|
||||
{
|
||||
return await CreateWeightedChannel(
|
||||
effectiveTemplateId, group, name, logo, selection, plan, cancellationToken);
|
||||
}
|
||||
|
||||
return await CreateSingleSmartCollectionChannel(
|
||||
effectiveTemplateId,
|
||||
group,
|
||||
name,
|
||||
logo,
|
||||
selection,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AutoTuneChannelOutcome> CreateSingleSmartCollectionChannel(
|
||||
int effectiveTemplateId,
|
||||
string group,
|
||||
string name,
|
||||
ArtworkContentTypeModel logo,
|
||||
AutoTuneChannelSelection selection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
|
||||
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
|
||||
|
||||
// The axis default (SeasonEpisode for a single show, Shuffle for a genre) is the playback order
|
||||
// unless the DetailPanel set an explicit per-channel override. Any other Advanced field the caller
|
||||
// set is layered on top of the template by CreateChannelFromLineup's `advanced.X ?? template.X`
|
||||
// stamp-at-create contract, so we only have to fill in the axis-derived PlaybackOrder default here.
|
||||
PlaybackOrder axisOrder = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
|
||||
CreateChannelFromLineupAdvancedOptions advanced =
|
||||
(selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with
|
||||
{
|
||||
PlaybackOrder = selection.Advanced?.PlaybackOrder ?? axisOrder
|
||||
};
|
||||
|
||||
// 1. Create the smart collection that drives this channel.
|
||||
Either<BaseError, SmartCollectionViewModel> scResult =
|
||||
@@ -67,11 +124,11 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
|
||||
selection.Number,
|
||||
group,
|
||||
string.Empty,
|
||||
ArtworkContentTypeModel.None,
|
||||
logo,
|
||||
IsEnabled: true,
|
||||
ShowInEpg: true,
|
||||
templateId,
|
||||
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
|
||||
effectiveTemplateId,
|
||||
advanced,
|
||||
[
|
||||
new CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType.SmartCollection,
|
||||
@@ -113,4 +170,350 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
|
||||
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
|
||||
}
|
||||
|
||||
// A resolved weighting plan: the per-source member queries + their weights, and the catch-all remainder.
|
||||
// Null when the caller did not actually customize anything (fall back to the single-SmartCollection path).
|
||||
private sealed record WeightedPlan(List<WeightedMember> Members, WeightedMember Remainder);
|
||||
|
||||
private sealed record WeightedMember(string Query, int Weight);
|
||||
|
||||
// Resolve the caller's per-source overrides against the channel's live base source set. Returns null when
|
||||
// no source was customized (all weights 1, nothing excluded, nothing added) so the caller keeps the
|
||||
// single-SmartCollection fair-share shape.
|
||||
private async Task<WeightedPlan> BuildWeightedPlan(
|
||||
AutoTuneChannelSelection selection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<AutoTuneSourceWeight> sources = selection.Sources ?? [];
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enumerate the axis's distinct base sources (parent shows for TV, movies for the movie axis) exactly
|
||||
// as the DetailPanel members list does, so weight resolution matches what the user saw.
|
||||
PagedLibraryBrowseItemsResponseModel members = await mediator.Send(
|
||||
new GetAutoTuneChannelMembers(selection.Axis, selection.Value, 0, MaxSources),
|
||||
cancellationToken);
|
||||
|
||||
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
|
||||
|
||||
// Any override touching a non-default weight, an exclusion, or an id outside the base set means the
|
||||
// channel really is customized; otherwise the plan would be identical to fair-share.
|
||||
bool customized = sources.Any(s => s.Weight != 1 || s.Excluded || !baseIds.Contains(s.SourceId));
|
||||
if (!customized)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<int, AutoTuneSourceWeight> overridesById = sources
|
||||
.GroupBy(s => s.SourceId)
|
||||
.ToDictionary(g => g.Key, g => g.Last());
|
||||
|
||||
return selection.Axis switch
|
||||
{
|
||||
AutoTuneAxis.MovieGenre => BuildMoviePlan(selection, members, overridesById),
|
||||
_ => await BuildTvPlan(selection, members, overridesById, cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
// TV: every base show becomes its own weighted SmartCollection (discriminator-only `show_title`) so
|
||||
// un-weighted shows keep per-show fair-share — a single merged remainder would regress them to
|
||||
// item-proportional (a 200-episode show would swamp a 20-episode one). The remainder is the live
|
||||
// catch-all for shows/episodes added after tune-in, at weight 1.
|
||||
private async Task<WeightedPlan> BuildTvPlan(
|
||||
AutoTuneChannelSelection selection,
|
||||
PagedLibraryBrowseItemsResponseModel members,
|
||||
Dictionary<int, AutoTuneSourceWeight> overridesById,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var weightedMembers = new List<WeightedMember>();
|
||||
var subtracted = new List<string>();
|
||||
|
||||
// Base shows (title is the discriminator; the members list already carries it).
|
||||
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
|
||||
foreach (LibraryBrowseItemResponseModel item in members.Page)
|
||||
{
|
||||
AutoTuneSourceWeight ov = overridesById.GetValueOrDefault(item.Id);
|
||||
if (ov is { Excluded: true })
|
||||
{
|
||||
subtracted.Add(item.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
weightedMembers.Add(new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, item.Title),
|
||||
NormalizeWeight(ov?.Weight ?? 1)));
|
||||
subtracted.Add(item.Title);
|
||||
}
|
||||
|
||||
// Added (out-of-axis) shows: resolve the title from metadata since the members list won't include them.
|
||||
List<int> addedIds = overridesById.Keys.Where(id => !baseIds.Contains(id)).ToList();
|
||||
if (addedIds.Count > 0)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Dictionary<int, string> titles = (await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => addedIds.Contains(sm.ShowId))
|
||||
.Select(sm => new { sm.ShowId, sm.Title })
|
||||
.ToListAsync(cancellationToken))
|
||||
.GroupBy(x => x.ShowId)
|
||||
.ToDictionary(g => g.Key, g => g.First().Title);
|
||||
|
||||
foreach (int id in addedIds)
|
||||
{
|
||||
AutoTuneSourceWeight ov = overridesById[id];
|
||||
if (ov.Excluded || !titles.TryGetValue(id, out string title) || string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
weightedMembers.Add(new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, title),
|
||||
NormalizeWeight(ov.Weight)));
|
||||
subtracted.Add(title);
|
||||
}
|
||||
}
|
||||
|
||||
var remainder = new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted),
|
||||
1);
|
||||
|
||||
return new WeightedPlan(weightedMembers, remainder);
|
||||
}
|
||||
|
||||
// Movies: materialize only the touched movies (a non-default weight, or an added out-of-axis movie) as
|
||||
// individual `id:{n}` SmartCollections; every un-touched base movie stays in ONE remainder whose weight is
|
||||
// its member count. Because the fake-collection path already pools all movies uniformly, a count-weighted
|
||||
// remainder is exactly equivalent to materializing each movie individually — without hundreds of rows.
|
||||
private static WeightedPlan BuildMoviePlan(
|
||||
AutoTuneChannelSelection selection,
|
||||
PagedLibraryBrowseItemsResponseModel members,
|
||||
Dictionary<int, AutoTuneSourceWeight> overridesById)
|
||||
{
|
||||
var weightedMembers = new List<WeightedMember>();
|
||||
var subtracted = new List<string>();
|
||||
|
||||
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
|
||||
var subtractedBase = 0;
|
||||
|
||||
foreach ((int id, AutoTuneSourceWeight ov) in overridesById)
|
||||
{
|
||||
bool inBase = baseIds.Contains(id);
|
||||
string idClause = id.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
if (ov.Excluded)
|
||||
{
|
||||
subtracted.Add(idClause);
|
||||
if (inBase)
|
||||
{
|
||||
subtractedBase++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Materialize weighted base movies and every added (out-of-axis) movie; a base movie left at
|
||||
// weight 1 is cheaper to leave in the remainder (same airtime either way).
|
||||
if (ov.Weight != 1 || !inBase)
|
||||
{
|
||||
weightedMembers.Add(new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, idClause),
|
||||
NormalizeWeight(ov.Weight)));
|
||||
subtracted.Add(idClause);
|
||||
if (inBase)
|
||||
{
|
||||
subtractedBase++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remainder weight = the un-touched base movie count, so a weighted movie airs N× *each* remainder
|
||||
// movie (the fake path already pools movies uniformly, so this is equivalent to materializing each).
|
||||
// Clamped to MultiCollectionItemWeight.Maximum (1000): a genre with >1000 un-touched movies can't
|
||||
// express the exact ratio (the weighted movie then airs slightly more than intended) — the same
|
||||
// 1..1000 bound #70's weight column imposes everywhere. Realistic only at very large scale.
|
||||
int remainderCount = baseIds.Count - subtractedBase;
|
||||
var remainder = new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted),
|
||||
NormalizeWeight(remainderCount));
|
||||
|
||||
return new WeightedPlan(weightedMembers, remainder);
|
||||
}
|
||||
|
||||
private static int NormalizeWeight(int weight) =>
|
||||
Math.Clamp(weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum);
|
||||
|
||||
private async Task<AutoTuneChannelOutcome> CreateWeightedChannel(
|
||||
int effectiveTemplateId,
|
||||
string group,
|
||||
string name,
|
||||
ArtworkContentTypeModel logo,
|
||||
AutoTuneChannelSelection selection,
|
||||
WeightedPlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// WeightedShuffle is the whole point; it overrides any axis default / caller Advanced.PlaybackOrder.
|
||||
CreateChannelFromLineupAdvancedOptions advanced =
|
||||
(selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with
|
||||
{
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle
|
||||
};
|
||||
|
||||
// Short unique token: the channel id isn't known until CreateChannelFromLineup runs, and both
|
||||
// SmartCollection.Name and MultiCollection.Name are unique varchar(50).
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
int multiCollectionId;
|
||||
List<int> smartCollectionIds;
|
||||
await using (TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken))
|
||||
{
|
||||
var multiCollection = new MultiCollection
|
||||
{
|
||||
Name = $"at-mc:{token}",
|
||||
MultiCollectionItems = [],
|
||||
MultiCollectionSmartItems = []
|
||||
};
|
||||
|
||||
var index = 0;
|
||||
foreach (WeightedMember member in plan.Members.Append(plan.Remainder))
|
||||
{
|
||||
var smartCollection = new SmartCollection
|
||||
{
|
||||
Name = index == plan.Members.Count ? $"at:{token}:rem" : $"at:{token}:{index}",
|
||||
Query = member.Query
|
||||
};
|
||||
|
||||
dbContext.SmartCollections.Add(smartCollection);
|
||||
multiCollection.MultiCollectionSmartItems.Add(new MultiCollectionSmartItem
|
||||
{
|
||||
MultiCollection = multiCollection,
|
||||
SmartCollection = smartCollection,
|
||||
ScheduleAsGroup = false,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
Weight = member.Weight
|
||||
});
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
dbContext.MultiCollections.Add(multiCollection);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AutoTuneChannelOutcome(
|
||||
name, AutoTuneOutcomeStatus.Failed, null, $"Weighted collections: {ex.Message}");
|
||||
}
|
||||
|
||||
multiCollectionId = multiCollection.Id;
|
||||
smartCollectionIds = multiCollection.MultiCollectionSmartItems
|
||||
.Select(i => i.SmartCollectionId)
|
||||
.ToList();
|
||||
|
||||
// New smart collections became visible; refresh targets + cache like CreateSmartCollectionHandler
|
||||
// (post-commit, CancellationToken.None so a late cancel can't abort it after the commit landed).
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await smartCollectionCache.Refresh(CancellationToken.None);
|
||||
}
|
||||
|
||||
var command = new CreateChannelFromLineup(
|
||||
name,
|
||||
selection.Number,
|
||||
group,
|
||||
string.Empty,
|
||||
logo,
|
||||
IsEnabled: true,
|
||||
ShowInEpg: true,
|
||||
effectiveTemplateId,
|
||||
advanced,
|
||||
[
|
||||
new CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType.MultiCollection,
|
||||
CollectionType.MultiCollection,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: multiCollectionId,
|
||||
SmartCollectionId: null,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null)
|
||||
]);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
foreach (BaseError error in channelResult.LeftToSeq())
|
||||
{
|
||||
// Roll back the multi collection + its member smart collections so a retry doesn't collide on
|
||||
// name uniqueness. Best-effort; the outcome below stands regardless of the cleanup result.
|
||||
await TryDeleteOwnedArtifacts(multiCollectionId, smartCollectionIds, cancellationToken);
|
||||
|
||||
AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal)
|
||||
? AutoTuneOutcomeStatus.Skipped
|
||||
: AutoTuneOutcomeStatus.Failed;
|
||||
return new AutoTuneChannelOutcome(name, status, null, error.Value);
|
||||
}
|
||||
|
||||
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
|
||||
|
||||
// Stamp ownership so the artifacts are hidden from user collection lists and cleaned up on channel
|
||||
// delete. Best-effort: an unstamped artifact is a cosmetic/cleanup issue, never a failed channel.
|
||||
await TryStampOwnership(multiCollectionId, smartCollectionIds, channelId);
|
||||
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
|
||||
}
|
||||
|
||||
private async Task TryStampOwnership(
|
||||
int multiCollectionId,
|
||||
List<int> smartCollectionIds,
|
||||
int channelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Post-commit side effect: runs on CancellationToken.None so a late request cancellation can't
|
||||
// abort it after the channel-create commit landed (#254) — an un-stamped artifact would be a
|
||||
// permanent orphan (never cleaned on delete, and visible in the user collection lists). The MC +
|
||||
// its member smart collections are stamped in one transaction so a mid-way failure can't leave the
|
||||
// MC owned while the smart collections stay orphaned.
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(CancellationToken.None);
|
||||
await dbContext.MultiCollections
|
||||
.Where(mc => mc.Id == multiCollectionId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(mc => mc.OwnedByChannelId, channelId), CancellationToken.None);
|
||||
await dbContext.SmartCollections
|
||||
.Where(sc => smartCollectionIds.Contains(sc.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(sc => sc.OwnedByChannelId, channelId), CancellationToken.None);
|
||||
await transaction.CommitAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// intentionally ignored; see call site
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryDeleteOwnedArtifacts(
|
||||
int multiCollectionId,
|
||||
List<int> smartCollectionIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await dbContext.MultiCollections
|
||||
.Where(mc => mc.Id == multiCollectionId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.SmartCollections
|
||||
.Where(sc => smartCollectionIds.Contains(sc.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await smartCollectionCache.Refresh(CancellationToken.None);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// intentionally ignored; see call site
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -6,6 +6,29 @@ namespace ErsatzTV.Application.Channels;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
/// <summary>
|
||||
/// A mirror channel has no playouts of its own; it relays the playouts of its mirror source, so both must be
|
||||
/// counted for the total to answer "can this channel play anything?". Requires <see cref="Channel.Playouts" />
|
||||
/// and, for mirrors, <see cref="Channel.MirrorSourceChannel" />.<see cref="Channel.Playouts" /> to be included
|
||||
/// by the query — the repository reads are AsNoTracking, so an un-included navigation silently counts zero.
|
||||
/// </summary>
|
||||
internal static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
var result = 0;
|
||||
|
||||
if (channel.Playouts != null)
|
||||
{
|
||||
result += channel.Playouts.Count;
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
||||
{
|
||||
result += channel.MirrorSourceChannel.Playouts.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
@@ -73,7 +96,7 @@ internal static class Mapper
|
||||
channel.ShowInEpg);
|
||||
}
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
@@ -85,7 +108,8 @@ internal static class Mapper
|
||||
channel.PreferredAudioLanguageCode,
|
||||
GetStreamingMode(channel),
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
channel.ShowInEpg,
|
||||
playoutCount);
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
@@ -13,6 +13,6 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(ProjectToResponseModel).ToList();
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
@@ -11,21 +11,4 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
|
||||
await channelRepository.GetAll(cancellationToken)
|
||||
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
|
||||
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
|
||||
|
||||
private static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
var result = 0;
|
||||
|
||||
if (channel.Playouts != null)
|
||||
{
|
||||
result += channel.Playouts.Count;
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
||||
{
|
||||
result += channel.MirrorSourceChannel.Playouts.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
|
||||
GetChannelByIdForApi request,
|
||||
CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0));
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel)));
|
||||
}
|
||||
|
||||
@@ -60,7 +60,10 @@ public partial class GetChannelGuideHandler(
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
// The token value is HTTP-request-derived and interpolated raw into the pre-built XMLTV
|
||||
// cache fragments, so it must be XML-escaped like {RequestBase} above — a token containing
|
||||
// '&', '<', '>', or '"' would otherwise malform the whole guide. Opaque tokens are a no-op.
|
||||
accessTokenUri += $"&access_token={SecurityElement.Escape(request.AccessToken)}";
|
||||
}
|
||||
|
||||
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
@@ -70,6 +71,32 @@ public static class ConcurrencyExtensions
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Like <see cref="SaveChangesForcingVersion" />, but additionally treats a unique / primary-key
|
||||
/// constraint violation as an idempotent no-op: returns <c>false</c> instead of throwing when the
|
||||
/// save fails because a concurrent request inserted a row we had membership-checked absent (the
|
||||
/// composite-PK race on <c>CollectionItem</c> — issue #308). A <c>false</c> means "the desired row
|
||||
/// already exists because a racing writer won; the winner ran the ETag rotation + fan-out, so skip
|
||||
/// ours." <c>true</c> means our own change committed. Every other <see cref="DbUpdateException" />
|
||||
/// (and the genuine deleted-row concurrency conflict rethrown by <see cref="SaveChangesForcingVersion" />)
|
||||
/// still propagates. The only insert these callers stage is the <c>CollectionItem</c> join row, so the
|
||||
/// sole unique/PK constraint that can fire here is that composite key.
|
||||
/// </summary>
|
||||
public static async Task<bool> TrySaveChangesForcingVersion(
|
||||
this DbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesForcingVersion(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persist pending changes, mapping the EF optimistic-concurrency failure to
|
||||
/// <see cref="PreconditionFailedError" /> (→ 412). When a versioned root carries an
|
||||
|
||||
@@ -26,4 +26,10 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>ErsatzTV.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -10,7 +10,11 @@ internal static class Mapper
|
||||
result.Title,
|
||||
GetStatus(result.Status),
|
||||
result.Message,
|
||||
result.Link.MatchUnsafe(l => l.Link, () => null));
|
||||
string.IsNullOrWhiteSpace(result.BriefMessage) ? null : result.BriefMessage,
|
||||
result.Link.MatchUnsafe(l => l.Target, () => (string)null),
|
||||
result.Link.MatchUnsafe(
|
||||
l => new HealthCheckRemediationResponseModel(GetLinkKind(l.Kind), l.Target),
|
||||
() => (HealthCheckRemediationResponseModel)null));
|
||||
|
||||
private static string GetStatus(HealthCheckStatus status) =>
|
||||
status switch
|
||||
@@ -19,6 +23,17 @@ internal static class Mapper
|
||||
HealthCheckStatus.Fail => "fail",
|
||||
HealthCheckStatus.Warning => "warn",
|
||||
HealthCheckStatus.Info => "info",
|
||||
// NotApplicable is filtered out before mapping today; map it defensively rather
|
||||
// than throwing, so a future caller that skips the filter can't 500 the endpoint.
|
||||
HealthCheckStatus.NotApplicable => "notApplicable",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
||||
};
|
||||
|
||||
private static string GetLinkKind(HealthCheckLinkKind kind) =>
|
||||
kind switch
|
||||
{
|
||||
HealthCheckLinkKind.ExternalDoc => "ExternalDoc",
|
||||
HealthCheckLinkKind.AppRoute => "AppRoute",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,7 +55,13 @@ public class AddArtistToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -57,7 +57,13 @@ public class AddEpisodeToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -54,7 +54,13 @@ public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection,
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -38,23 +38,52 @@ public class AddItemsToCollectionHandler :
|
||||
_searchChannel = searchChannel;
|
||||
}
|
||||
|
||||
// A duplicate-key race can roll back the whole batch (#308); recompute membership from a fresh
|
||||
// context and retry with only the still-missing items. Bounded to avoid a livelock — the common
|
||||
// no-collision path runs the loop body exactly once.
|
||||
private const int MaxDuplicateRetries = 5;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddItemsToCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: async collection =>
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
|
||||
// true = terminal (nothing to add, or our batch committed); false = a duplicate-key race
|
||||
// rolled the batch back, recompute membership and retry.
|
||||
Either<BaseError, bool> attemptResult = await maybeCollection.Match(
|
||||
Some: async collection =>
|
||||
{
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, bool>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
|
||||
if (attemptResult.IsLeft)
|
||||
{
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
return attemptResult.Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
bool committed = attemptResult.Match(Left: _ => false, Right: done => done);
|
||||
if (committed)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// A concurrent add inserted one+ of our items first; recompute against fresh membership.
|
||||
if (attempt >= MaxDuplicateRetries)
|
||||
{
|
||||
return BaseError.New(
|
||||
"Concurrent modification while adding items to the collection; please retry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddItemsRequest(
|
||||
private async Task<bool> ApplyAddItemsRequest(
|
||||
TvContext dbContext,
|
||||
Collection collection,
|
||||
AddItemsToCollection request,
|
||||
@@ -75,10 +104,10 @@ public class AddItemsToCollectionHandler :
|
||||
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
|
||||
|
||||
// No-op when every requested item is already a member: don't rotate the ETag or fan out
|
||||
// rebuilds for an idempotent re-add — #269.
|
||||
// rebuilds for an idempotent re-add — #269. Terminal success (no retry).
|
||||
if (toAddIds.Count == 0)
|
||||
{
|
||||
return Unit.Default;
|
||||
return true;
|
||||
}
|
||||
|
||||
List<MediaItem> toAdd = await dbContext.MediaItems
|
||||
@@ -91,7 +120,15 @@ public class AddItemsToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a).
|
||||
collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(cancellationToken);
|
||||
|
||||
// A concurrent add of an overlapping item won the composite-PK race and rolled back this whole
|
||||
// batch. Unlike the single-item handlers (idempotent no-op), a bulk add must NOT drop the items
|
||||
// that did NOT collide — signal the caller to recompute membership and retry the still-missing
|
||||
// ones. #308
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(cancellationToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
@@ -104,7 +141,7 @@ public class AddItemsToCollectionHandler :
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Collection>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,7 +55,13 @@ public class AddMediaItemToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,7 +55,13 @@ public class AddMovieToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -57,7 +57,13 @@ public class AddMusicVideoToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -57,7 +57,13 @@ public class AddOtherVideoToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,7 +55,13 @@ public class AddSeasonToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,7 +55,13 @@ public class AddShowToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,7 +55,13 @@ public class AddSongToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
@@ -7,7 +7,8 @@ public record CreateMultiCollectionItem(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
public record CreateMultiCollection(string Name, List<CreateMultiCollectionItem> Items)
|
||||
: IRequest<Either<BaseError, MultiCollectionViewModel>>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -51,42 +51,56 @@ public class CreateMultiCollectionHandler :
|
||||
private static Task<Validation<BaseError, MultiCollection>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateMultiCollection request) =>
|
||||
ValidateName(dbContext, request).MapT(name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Bind(i =>
|
||||
{
|
||||
if (i.CollectionId.HasValue)
|
||||
ValidateName(dbContext, request)
|
||||
.BindT(name => ValidateWeights(request).Map(_ => name))
|
||||
.MapT(name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Bind(i =>
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = i.CollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
});
|
||||
}
|
||||
if (i.CollectionId.HasValue)
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = i.CollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
});
|
||||
}
|
||||
|
||||
return Option<MultiCollectionItem>.None;
|
||||
})
|
||||
return Option<MultiCollectionItem>.None;
|
||||
})
|
||||
.ToList(),
|
||||
MultiCollectionSmartItems = request.Items.Bind(i =>
|
||||
{
|
||||
if (i.SmartCollectionId.HasValue)
|
||||
MultiCollectionSmartItems = request.Items.Bind(i =>
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionSmartItem
|
||||
{
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
});
|
||||
}
|
||||
if (i.SmartCollectionId.HasValue)
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionSmartItem
|
||||
{
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
});
|
||||
}
|
||||
|
||||
return Option<MultiCollectionSmartItem>.None;
|
||||
})
|
||||
return Option<MultiCollectionSmartItem>.None;
|
||||
})
|
||||
.ToList()
|
||||
});
|
||||
});
|
||||
|
||||
// Bounds are shared with the update path so the two cannot drift -- they silently disagreed before #402:
|
||||
// EF's HasDefaultValue substitutes 1 for a 0 on INSERT (0 reads as "not set") while an UPDATE writes the 0
|
||||
// through, so the same input landed differently depending on the verb. The enumerator clamps out-of-range
|
||||
// weights, so neither a 0 nor a huge value can reach the rotation; this gate refuses input that has no
|
||||
// meaning on a share-of-airtime scale, and keeps create and update honest with each other. See #70.
|
||||
private static Validation<BaseError, Unit> ValidateWeights(CreateMultiCollection request) =>
|
||||
request.Items.All(i => MultiCollectionItemWeight.IsValid(i.Weight))
|
||||
? Unit.Default
|
||||
: BaseError.New(MultiCollectionItemWeight.ValidationMessage);
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -73,7 +73,30 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
ReplacePlaylistItems request,
|
||||
CancellationToken cancellationToken) =>
|
||||
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist));
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
|
||||
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
|
||||
|
||||
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
|
||||
ReplacePlaylistItems request,
|
||||
Playlist playlist) =>
|
||||
request.Items
|
||||
.Map(item => PlaybackOrderMustBeSupported(item.PlaybackOrder))
|
||||
.Sequence()
|
||||
.Map(_ => playlist);
|
||||
|
||||
private static Validation<BaseError, Unit> PlaybackOrderMustBeSupported(PlaybackOrder playbackOrder)
|
||||
{
|
||||
// WeightedShuffle (#70) is implemented for classic schedule items only. PlaylistEnumerator has no
|
||||
// default arm, so an order it doesn't know leaves the enumerator null and the item is dropped from the
|
||||
// playlist silently -- refuse it at the write path instead of scheduling nothing at build time.
|
||||
if (playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for playlist items; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -7,7 +7,8 @@ public record UpdateMultiCollectionItem(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
public record UpdateMultiCollection(
|
||||
int MultiCollectionId,
|
||||
|
||||
@@ -76,7 +76,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
CollectionId = i.CollectionId.Value,
|
||||
MultiCollectionId = c.Id,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
})
|
||||
.ToList();
|
||||
var toRemove = c.MultiCollectionItems
|
||||
@@ -94,6 +95,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
{
|
||||
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
|
||||
item.PlaybackOrder = incoming.PlaybackOrder;
|
||||
item.Weight = incoming.Weight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +112,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
MultiCollectionId = c.Id,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
})
|
||||
.ToList();
|
||||
var toRemoveSmart = c.MultiCollectionSmartItems
|
||||
@@ -128,6 +131,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
{
|
||||
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
|
||||
item.PlaybackOrder = incoming.PlaybackOrder;
|
||||
item.Weight = incoming.Weight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,8 +160,20 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
TvContext dbContext,
|
||||
UpdateMultiCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await MultiCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
(await MultiCollectionMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
ValidateWeights(request))
|
||||
.Apply((collectionToUpdate, _, _) => collectionToUpdate);
|
||||
|
||||
// Bounds are shared with the create path so the two cannot drift -- they silently disagreed before #402:
|
||||
// EF's HasDefaultValue substitutes 1 for a 0 on INSERT (0 reads as "not set"), but an UPDATE writes the 0
|
||||
// through, so the same input landed differently depending on the verb. The enumerator clamps out-of-range
|
||||
// weights, so a 0 no longer removes the source; this gate is about refusing input that has no meaning on a
|
||||
// share-of-airtime scale, and about keeping create and update honest with each other. See #70.
|
||||
private static Validation<BaseError, Unit> ValidateWeights(UpdateMultiCollection request) =>
|
||||
request.Items.All(i => MultiCollectionItemWeight.IsValid(i.Weight))
|
||||
? Unit.Default
|
||||
: BaseError.New(MultiCollectionItemWeight.ValidationMessage);
|
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -70,7 +70,8 @@ internal static class Mapper
|
||||
multiCollectionItem.MultiCollectionId,
|
||||
ProjectToViewModel(multiCollectionItem.Collection),
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder);
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Weight);
|
||||
|
||||
private static MultiCollectionSmartItemViewModel ProjectToViewModel(
|
||||
MultiCollectionSmartItem multiCollectionSmartItem) =>
|
||||
@@ -78,7 +79,8 @@ internal static class Mapper
|
||||
multiCollectionSmartItem.MultiCollectionId,
|
||||
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
|
||||
multiCollectionSmartItem.ScheduleAsGroup,
|
||||
multiCollectionSmartItem.PlaybackOrder);
|
||||
multiCollectionSmartItem.PlaybackOrder,
|
||||
multiCollectionSmartItem.Weight);
|
||||
|
||||
internal static TreeViewModel ProjectToViewModel(List<PlaylistGroup> playlistGroups) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
@@ -6,4 +6,5 @@ public record MultiCollectionItemViewModel(
|
||||
int MultiCollectionId,
|
||||
MediaCollectionViewModel Collection,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
@@ -6,4 +6,5 @@ public record MultiCollectionSmartItemViewModel(
|
||||
int MultiCollectionId,
|
||||
SmartCollectionViewModel SmartCollection,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
@@ -17,6 +17,7 @@ public class GetAllMultiCollectionsHandler : IRequestHandler<GetAllMultiCollecti
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.MultiCollections
|
||||
.Where(mc => mc.OwnedByChannelId == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,6 +16,7 @@ public class GetAllSmartCollectionsForApiHandler(IDbContextFactory<TvContext> db
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<SmartCollection> ffmpegProfiles = await dbContext.SmartCollections
|
||||
.AsNoTracking()
|
||||
.Where(sc => sc.OwnedByChannelId == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
@@ -17,6 +17,7 @@ public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollecti
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.SmartCollections
|
||||
.Where(sc => sc.OwnedByChannelId == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
@@ -13,9 +13,12 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int count = await dbContext.MultiCollections.CountAsync(cancellationToken);
|
||||
int count = await dbContext.MultiCollections
|
||||
.CountAsync(mc => mc.OwnedByChannelId == null, cancellationToken);
|
||||
|
||||
IQueryable<MultiCollection> query = dbContext.MultiCollections.AsNoTracking();
|
||||
IQueryable<MultiCollection> query = dbContext.MultiCollections
|
||||
.AsNoTracking()
|
||||
.Where(mc => mc.OwnedByChannelId == null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
@@ -13,9 +13,12 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
int count = await dbContext.SmartCollections.CountAsync(cancellationToken);
|
||||
int count = await dbContext.SmartCollections
|
||||
.CountAsync(sc => sc.OwnedByChannelId == null, cancellationToken);
|
||||
|
||||
IQueryable<SmartCollection> query = dbContext.SmartCollections.AsNoTracking();
|
||||
IQueryable<SmartCollection> query = dbContext.SmartCollections
|
||||
.AsNoTracking()
|
||||
.Where(sc => sc.OwnedByChannelId == null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -63,6 +63,9 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
||||
case PlaybackOrder.Shuffle:
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
// WeightedShuffle (#70) distributes across a multi collection's sources, so this is its
|
||||
// intended home. Listed explicitly rather than falling through the switch by omission.
|
||||
case PlaybackOrder.WeightedShuffle:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,30 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
BlockMustExist(dbContext, request.BlockId, cancellationToken)
|
||||
.BindT(block => MinutesMustBeValid(request, block))
|
||||
.BindT(block => BlockNameMustBeValid(dbContext, block, request))
|
||||
.BindT(block => CollectionTypesMustBeValid(request, block));
|
||||
.BindT(block => CollectionTypesMustBeValid(request, block))
|
||||
.BindT(block => PlaybackOrdersMustBeSupported(request, block));
|
||||
|
||||
private static Validation<BaseError, Block> PlaybackOrdersMustBeSupported(
|
||||
ReplaceBlockItems request,
|
||||
Block block) =>
|
||||
request.Items
|
||||
.Map(item => PlaybackOrderMustBeSupported(item.PlaybackOrder))
|
||||
.Sequence()
|
||||
.Map(_ => block);
|
||||
|
||||
private static Validation<BaseError, Unit> PlaybackOrderMustBeSupported(PlaybackOrder playbackOrder)
|
||||
{
|
||||
// WeightedShuffle (#70) is implemented for classic schedule items only. BlockPlayoutBuilder filters
|
||||
// block items against an allow-list of orders and silently `continue`s past anything else, so an
|
||||
// unsupported order here means the block item never airs and nothing reports why.
|
||||
if (playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for block items; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Block>> BlockMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries;
|
||||
|
||||
public record GetSearchFieldCatalog : IRequest<List<SearchFieldResponseModel>>;
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries;
|
||||
|
||||
public class GetSearchFieldCatalogHandler : IRequestHandler<GetSearchFieldCatalog, List<SearchFieldResponseModel>>
|
||||
{
|
||||
public Task<List<SearchFieldResponseModel>> Handle(
|
||||
GetSearchFieldCatalog request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(SearchFieldCatalog.Fields);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record QuerySearchIndexAllItems(string Query) : IRequest<SearchResultAllItemsViewModel>;
|
||||
public record QuerySearchIndexAllItems(string Query, int PageNum, int PageSize)
|
||||
: IRequest<SearchResultAllItemsViewModel>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
@@ -8,21 +9,69 @@ public class QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex)
|
||||
{
|
||||
public async Task<SearchResultAllItemsViewModel> Handle(
|
||||
QuerySearchIndexAllItems request,
|
||||
CancellationToken cancellationToken) =>
|
||||
new(
|
||||
await GetIds(LuceneSearchIndex.MovieType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.ShowType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.SeasonType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.EpisodeType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.ArtistType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.OtherVideoType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.SongType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.ImageType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, cancellationToken));
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int skip = request.PageNum * request.PageSize;
|
||||
int limit = request.PageSize;
|
||||
|
||||
private async Task<List<int>> GetIds(string type, string query, CancellationToken cancellationToken) =>
|
||||
(await searchIndex.Search($"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
(List<int> Ids, int Total) movies =
|
||||
await GetIds(LuceneSearchIndex.MovieType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) shows =
|
||||
await GetIds(LuceneSearchIndex.ShowType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) seasons =
|
||||
await GetIds(LuceneSearchIndex.SeasonType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) episodes =
|
||||
await GetIds(LuceneSearchIndex.EpisodeType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) artists =
|
||||
await GetIds(LuceneSearchIndex.ArtistType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) musicVideos =
|
||||
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) otherVideos =
|
||||
await GetIds(LuceneSearchIndex.OtherVideoType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) songs =
|
||||
await GetIds(LuceneSearchIndex.SongType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) images =
|
||||
await GetIds(LuceneSearchIndex.ImageType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) remoteStreams =
|
||||
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, skip, limit, cancellationToken);
|
||||
|
||||
return new SearchResultAllItemsViewModel(
|
||||
movies.Ids,
|
||||
shows.Ids,
|
||||
seasons.Ids,
|
||||
episodes.Ids,
|
||||
artists.Ids,
|
||||
musicVideos.Ids,
|
||||
otherVideos.Ids,
|
||||
songs.Ids,
|
||||
images.Ids,
|
||||
remoteStreams.Ids,
|
||||
new SearchResultAllItemsTotals(
|
||||
movies.Total,
|
||||
shows.Total,
|
||||
seasons.Total,
|
||||
episodes.Total,
|
||||
artists.Total,
|
||||
musicVideos.Total,
|
||||
otherVideos.Total,
|
||||
songs.Total,
|
||||
images.Total,
|
||||
remoteStreams.Total));
|
||||
}
|
||||
|
||||
private async Task<(List<int> Ids, int Total)> GetIds(
|
||||
string type,
|
||||
string query,
|
||||
int skip,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult result = await searchIndex.Search(
|
||||
$"type:{type} AND ({query})",
|
||||
string.Empty,
|
||||
skip,
|
||||
limit,
|
||||
cancellationToken);
|
||||
return (result.Items.Map(i => i.Id).ToList(), result.TotalCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ public class SearchMultiCollectionsHandler(IDbContextFactory<TvContext> dbContex
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.MultiCollections
|
||||
.AsNoTracking()
|
||||
// Hide system-owned auto-tune weighted artifacts (#425) from the scheduling picker: selecting one
|
||||
// into a user schedule would let a later channel delete cascade away that schedule item.
|
||||
.Where(mc => mc.OwnedByChannelId == null)
|
||||
.Where(mc => EF.Functions.Like(mc.Name, $"%{request.Query}%"))
|
||||
.OrderBy(mc => mc.Name)
|
||||
.Take(10)
|
||||
|
||||
@@ -15,6 +15,9 @@ public class SearchSmartCollectionsHandler(IDbContextFactory<TvContext> dbContex
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.SmartCollections
|
||||
.AsNoTracking()
|
||||
// Hide system-owned auto-tune weighted artifacts (#425) from the scheduling picker: selecting one
|
||||
// into a user schedule would let a later channel delete cascade away that schedule item.
|
||||
.Where(sc => sc.OwnedByChannelId == null)
|
||||
.Where(sc => EF.Functions.Like(sc.Name, $"%{request.Query}%"))
|
||||
.OrderBy(sc => sc.Name)
|
||||
.Take(10)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public static class SearchFieldCatalog
|
||||
{
|
||||
private static readonly string[] None = [];
|
||||
|
||||
// Allowed values for the `type` enum — mirrors the lowercase tokens the Lucene index stores for
|
||||
// the `type` field (see LuceneSearchIndex.TypeField and its *Type constants).
|
||||
private static readonly string[] ItemTypes =
|
||||
[
|
||||
"movie", "show", "season", "episode", "artist", "music_video", "other_video", "song", "image",
|
||||
"remote_stream"
|
||||
];
|
||||
|
||||
public static readonly List<SearchFieldResponseModel> Fields =
|
||||
[
|
||||
// General
|
||||
new("title", "Title", "text", "General", None),
|
||||
new("genre", "Genre", "text", "General", None),
|
||||
new("tag", "Tag", "text", "General", None),
|
||||
new("plot", "Plot", "fulltext", "General", None),
|
||||
new("content_rating", "Content rating", "text", "General", None),
|
||||
new("studio", "Studio", "text", "General", None),
|
||||
new("collection", "Collection", "text", "General", None),
|
||||
new("state", "State", "text", "General", None),
|
||||
new("type", "Item type", "enum", "General", ItemTypes),
|
||||
|
||||
// TV
|
||||
new("network", "Network", "text", "TV", None),
|
||||
new("show_title", "Show title", "text", "TV", None),
|
||||
new("show_genre", "Show genre", "text", "TV", None),
|
||||
new("season_number", "Season number", "number", "TV", None),
|
||||
new("episode_number", "Episode number", "number", "TV", None),
|
||||
|
||||
// Movie / People
|
||||
new("director", "Director", "text", "Movie", None),
|
||||
new("writer", "Writer", "text", "Movie", None),
|
||||
new("actor", "Actor", "text", "Movie", None),
|
||||
|
||||
// Music
|
||||
new("artist", "Artist", "text", "Music", None),
|
||||
new("album", "Album", "text", "Music", None),
|
||||
new("album_artist", "Album artist", "text", "Music", None),
|
||||
|
||||
// Technical
|
||||
new("minutes", "Duration (min)", "number", "Technical", None),
|
||||
new("height", "Height (px)", "number", "Technical", None),
|
||||
new("width", "Width (px)", "number", "Technical", None),
|
||||
new("video_codec", "Video codec", "text", "Technical", None),
|
||||
new("video_dynamic_range", "Dynamic range", "text", "Technical", None),
|
||||
|
||||
// Dates
|
||||
new("added_date", "Date added", "date", "Dates", None),
|
||||
new("release_date", "Release date", "date", "Dates", None)
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchResultAllItemsViewModel(
|
||||
List<int> MovieIds,
|
||||
@@ -10,4 +10,17 @@ public record SearchResultAllItemsViewModel(
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds,
|
||||
List<int> RemoteStreamIds);
|
||||
List<int> RemoteStreamIds,
|
||||
SearchResultAllItemsTotals Totals);
|
||||
|
||||
public record SearchResultAllItemsTotals(
|
||||
int MovieCount,
|
||||
int ShowCount,
|
||||
int SeasonCount,
|
||||
int EpisodeCount,
|
||||
int ArtistCount,
|
||||
int MusicVideoCount,
|
||||
int OtherVideoCount,
|
||||
int SongCount,
|
||||
int ImageCount,
|
||||
int RemoteStreamCount);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Graphics;
|
||||
@@ -75,6 +76,10 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
|
||||
private async Task<Unit> StartProcess(StartFFmpegSession request, CancellationToken cancellationToken)
|
||||
{
|
||||
// measures the full client-visible cold-start: this handler only runs when the session
|
||||
// is not already active, so its whole duration is the tune-in delay the client waits on
|
||||
var coldStartStopwatch = Stopwatch.StartNew();
|
||||
|
||||
Option<TimeSpan> idleTimeout = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken)
|
||||
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
|
||||
@@ -114,7 +119,34 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken)
|
||||
.Map(maybeCount => maybeCount.Match(identity, () => 1));
|
||||
|
||||
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
|
||||
PlaylistSegmentsResult segments = await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
|
||||
coldStartStopwatch.Stop();
|
||||
|
||||
// #350 cold-start instrumentation: one self-describing sample per tune-in so the real
|
||||
// driver split (process startup vs segment fill, and which heavy features were active)
|
||||
// can be measured on prod before any transcode-pipeline optimization.
|
||||
// "setup" is the pre-wait handler overhead (config reads + framerate/channel/graphics
|
||||
// mediator sends + worker spawn) so total = setup + startup + fill accounts for every ms.
|
||||
long totalMs = (long)coldStartStopwatch.Elapsed.TotalMilliseconds;
|
||||
long startupMs = (long)segments.ProcessStartup.TotalMilliseconds;
|
||||
long fillMs = (long)segments.SegmentFill.TotalMilliseconds;
|
||||
long setupMs = Math.Max(0, totalMs - startupMs - fillMs);
|
||||
_logger.LogInformation(
|
||||
"HLS cold-start channel {Channel} mode {Mode}: total {TotalMs}ms " +
|
||||
"(setup {SetupMs}ms + startup {ProcessStartupMs}ms + fill {SegmentFillMs}ms), " +
|
||||
"segments {SegmentsReached}/{InitialSegmentCount}, " +
|
||||
"deadlineExpired {DeadlineExpired}, subtitleBurnIn {SubtitleBurnIn}, hwaccel {HwAccel}",
|
||||
request.ChannelNumber,
|
||||
request.Mode,
|
||||
totalMs,
|
||||
setupMs,
|
||||
startupMs,
|
||||
fillMs,
|
||||
segments.SegmentsReached,
|
||||
segments.InitialSegmentCount,
|
||||
segments.DeadlineExpired,
|
||||
segments.Features.SubtitleBurnIn,
|
||||
segments.Features.HardwareAcceleration);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
@@ -55,6 +55,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private string _workingDirectory;
|
||||
private Option<double> _slugSeconds;
|
||||
|
||||
// The arguments of the first ffmpeg process launched for this session, captured for
|
||||
// cold-start telemetry (#350). Written once on the sequential Run loop, read on the
|
||||
// handler thread from WaitForPlaylistSegments after segments exist (a happens-before:
|
||||
// segments cannot exist until this process ran) — volatile for cross-thread visibility.
|
||||
private volatile string _coldStartFFmpegArguments;
|
||||
|
||||
public HlsSessionWorker(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
@@ -300,17 +306,20 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WaitForPlaylistSegments(
|
||||
public async Task<PlaylistSegmentsResult> WaitForPlaylistSegments(
|
||||
int initialSegmentCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Waiting for playlist segments...");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var processStartup = TimeSpan.Zero;
|
||||
var segmentCount = 0;
|
||||
try
|
||||
{
|
||||
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
|
||||
|
||||
// Phase A: ffmpeg process spawn -> playlist file exists (startup + probe + init + first GOP)
|
||||
_logger.LogDebug("Waiting for playlist to exist");
|
||||
while (!_fileSystem.File.Exists(playlistFileName))
|
||||
{
|
||||
@@ -318,12 +327,13 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
|
||||
_logger.LogDebug("Playlist exists");
|
||||
processStartup = sw.Elapsed;
|
||||
|
||||
// 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);
|
||||
|
||||
var segmentCount = 0;
|
||||
// Phase B: playlist exists -> the requested number of segments are present (or deadline)
|
||||
int lastSegmentCount = -1;
|
||||
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
|
||||
{
|
||||
@@ -345,6 +355,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
segmentCount = result.SegmentCount;
|
||||
}
|
||||
}
|
||||
|
||||
return new PlaylistSegmentsResult(
|
||||
processStartup,
|
||||
sw.Elapsed - processStartup,
|
||||
segmentCount,
|
||||
initialSegmentCount,
|
||||
segmentCount < initialSegmentCount,
|
||||
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -533,6 +551,9 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
Command process = processModel.Process;
|
||||
|
||||
// capture the first process's arguments once for cold-start telemetry (#350)
|
||||
_coldStartFFmpegArguments ??= process.Arguments;
|
||||
|
||||
_logger.LogDebug("ffmpeg hls arguments {FFmpegArguments}", process.Arguments);
|
||||
|
||||
try
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class ColdStartFeaturesTests
|
||||
{
|
||||
[Test]
|
||||
public void Should_Detect_Subtitle_BurnIn_From_Subtitles_Filter()
|
||||
{
|
||||
const string Args =
|
||||
"-i ep.mkv -filter_complex \"[0:0]subtitles=http://localhost:8409/media/subtitle/1.ass[v]\" " +
|
||||
"-c:v h264_vaapi -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Flag_Subtitle_BurnIn_For_Copy_Or_Overlay()
|
||||
{
|
||||
// subtitle stream copy (-c:s) and a watermark/image-subtitle overlay must NOT be read as burn-in
|
||||
const string Args = "-i ep.mkv -filter_complex \"[v][wm]overlay=10:10:format=0[vwm]\" -c:s copy -c:v libx264 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestCase("-c:v h264_vaapi -f hls", "vaapi")]
|
||||
[TestCase("-c:v hevc_vaapi -f hls", "vaapi")]
|
||||
[TestCase("-c:v h264_nvenc -f hls", "nvenc")]
|
||||
[TestCase("-c:v h264_qsv -f hls", "qsv")]
|
||||
[TestCase("-c:v h264_videotoolbox -f hls", "videotoolbox")]
|
||||
[TestCase("-c:v h264_amf -f hls", "amf")]
|
||||
public void Should_Detect_Hardware_Family_From_Encoder(string args, string expected)
|
||||
{
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Detect_Hardware_Family_From_Vaapi_Filter_Even_Without_Encoder_Token()
|
||||
{
|
||||
// a vaapi-accelerated pipeline surfaces the family via filters (e.g. scale_vaapi) too
|
||||
const string Args = "-hwaccel vaapi -i ep.mkv -vf scale_vaapi=1920:1080 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe("vaapi");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fall_Back_To_Hwaccel_Flag_When_No_Hardware_Encoder_Or_Filter()
|
||||
{
|
||||
// hardware decode, software encode: attribute the decode accel
|
||||
const string Args = "-hwaccel cuda -i ep.mkv -c:v libx264 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe("cuda");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Report_Software_When_No_Acceleration()
|
||||
{
|
||||
const string Args = "-i ep.mkv -c:v libx264 -c:a aac -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
features.HardwareAcceleration.ShouldBe("software");
|
||||
}
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
[TestCase(null)]
|
||||
public void Should_Report_Unknown_For_Empty_Arguments(string args)
|
||||
{
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
features.HardwareAcceleration.ShouldBe("unknown");
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,47 @@ public class ChannelGuideGoldenTests
|
||||
xml.ShouldNotContain("a&b");
|
||||
}
|
||||
|
||||
// The access-token value is HTTP-request-derived (?access_token=) and interpolated raw into the
|
||||
// {AccessTokenUri} placeholder, so a token containing XML-special chars must be escaped too —
|
||||
// otherwise it malforms the whole guide, exactly like the {RequestBase} case above. (Finding #376.)
|
||||
[Test]
|
||||
public async Task Guide_xml_escapes_access_token()
|
||||
{
|
||||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
localFileSystem
|
||||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||||
.Returns(new[]
|
||||
{
|
||||
FragmentPath(fileSystem, "channels.xml"),
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.None);
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: "tok&<>\""),
|
||||
CancellationToken.None);
|
||||
|
||||
string xml = result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// Every XML-special char in the token must be escaped; the raw token must never reach the output.
|
||||
xml.ShouldContain("access_token=tok&<>"");
|
||||
xml.ShouldNotContain("access_token=tok&<");
|
||||
}
|
||||
|
||||
// --- harness ---
|
||||
|
||||
private async Task Verify(string goldenName, GetChannelGuide request)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Heavy Movie 01
|
||||
001 | 2026-01-15 00:30:00 - 2026-01-15 01:00:00 | None | Heavy Movie 02
|
||||
002 | 2026-01-15 01:00:00 - 2026-01-15 01:30:00 | None | Light Movie 04
|
||||
003 | 2026-01-15 01:30:00 - 2026-01-15 02:00:00 | None | Heavy Movie 01
|
||||
004 | 2026-01-15 02:00:00 - 2026-01-15 02:30:00 | None | Heavy Movie 02
|
||||
005 | 2026-01-15 02:30:00 - 2026-01-15 03:00:00 | None | Heavy Movie 01
|
||||
006 | 2026-01-15 03:00:00 - 2026-01-15 03:30:00 | None | Light Movie 03
|
||||
007 | 2026-01-15 03:30:00 - 2026-01-15 04:00:00 | None | Heavy Movie 02
|
||||
008 | 2026-01-15 04:00:00 - 2026-01-15 04:30:00 | None | Heavy Movie 01
|
||||
009 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Heavy Movie 02
|
||||
010 | 2026-01-15 05:00:00 - 2026-01-15 05:30:00 | None | Light Movie 01
|
||||
011 | 2026-01-15 05:30:00 - 2026-01-15 06:00:00 | None | Heavy Movie 01
|
||||
012 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Heavy Movie 02
|
||||
013 | 2026-01-15 06:30:00 - 2026-01-15 07:00:00 | None | Heavy Movie 01
|
||||
014 | 2026-01-15 07:00:00 - 2026-01-15 07:30:00 | None | Light Movie 02
|
||||
015 | 2026-01-15 07:30:00 - 2026-01-15 08:00:00 | None | Heavy Movie 02
|
||||
016 | 2026-01-15 08:00:00 - 2026-01-15 08:30:00 | None | Heavy Movie 01
|
||||
017 | 2026-01-15 08:30:00 - 2026-01-15 09:00:00 | None | Heavy Movie 02
|
||||
018 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Light Movie 02
|
||||
019 | 2026-01-15 09:30:00 - 2026-01-15 10:00:00 | None | Heavy Movie 01
|
||||
020 | 2026-01-15 10:00:00 - 2026-01-15 10:30:00 | None | Heavy Movie 02
|
||||
021 | 2026-01-15 10:30:00 - 2026-01-15 11:00:00 | None | Heavy Movie 01
|
||||
022 | 2026-01-15 11:00:00 - 2026-01-15 11:30:00 | None | Light Movie 04
|
||||
023 | 2026-01-15 11:30:00 - 2026-01-15 12:00:00 | None | Heavy Movie 02
|
||||
024 | 2026-01-15 12:00:00 - 2026-01-15 12:30:00 | None | Heavy Movie 01
|
||||
025 | 2026-01-15 12:30:00 - 2026-01-15 13:00:00 | None | Heavy Movie 02
|
||||
026 | 2026-01-15 13:00:00 - 2026-01-15 13:30:00 | None | Light Movie 01
|
||||
027 | 2026-01-15 13:30:00 - 2026-01-15 14:00:00 | None | Heavy Movie 01
|
||||
028 | 2026-01-15 14:00:00 - 2026-01-15 14:30:00 | None | Heavy Movie 02
|
||||
029 | 2026-01-15 14:30:00 - 2026-01-15 15:00:00 | None | Heavy Movie 01
|
||||
030 | 2026-01-15 15:00:00 - 2026-01-15 15:30:00 | None | Light Movie 03
|
||||
031 | 2026-01-15 15:30:00 - 2026-01-15 16:00:00 | None | Heavy Movie 02
|
||||
032 | 2026-01-15 16:00:00 - 2026-01-15 16:30:00 | None | Heavy Movie 01
|
||||
033 | 2026-01-15 16:30:00 - 2026-01-15 17:00:00 | None | Heavy Movie 02
|
||||
034 | 2026-01-15 17:00:00 - 2026-01-15 17:30:00 | None | Light Movie 02
|
||||
035 | 2026-01-15 17:30:00 - 2026-01-15 18:00:00 | None | Heavy Movie 01
|
||||
036 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Heavy Movie 02
|
||||
037 | 2026-01-15 18:30:00 - 2026-01-15 19:00:00 | None | Heavy Movie 01
|
||||
038 | 2026-01-15 19:00:00 - 2026-01-15 19:30:00 | None | Light Movie 03
|
||||
039 | 2026-01-15 19:30:00 - 2026-01-15 20:00:00 | None | Heavy Movie 02
|
||||
040 | 2026-01-15 20:00:00 - 2026-01-15 20:30:00 | None | Heavy Movie 01
|
||||
041 | 2026-01-15 20:30:00 - 2026-01-15 21:00:00 | None | Heavy Movie 02
|
||||
042 | 2026-01-15 21:00:00 - 2026-01-15 21:30:00 | None | Light Movie 01
|
||||
043 | 2026-01-15 21:30:00 - 2026-01-15 22:00:00 | None | Heavy Movie 01
|
||||
044 | 2026-01-15 22:00:00 - 2026-01-15 22:30:00 | None | Heavy Movie 02
|
||||
045 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Heavy Movie 01
|
||||
046 | 2026-01-15 23:00:00 - 2026-01-15 23:30:00 | None | Light Movie 04
|
||||
047 | 2026-01-15 23:30:00 - 2026-01-16 00:00:00 | None | Heavy Movie 02
|
||||
048 | 2026-01-16 00:00:00 - 2026-01-16 00:30:00 | None | Heavy Movie 01
|
||||
049 | 2026-01-16 00:30:00 - 2026-01-16 01:00:00 | None | Heavy Movie 02
|
||||
050 | 2026-01-16 01:00:00 - 2026-01-16 01:30:00 | None | Light Movie 02
|
||||
051 | 2026-01-16 01:30:00 - 2026-01-16 02:00:00 | None | Heavy Movie 01
|
||||
052 | 2026-01-16 02:00:00 - 2026-01-16 02:30:00 | None | Heavy Movie 02
|
||||
053 | 2026-01-16 02:30:00 - 2026-01-16 03:00:00 | None | Heavy Movie 01
|
||||
054 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Light Movie 04
|
||||
055 | 2026-01-16 03:30:00 - 2026-01-16 04:00:00 | None | Heavy Movie 02
|
||||
056 | 2026-01-16 04:00:00 - 2026-01-16 04:30:00 | None | Heavy Movie 01
|
||||
057 | 2026-01-16 04:30:00 - 2026-01-16 05:00:00 | None | Heavy Movie 02
|
||||
058 | 2026-01-16 05:00:00 - 2026-01-16 05:30:00 | None | Light Movie 01
|
||||
059 | 2026-01-16 05:30:00 - 2026-01-16 06:00:00 | None | Heavy Movie 01
|
||||
060 | 2026-01-16 06:00:00 - 2026-01-16 06:30:00 | None | Heavy Movie 02
|
||||
061 | 2026-01-16 06:30:00 - 2026-01-16 07:00:00 | None | Heavy Movie 01
|
||||
062 | 2026-01-16 07:00:00 - 2026-01-16 07:30:00 | None | Light Movie 03
|
||||
063 | 2026-01-16 07:30:00 - 2026-01-16 08:00:00 | None | Heavy Movie 02
|
||||
064 | 2026-01-16 08:00:00 - 2026-01-16 08:30:00 | None | Heavy Movie 01
|
||||
065 | 2026-01-16 08:30:00 - 2026-01-16 09:00:00 | None | Heavy Movie 02
|
||||
066 | 2026-01-16 09:00:00 - 2026-01-16 09:30:00 | None | Light Movie 01
|
||||
067 | 2026-01-16 09:30:00 - 2026-01-16 10:00:00 | None | Heavy Movie 01
|
||||
068 | 2026-01-16 10:00:00 - 2026-01-16 10:30:00 | None | Heavy Movie 02
|
||||
069 | 2026-01-16 10:30:00 - 2026-01-16 11:00:00 | None | Heavy Movie 01
|
||||
070 | 2026-01-16 11:00:00 - 2026-01-16 11:30:00 | None | Light Movie 02
|
||||
071 | 2026-01-16 11:30:00 - 2026-01-16 12:00:00 | None | Heavy Movie 02
|
||||
072 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Heavy Movie 01
|
||||
073 | 2026-01-16 12:30:00 - 2026-01-16 13:00:00 | None | Heavy Movie 02
|
||||
074 | 2026-01-16 13:00:00 - 2026-01-16 13:30:00 | None | Light Movie 03
|
||||
075 | 2026-01-16 13:30:00 - 2026-01-16 14:00:00 | None | Heavy Movie 01
|
||||
076 | 2026-01-16 14:00:00 - 2026-01-16 14:30:00 | None | Heavy Movie 02
|
||||
077 | 2026-01-16 14:30:00 - 2026-01-16 15:00:00 | None | Heavy Movie 01
|
||||
078 | 2026-01-16 15:00:00 - 2026-01-16 15:30:00 | None | Light Movie 04
|
||||
079 | 2026-01-16 15:30:00 - 2026-01-16 16:00:00 | None | Heavy Movie 02
|
||||
080 | 2026-01-16 16:00:00 - 2026-01-16 16:30:00 | None | Heavy Movie 01
|
||||
081 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Heavy Movie 02
|
||||
082 | 2026-01-16 17:00:00 - 2026-01-16 17:30:00 | None | Light Movie 03
|
||||
083 | 2026-01-16 17:30:00 - 2026-01-16 18:00:00 | None | Heavy Movie 01
|
||||
084 | 2026-01-16 18:00:00 - 2026-01-16 18:30:00 | None | Heavy Movie 02
|
||||
085 | 2026-01-16 18:30:00 - 2026-01-16 19:00:00 | None | Heavy Movie 01
|
||||
086 | 2026-01-16 19:00:00 - 2026-01-16 19:30:00 | None | Light Movie 02
|
||||
087 | 2026-01-16 19:30:00 - 2026-01-16 20:00:00 | None | Heavy Movie 02
|
||||
088 | 2026-01-16 20:00:00 - 2026-01-16 20:30:00 | None | Heavy Movie 01
|
||||
089 | 2026-01-16 20:30:00 - 2026-01-16 21:00:00 | None | Heavy Movie 02
|
||||
090 | 2026-01-16 21:00:00 - 2026-01-16 21:30:00 | None | Light Movie 04
|
||||
091 | 2026-01-16 21:30:00 - 2026-01-16 22:00:00 | None | Heavy Movie 01
|
||||
092 | 2026-01-16 22:00:00 - 2026-01-16 22:30:00 | None | Heavy Movie 02
|
||||
093 | 2026-01-16 22:30:00 - 2026-01-16 23:00:00 | None | Heavy Movie 01
|
||||
094 | 2026-01-16 23:00:00 - 2026-01-16 23:30:00 | None | Light Movie 01
|
||||
095 | 2026-01-16 23:30:00 - 2026-01-17 00:00:00 | None | Heavy Movie 02
|
||||
096 | 2026-01-17 00:00:00 - 2026-01-17 00:30:00 | None | Heavy Movie 01
|
||||
097 | 2026-01-17 00:30:00 - 2026-01-17 01:00:00 | None | Heavy Movie 02
|
||||
098 | 2026-01-17 01:00:00 - 2026-01-17 01:30:00 | None | Light Movie 04
|
||||
099 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Heavy Movie 01
|
||||
100 | 2026-01-17 02:00:00 - 2026-01-17 02:30:00 | None | Heavy Movie 02
|
||||
101 | 2026-01-17 02:30:00 - 2026-01-17 03:00:00 | None | Heavy Movie 01
|
||||
102 | 2026-01-17 03:00:00 - 2026-01-17 03:30:00 | None | Light Movie 02
|
||||
103 | 2026-01-17 03:30:00 - 2026-01-17 04:00:00 | None | Heavy Movie 02
|
||||
104 | 2026-01-17 04:00:00 - 2026-01-17 04:30:00 | None | Heavy Movie 01
|
||||
105 | 2026-01-17 04:30:00 - 2026-01-17 05:00:00 | None | Heavy Movie 02
|
||||
106 | 2026-01-17 05:00:00 - 2026-01-17 05:30:00 | None | Light Movie 01
|
||||
107 | 2026-01-17 05:30:00 - 2026-01-17 06:00:00 | None | Heavy Movie 01
|
||||
@@ -124,6 +124,16 @@ public class PlayoutBuildGoldenTests
|
||||
[Test]
|
||||
public Task Classic_shuffle() => Verify("classic-shuffle.txt", BuildShufflePlayout);
|
||||
|
||||
// Classic + PlaybackOrder.WeightedShuffle (#70): the weight only means anything end-to-end if it survives
|
||||
// MultiCollection -> MediaCollectionRepository -> CollectionWithItems.Weight -> ShuffleSourceBuilder ->
|
||||
// WeightedShuffleCollectionEnumerator. The unit tests pin the enumerator's sequence in isolation; this pins
|
||||
// that the real builder actually distributes by weight. The fixture is deliberately lopsided -- the LIGHTER
|
||||
// source (weight 1) is the LARGER collection (4 items vs 2) -- so a golden that merely tracked collection
|
||||
// size, or that ignored weight, would look obviously different from one that honors a 3:1 ratio.
|
||||
// Same determinism contract as Classic_shuffle: pinned Seed + Continue (Reset would randomize the seed).
|
||||
[Test]
|
||||
public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);
|
||||
|
||||
[Test]
|
||||
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
|
||||
public async Task Regenerate_goldens()
|
||||
@@ -376,6 +386,183 @@ public class PlayoutBuildGoldenTests
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildWeightedPlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedWeightedData(cancellationToken);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Continue,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedWeightedData(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Weighted LibraryPath" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Titles carry their source so the golden reads as a rotation rather than a list of ids.
|
||||
// "Heavy" is the SMALL collection with weight 3; "Light" is the LARGE one with weight 1.
|
||||
async Task<List<Movie>> SeedMovies(string prefix, int count)
|
||||
{
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
movies.Add(
|
||||
new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(30) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"{prefix} Movie {i:D2}",
|
||||
ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return movies;
|
||||
}
|
||||
|
||||
List<Movie> heavy = await SeedMovies("Heavy", 2);
|
||||
List<Movie> light = await SeedMovies("Light", 4);
|
||||
|
||||
var titles = heavy.Concat(light).ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||||
|
||||
var heavyCollection = new Collection
|
||||
{
|
||||
Name = "Weighted Heavy Collection",
|
||||
MediaItems = heavy.Cast<MediaItem>().ToList()
|
||||
};
|
||||
var lightCollection = new Collection
|
||||
{
|
||||
Name = "Weighted Light Collection",
|
||||
MediaItems = light.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddRangeAsync([heavyCollection, lightCollection], cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var multiCollection = new MultiCollection
|
||||
{
|
||||
Name = "Weighted Multi Collection",
|
||||
MultiCollectionItems =
|
||||
[
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = heavyCollection.Id,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 3
|
||||
},
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = lightCollection.Id,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 1
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.MultiCollections.AddAsync(multiCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
MultiCollection = multiCollection,
|
||||
MultiCollectionId = multiCollection.Id,
|
||||
CollectionType = CollectionType.MultiCollection,
|
||||
PlayoutDuration = TimeSpan.FromHours(1),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle
|
||||
}
|
||||
};
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Weighted FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000005"))
|
||||
{
|
||||
Name = "Weighted Test Channel",
|
||||
Number = "5",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule { Name = "Weighted Test Schedule", Items = scheduleItems };
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic,
|
||||
// Pinned so the weighted rotation is deterministic; Continue preserves it (Reset would overwrite).
|
||||
Seed = 7654321
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the weighted / fair-share distribution contract (#70). The sequence semantics here are the
|
||||
/// product decision, so they are asserted exactly rather than statistically.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WeightedShuffleCollectionEnumeratorTests
|
||||
{
|
||||
// ids are allocated per source so an emitted item's source is identifiable from its id
|
||||
private const int SourceAFirstId = 100;
|
||||
private const int SourceBFirstId = 200;
|
||||
private const int SourceCFirstId = 300;
|
||||
|
||||
private static CollectionWithItems Source(string key, int firstId, int itemCount, int weight) =>
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
key,
|
||||
Enumerable.Range(firstId, itemCount)
|
||||
.Select(i => new Movie { Id = i, MovieMetadata = [] })
|
||||
.Cast<MediaItem>()
|
||||
.ToList(),
|
||||
true,
|
||||
PlaybackOrder.WeightedShuffle,
|
||||
false,
|
||||
weight);
|
||||
|
||||
private static string SourceOf(int id) => id switch
|
||||
{
|
||||
>= SourceCFirstId => "C",
|
||||
>= SourceBFirstId => "B",
|
||||
_ => "A"
|
||||
};
|
||||
|
||||
private static List<string> TakeSourceSequence(WeightedShuffleCollectionEnumerator enumerator, int count)
|
||||
{
|
||||
var result = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
enumerator.Current.IsSome.ShouldBeTrue();
|
||||
result.Add(SourceOf(enumerator.Current.ValueUnsafe().Id));
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Weights_Three_To_One_Emit_A_A_B_A()
|
||||
{
|
||||
// the canonical smooth-WRR contract: 3:1 spreads B through the rotation (A A B A),
|
||||
// rather than draining A first (A A A B) the way PlaylistItem.Count does
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 3, 3),
|
||||
Source("B", SourceBFirstId, 1, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "B", "A"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Equal_Weights_Are_Fair_Share_Regardless_Of_Collection_Size()
|
||||
{
|
||||
// the heart of goal (2): a 20-item source must air as often as a 2-item source.
|
||||
// ShuffleInOrder cannot do this -- it plays every item once, so airtime tracks size.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 20, 1),
|
||||
Source("B", SourceBFirstId, 2, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
List<string> sequence = TakeSourceSequence(enumerator, 40);
|
||||
|
||||
// equal weights => strict alternation, so the small source loops rather than falling silent
|
||||
sequence.Count(s => s == "A").ShouldBe(20);
|
||||
sequence.Count(s => s == "B").ShouldBe(20);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ties_Break_To_The_Earliest_Source_In_List_Order()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 4, 1),
|
||||
Source("B", SourceBFirstId, 4, 1),
|
||||
Source("C", SourceCFirstId, 4, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 99, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// all accumulators tie every round, so list order decides
|
||||
TakeSourceSequence(enumerator, 6).ShouldBe(["A", "B", "C", "A", "B", "C"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Unweighted_Collection_Is_Fair_Share()
|
||||
{
|
||||
// a source arriving without an explicit weight must rotate as fair-share, never fall out
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(0, 0, "A", [new Movie { Id = SourceAFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false),
|
||||
new(0, 0, "B", [new Movie { Id = SourceBFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 7, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(0)]
|
||||
[TestCase(-5)]
|
||||
public void A_Non_Positive_Weight_Does_Not_Delete_The_Source(int weight)
|
||||
{
|
||||
// the write path bounds weight, but a row can predate that gate. Treating a 0/negative weight as a
|
||||
// filter would remove the source from the channel silently -- the exact failure this order avoids
|
||||
// everywhere else. It is clamped to the floor instead.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, 1),
|
||||
Source("B", SourceBFirstId, 2, weight)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 21, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 8).ShouldContain("B");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Enormous_Weight_Does_Not_Overflow_The_Rotation()
|
||||
{
|
||||
// summing unclamped weights is checked arithmetic, so an out-of-range row would throw from inside a
|
||||
// playout build rather than merely schedule oddly
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, int.MaxValue),
|
||||
Source("B", SourceBFirstId, 2, int.MaxValue)
|
||||
};
|
||||
|
||||
WeightedShuffleCollectionEnumerator enumerator = null;
|
||||
Should.NotThrow(() => enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 5, Index = 0 },
|
||||
CancellationToken.None));
|
||||
|
||||
// both clamp to the ceiling, so they tie and alternate by list order
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(12)]
|
||||
[TestCase(13)]
|
||||
[TestCase(20)]
|
||||
[TestCase(37)]
|
||||
public void Restoring_Past_A_Rotation_Wrap_Equals_Advancing_To_It(int target)
|
||||
{
|
||||
// the stateless claim has to hold ACROSS a wrap, not just within the first rotation: each wrap
|
||||
// re-derives the rotation from the new seed alone, so (Seed, Index) still determines position.
|
||||
// Cycle length here is 12, so every case but the first crosses at least one wrap.
|
||||
List<CollectionWithItems> Collections() =>
|
||||
[
|
||||
Source("A", SourceAFirstId, 5, 3),
|
||||
Source("B", SourceBFirstId, 3, 1)
|
||||
];
|
||||
|
||||
const int Seed = 909;
|
||||
|
||||
var advanced = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
|
||||
CancellationToken.None);
|
||||
for (var i = 0; i < target; i++)
|
||||
{
|
||||
advanced.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
var restored = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = advanced.State.Seed, Index = advanced.State.Index },
|
||||
CancellationToken.None);
|
||||
|
||||
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Restoring_At_An_Index_Equals_Advancing_To_It()
|
||||
{
|
||||
// the stateless contract: (Seed, Index) fully determines position, which is what lets the
|
||||
// existing CollectionEnumeratorState persistence carry this order with no per-source counters
|
||||
List<CollectionWithItems> Collections() =>
|
||||
[
|
||||
Source("A", SourceAFirstId, 5, 3),
|
||||
Source("B", SourceBFirstId, 3, 1)
|
||||
];
|
||||
|
||||
const int Seed = 4242;
|
||||
const int Target = 7;
|
||||
|
||||
var advanced = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
|
||||
CancellationToken.None);
|
||||
for (var i = 0; i < Target; i++)
|
||||
{
|
||||
advanced.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
var restored = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = Target },
|
||||
CancellationToken.None);
|
||||
|
||||
restored.State.Index.ShouldBe(advanced.State.Index);
|
||||
restored.State.Seed.ShouldBe(advanced.State.Seed);
|
||||
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Single_Source_Emits_All_Of_Its_Items()
|
||||
{
|
||||
var collections = new List<CollectionWithItems> { Source("A", SourceAFirstId, 5, 3) };
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 11, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
var seen = new System.Collections.Generic.HashSet<int>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
seen.Add(enumerator.Current.ValueUnsafe().Id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
seen.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Empty_Source_Is_Ignored_Rather_Than_Emitting_Nothing()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, 1),
|
||||
new(0, 0, "empty", [], true, PlaybackOrder.WeightedShuffle, false, 5)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 3, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// a heavily-weighted empty source must not starve the rotation or emit None
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "A", "A"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(10_000)]
|
||||
public void Single_Item_Sources_Do_Not_Hang_On_Rotation_Wrap()
|
||||
{
|
||||
// regression: the wrap retries a rebuild to avoid an immediate repeat, but this order's lead item is
|
||||
// decided by weight, so the heaviest source always leads. With one item in it the lead is invariant and
|
||||
// an unbounded retry never terminates -- a hung playout build, not a wrong one. Found by the
|
||||
// non-vacuity control, which hung instead of failing.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 1, 3),
|
||||
Source("B", SourceBFirstId, 1, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// walk several full rotations so the wrap path is exercised repeatedly
|
||||
List<string> sequence = TakeSourceSequence(enumerator, 24);
|
||||
|
||||
sequence.ShouldContain("A");
|
||||
sequence.ShouldContain("B");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Sources_Yields_No_Current()
|
||||
{
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
[],
|
||||
new CollectionEnumeratorState { Seed = 1, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
enumerator.Current.IsNone.ShouldBeTrue();
|
||||
enumerator.Count.ShouldBe(0);
|
||||
Should.NotThrow(() => enumerator.MoveNext(Option<DateTimeOffset>.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Custom_Ordered_Source_Keeps_Its_Order()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"A",
|
||||
Enumerable.Range(SourceAFirstId, 4)
|
||||
.Select(i => new Movie { Id = i, MovieMetadata = [] })
|
||||
.Cast<MediaItem>()
|
||||
.ToList(),
|
||||
true,
|
||||
PlaybackOrder.WeightedShuffle,
|
||||
true,
|
||||
1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 555, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
var ids = new List<int>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
ids.Add(enumerator.Current.ValueUnsafe().Id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
ids.ShouldBe([SourceAFirstId, SourceAFirstId + 1, SourceAFirstId + 2, SourceAFirstId + 3]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable enable
|
||||
#nullable enable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
@@ -15,4 +15,5 @@ public record ChannelResponseModel(
|
||||
string Language,
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg);
|
||||
bool ShowInEpg,
|
||||
int PlayoutCount);
|
||||
|
||||
@@ -5,4 +5,14 @@ public record HealthCheckResponseModel(
|
||||
string Title,
|
||||
string Status,
|
||||
string Detail,
|
||||
string? Link);
|
||||
string? Brief,
|
||||
// Deprecated: the raw remediation target, kept for back-compat. Prefer `Remediation`,
|
||||
// which also carries the kind (external doc vs in-app route). Still populated.
|
||||
string? Link,
|
||||
HealthCheckRemediationResponseModel? Remediation);
|
||||
|
||||
// Where the user should go to investigate or fix the check. `Kind` is "ExternalDoc"
|
||||
// (open `Target` as an external URL) or "AppRoute" (navigate to the `Target` /app path).
|
||||
public record HealthCheckRemediationResponseModel(
|
||||
string Kind,
|
||||
string Target);
|
||||
|
||||
@@ -13,7 +13,8 @@ public record MultiCollectionItemResponseModel(
|
||||
int? SmartCollectionId,
|
||||
string Name,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
public record PagedMultiCollectionsResponseModel(
|
||||
int TotalCount,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Api.Search;
|
||||
|
||||
/// <summary>
|
||||
/// One filterable field in the visual rule builder's catalog.
|
||||
/// <c>Type</c> is one of: text, fulltext, number, date, enum.
|
||||
/// <c>Values</c> is populated only for <c>enum</c> fields (allowed dropdown values); empty otherwise.
|
||||
/// </summary>
|
||||
public record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values);
|
||||
@@ -11,4 +11,5 @@ public record SearchResultAllItemsResponseModel(
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds,
|
||||
List<int> RemoteStreamIds);
|
||||
List<int> RemoteStreamIds,
|
||||
SearchResultAllItemsTotalsResponseModel Totals);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Search;
|
||||
|
||||
// Per-kind total hit counts for a search all-items query, independent of the current page. Lets a
|
||||
// client page GET /api/v1/search/all-items to completeness (the SPA "add all" flow) instead of
|
||||
// materializing every id in one unbounded response (issue #293).
|
||||
public record SearchResultAllItemsTotalsResponseModel(
|
||||
int MovieCount,
|
||||
int ShowCount,
|
||||
int SeasonCount,
|
||||
int EpisodeCount,
|
||||
int ArtistCount,
|
||||
int MusicVideoCount,
|
||||
int OtherVideoCount,
|
||||
int SongCount,
|
||||
int ImageCount,
|
||||
int RemoteStreamCount);
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -12,4 +12,12 @@ public class MultiCollection : IVersionedAggregate
|
||||
public List<SmartCollection> SmartCollections { get; set; }
|
||||
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
|
||||
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When non-null, this multi collection is a system-owned artifact created for an auto-tune weighted
|
||||
/// channel (#425): its members are the per-content-source SmartCollections that carry the rotation
|
||||
/// weights. Hidden from the user-facing collection lists and deleted when the channel is deleted.
|
||||
/// Null for every user-created multi collection.
|
||||
/// </summary>
|
||||
public int? OwnedByChannelId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class MultiCollectionItem
|
||||
{
|
||||
@@ -8,4 +8,10 @@ public class MultiCollectionItem
|
||||
public Collection Collection { get; set; }
|
||||
public bool ScheduleAsGroup { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative share of airtime under <see cref="PlaybackOrder.WeightedShuffle" /> (#70). Defaults to 1,
|
||||
/// which is fair-share. Ignored by every other playback order.
|
||||
/// </summary>
|
||||
public int Weight { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds for a multi-collection member's per-source <c>Weight</c> (#70).
|
||||
/// <para>
|
||||
/// The floor is <b>1, not 0</b>. A weight is a <i>share of airtime</i>, and 0 has no meaning on that scale —
|
||||
/// "don't play this source" is expressed by removing it from the multi collection, not by weighting it to
|
||||
/// nothing. Rejecting it here keeps that intent from being expressed in a way the rotation would have to
|
||||
/// interpret. <c>WeightedShuffleCollectionEnumerator</c> independently clamps to this range rather than
|
||||
/// trusting it, so a row that predates this gate rotates at the floor instead of vanishing; the two are
|
||||
/// belt-and-braces, not duplicates — <b>neither is redundant</b>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The ceiling is the same argument as the floor, not an arithmetic guard. A weight of a billion is not a
|
||||
/// share of airtime any more than 0 is, so it is refused at the boundary rather than silently reinterpreted.
|
||||
/// The rotation arithmetic is made safe by <c>EffectiveWeight</c>'s clamp (and by <c>CycleLength</c> summing
|
||||
/// to <c>long</c>) — <b>not</b> by this constant: with the clamp in place a sum cannot overflow whatever the
|
||||
/// stored value is, which is exactly why a row that predates this gate is still safe.
|
||||
/// <c>An_Enormous_Weight_Does_Not_Overflow_The_Rotation</c> pins that.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MultiCollectionItemWeight
|
||||
{
|
||||
public const int Minimum = 1;
|
||||
public const int Maximum = 1000;
|
||||
public const int Default = 1;
|
||||
|
||||
public static bool IsValid(int weight) => weight is >= Minimum and <= Maximum;
|
||||
|
||||
public static string ValidationMessage =>
|
||||
$"Weight must be between {Minimum} and {Maximum}";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class MultiCollectionSmartItem
|
||||
{
|
||||
@@ -8,4 +8,10 @@ public class MultiCollectionSmartItem
|
||||
public SmartCollection SmartCollection { get; set; }
|
||||
public bool ScheduleAsGroup { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative share of airtime under <see cref="PlaybackOrder.WeightedShuffle" /> (#70). Defaults to 1,
|
||||
/// which is fair-share. Ignored by every other playback order.
|
||||
/// </summary>
|
||||
public int Weight { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -10,4 +10,12 @@ public class SmartCollection
|
||||
public string Query { get; set; }
|
||||
public List<MultiCollection> MultiCollections { get; set; }
|
||||
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When non-null, this is a system-owned artifact created for an auto-tune weighted channel (#425):
|
||||
/// one per-content-source SmartCollection (or the catch-all remainder) inside that channel's
|
||||
/// MultiCollection. Hidden from the user-facing collection lists and deleted when the channel is
|
||||
/// deleted. Null for every user-created smart collection.
|
||||
/// </summary>
|
||||
public int? OwnedByChannelId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum PlaybackOrder
|
||||
{
|
||||
@@ -11,5 +11,9 @@ public enum PlaybackOrder
|
||||
MultiEpisodeShuffle = 5,
|
||||
SeasonEpisode = 6,
|
||||
RandomRotation = 7,
|
||||
Marathon = 8
|
||||
Marathon = 8,
|
||||
|
||||
// weighted / fair-share distribution (#70); Classic schedule items only -- write-path validation
|
||||
// rejects it elsewhere, because the other engines silently drop or substitute an unknown order
|
||||
WeightedShuffle = 9
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Heavy transcode features detected from a cold-start's FFmpeg arguments, so each HLS
|
||||
/// cold-start latency sample is self-describing (#350). Only cleanly-detectable signals are
|
||||
/// surfaced: subtitle burn-in (the libass <c>subtitles=</c> text filter) and the hardware
|
||||
/// acceleration family. Watermark, HDR->SDR, and <em>image</em>-based subtitle burn-in are
|
||||
/// intentionally NOT flagged here: in the argument string they all reduce to <c>overlay=</c>,
|
||||
/// indistinguishable from one another, so a boolean would mislead the very measurement it
|
||||
/// serves. Inspect the full <c>ffmpeg hls arguments</c> debug log for those details.
|
||||
/// Detection is a case-sensitive substring scan of the whole argument string (which includes
|
||||
/// the input media path), so a media filename literally containing e.g. <c>subtitles=</c> or
|
||||
/// <c>_amf</c> could false-positive; this is telemetry-only (no behavior impact) and low-probability.
|
||||
/// </summary>
|
||||
public readonly record struct ColdStartFeatures(bool SubtitleBurnIn, string HardwareAcceleration)
|
||||
{
|
||||
// Encoder/filter suffix -> accel family. Matching any of these substrings means the
|
||||
// pipeline is that hardware family (the encoder codec, e.g. h264_vaapi, dominates
|
||||
// cold-start encoder init cost; VAAPI/QSV/CUDA filters carry the same suffix).
|
||||
private static readonly (string Token, string Family)[] HardwareFamilies =
|
||||
[
|
||||
("_vaapi", "vaapi"),
|
||||
("_nvenc", "nvenc"),
|
||||
("_qsv", "qsv"),
|
||||
("_videotoolbox", "videotoolbox"),
|
||||
("_amf", "amf"),
|
||||
("_rkmpp", "rkmpp")
|
||||
];
|
||||
|
||||
public static ColdStartFeatures FromFFmpegArguments(string arguments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
return new ColdStartFeatures(false, "unknown");
|
||||
}
|
||||
|
||||
bool subtitleBurnIn = arguments.Contains("subtitles=", StringComparison.Ordinal);
|
||||
return new ColdStartFeatures(subtitleBurnIn, DetectHardwareAcceleration(arguments));
|
||||
}
|
||||
|
||||
private static string DetectHardwareAcceleration(string arguments)
|
||||
{
|
||||
foreach ((string token, string family) in HardwareFamilies)
|
||||
{
|
||||
if (arguments.Contains(token, StringComparison.Ordinal))
|
||||
{
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
// No hardware encoder/filter present: fall back to the decode accel (a hardware
|
||||
// decode + software encode still pays an init cost worth attributing).
|
||||
const string HwAccelFlag = "-hwaccel ";
|
||||
int index = arguments.IndexOf(HwAccelFlag, StringComparison.Ordinal);
|
||||
if (index >= 0)
|
||||
{
|
||||
string rest = arguments[(index + HwAccelFlag.Length)..].TrimStart();
|
||||
int end = rest.IndexOf(' ');
|
||||
string value = end >= 0 ? rest[..end] : rest;
|
||||
if (value.Length > 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return "software";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Timing + descriptive result of an HLS cold-start's wait-for-first-segments phase (#350).
|
||||
/// <see cref="ProcessStartup"/> is Phase A (wait entry -> playlist file exists — approximately
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public readonly record struct PlaylistSegmentsResult(
|
||||
TimeSpan ProcessStartup,
|
||||
TimeSpan SegmentFill,
|
||||
int SegmentsReached,
|
||||
int InitialSegmentCount,
|
||||
bool DeadlineExpired,
|
||||
ColdStartFeatures Features);
|
||||
@@ -1,3 +1,11 @@
|
||||
namespace ErsatzTV.Core.Health;
|
||||
|
||||
public record HealthCheckLink(string Link);
|
||||
// A remediation link attached to a health-check result: where the user should go to
|
||||
// investigate or fix the reported problem, and whether that is an external doc or an
|
||||
// in-app SPA route. `Target` is the URL (ExternalDoc) or the /app path (AppRoute).
|
||||
public record HealthCheckLink(string Target, HealthCheckLinkKind Kind)
|
||||
{
|
||||
public static HealthCheckLink ExternalDoc(string url) => new(url, HealthCheckLinkKind.ExternalDoc);
|
||||
|
||||
public static HealthCheckLink AppRoute(string path) => new(path, HealthCheckLinkKind.AppRoute);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Health;
|
||||
|
||||
public enum HealthCheckLinkKind
|
||||
{
|
||||
// Opens an external documentation page in a new tab.
|
||||
ExternalDoc = 0,
|
||||
|
||||
// Navigates to an in-app SPA route (an /app/... path).
|
||||
AppRoute = 1
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
@@ -10,5 +10,5 @@ public interface IHlsSessionWorker : IDisposable
|
||||
void PlayoutUpdated();
|
||||
HlsSessionModel GetModel();
|
||||
Task Run(string channelNumber, Option<TimeSpan> idleTimeout, CancellationToken incomingCancellationToken);
|
||||
Task WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
|
||||
Task<PlaylistSegmentsResult> WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
@@ -9,4 +9,5 @@ public record CollectionWithItems(
|
||||
List<MediaItem> MediaItems,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
bool UseCustomOrder);
|
||||
bool UseCustomOrder,
|
||||
int Weight = 1);
|
||||
|
||||
@@ -1217,7 +1217,8 @@ public class SchedulingEngine(
|
||||
return _state;
|
||||
}
|
||||
|
||||
private static Option<IMediaCollectionEnumerator> EnumeratorForContent(
|
||||
// instance rather than static so an unsupported order can be reported (#70)
|
||||
private Option<IMediaCollectionEnumerator> EnumeratorForContent(
|
||||
List<MediaItem> items,
|
||||
CollectionEnumeratorState state,
|
||||
PlaybackOrder playbackOrder,
|
||||
@@ -1235,6 +1236,12 @@ public class SchedulingEngine(
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
}
|
||||
|
||||
// None means the caller's foreach never runs, so the content is simply absent from the playout with
|
||||
// nothing said. Report it instead of leaving a silently empty schedule (#70).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
|
||||
playbackOrder);
|
||||
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1383,6 +1383,14 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
state,
|
||||
activeSchedule.RandomStartPoint,
|
||||
cancellationToken);
|
||||
case PlaybackOrder.WeightedShuffle:
|
||||
return new WeightedShuffleCollectionEnumerator(
|
||||
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
||||
_mediaCollectionRepository,
|
||||
collectionKey,
|
||||
cancellationToken),
|
||||
state,
|
||||
cancellationToken);
|
||||
case PlaybackOrder.MultiEpisodeShuffle when
|
||||
collectionKey.CollectionType == CollectionType.TelevisionShow &&
|
||||
collectionKey.MediaItemId.HasValue:
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Weighted / fair-share distribution (issue #70). Picks a *source* by smooth weighted round-robin,
|
||||
/// then takes that source's next item. A source's <see cref="CollectionWithItems.Weight" /> is its
|
||||
/// relative share of airtime: weights 3:1 emit <c>A A B A</c> per rotation.
|
||||
/// <para>
|
||||
/// Fair-share is the equal-weights degenerate case (the default, since Weight defaults to 1):
|
||||
/// every source airs equally often regardless of library size, so a small show loops while a
|
||||
/// large one works through its items. This is what distinguishes it from
|
||||
/// <see cref="ShuffleInOrderCollectionEnumerator" />, whose balanced shuffle plays every item
|
||||
/// exactly once per cycle and therefore leaves airtime proportional to collection size
|
||||
/// (it prevents clumping, not domination).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deterministic and stateless: the emitted sequence is a pure function of
|
||||
/// (<see cref="CollectionEnumeratorState.Seed" />, <see cref="CollectionEnumeratorState.Index" />),
|
||||
/// so it restores by replay exactly like its siblings and needs no per-source persisted counters.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class WeightedShuffleCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Upper bound on the precomputed rotation, so a large collection paired with a low weight
|
||||
/// (picks needed grows as items * totalWeight / weight) can't allocate without limit. Hitting the
|
||||
/// clamp only means the largest source doesn't finish its items within one rotation before the
|
||||
/// reseed; the weight ratio itself is unaffected.
|
||||
/// </summary>
|
||||
private const int MaxCycleLength = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds the avoid-an-immediate-repeat retry when a rotation wraps. Unlike
|
||||
/// <see cref="ShuffleInOrderCollectionEnumerator" />, whose reshuffle randomizes which item leads the
|
||||
/// next cycle, this order's lead item is decided by weight — the heaviest source always wins the first
|
||||
/// pick. So when that source has a single item the lead never changes and an unbounded retry would spin
|
||||
/// forever. Avoiding a back-to-back repeat is a nicety; never terminating is not.
|
||||
/// </summary>
|
||||
private const int MaxReshuffleAttempts = 10;
|
||||
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private readonly IList<CollectionWithItems> _collections;
|
||||
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
|
||||
private Random _random;
|
||||
private MediaItem[] _shuffled;
|
||||
|
||||
public WeightedShuffleCollectionEnumerator(
|
||||
IList<CollectionWithItems> collections,
|
||||
CollectionEnumeratorState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CurrentIncludeInProgramGuide = Option<bool>.None;
|
||||
|
||||
_collections = collections;
|
||||
_cancellationToken = cancellationToken;
|
||||
|
||||
int cycleLength = CycleLength(collections);
|
||||
if (cycleLength > 0 && state.Index >= cycleLength)
|
||||
{
|
||||
state.Index = 0;
|
||||
state.Seed = new Random(state.Seed).Next();
|
||||
}
|
||||
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
// computed over every source's items, not the current rotation: a rotation can be a strict subset
|
||||
// (see MaxCycleLength) and is rebuilt on every wrap, so caching over it would go stale
|
||||
_lazyMinimumDuration =
|
||||
new Lazy<Option<TimeSpan>>(() =>
|
||||
_collections
|
||||
.Bind(c => c.MediaItems)
|
||||
.Bind(i => i.GetNonZeroDuration())
|
||||
.OrderBy(identity)
|
||||
.HeadOrNone());
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
{
|
||||
MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetState(CollectionEnumeratorState state)
|
||||
{
|
||||
// only rebuild if needed
|
||||
if (State.Seed != state.Seed)
|
||||
{
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
}
|
||||
|
||||
State.Seed = state.Seed;
|
||||
State.Index = state.Index;
|
||||
}
|
||||
|
||||
public string SchedulingContextName => "Weighted Shuffle";
|
||||
|
||||
public CollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _shuffled.Length != 0 ? _shuffled[State.Index % _shuffled.Length] : None;
|
||||
|
||||
public Option<bool> CurrentIncludeInProgramGuide { get; }
|
||||
|
||||
public void MoveNext(Option<DateTimeOffset> scheduledAt)
|
||||
{
|
||||
if (_shuffled.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((State.Index + 1) % _shuffled.Length == 0)
|
||||
{
|
||||
Option<MediaItem> tail = Current;
|
||||
|
||||
State.Index = 0;
|
||||
var attempts = 0;
|
||||
do
|
||||
{
|
||||
State.Seed = _random.Next();
|
||||
_random = new Random(State.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
attempts++;
|
||||
// guard on the rotation, not the raw collection count: an empty source contributes nothing,
|
||||
// so a 2-collection/1-item rotation would otherwise burn every attempt chasing an
|
||||
// impossible non-repeat on every wrap
|
||||
} while (!_cancellationToken.IsCancellationRequested && _shuffled.Length > 1 &&
|
||||
attempts < MaxReshuffleAttempts &&
|
||||
Current.Map(x => x.Id) == tail.Map(x => x.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
State.Index++;
|
||||
}
|
||||
|
||||
if (_shuffled.Length > 0)
|
||||
{
|
||||
State.Index %= _shuffled.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
|
||||
|
||||
public int Count => _shuffled.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Length of one rotation: enough picks for the source that needs the most of them to work through
|
||||
/// all of its items at its share of the rotation. Sources needing fewer picks loop within the
|
||||
/// rotation — that looping is exactly what makes equal weights mean equal airtime.
|
||||
/// </summary>
|
||||
private static int CycleLength(IList<CollectionWithItems> collections)
|
||||
{
|
||||
List<CollectionWithItems> active = ActiveSources(collections);
|
||||
if (active.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long totalWeight = active.Sum(c => (long)EffectiveWeight(c));
|
||||
|
||||
var length = 0;
|
||||
foreach (CollectionWithItems collection in active)
|
||||
{
|
||||
// picks needed for this source to emit every item once, given it wins Weight of every totalWeight picks
|
||||
var needed = (int)Math.Min(
|
||||
MaxCycleLength,
|
||||
Math.Ceiling(collection.MediaItems.Count * (double)totalWeight / EffectiveWeight(collection)));
|
||||
length = Math.Max(length, needed);
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
private static List<CollectionWithItems> ActiveSources(IList<CollectionWithItems> collections) =>
|
||||
collections.Filter(c => c.MediaItems.Count > 0).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Weight as the rotation should treat it. The write path bounds this (<see cref="MultiCollectionItemWeight" />),
|
||||
/// but a row can predate that gate, so it is clamped rather than trusted: an out-of-range value must not
|
||||
/// drop a source from the channel (0 or negative) or overflow the weight sum inside a playout build.
|
||||
/// </summary>
|
||||
private static int EffectiveWeight(CollectionWithItems collection) =>
|
||||
Math.Clamp(collection.Weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum);
|
||||
|
||||
private static MediaItem[] Build(IList<CollectionWithItems> collections, Random random)
|
||||
{
|
||||
List<CollectionWithItems> active = ActiveSources(collections);
|
||||
if (active.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var sources = active
|
||||
.Map(c => new WeightedSource { Weight = EffectiveWeight(c), Items = OrderItems(c, random) })
|
||||
.ToList();
|
||||
|
||||
int totalWeight = sources.Sum(s => s.Weight);
|
||||
int cycleLength = CycleLength(collections);
|
||||
|
||||
var result = new List<MediaItem>(cycleLength);
|
||||
for (var i = 0; i < cycleLength; i++)
|
||||
{
|
||||
// smooth weighted round-robin: every source gains its weight, the richest wins and pays the total.
|
||||
// Strict '>' keeps ties on the earliest source in list order, which makes the sequence deterministic.
|
||||
WeightedSource pick = null;
|
||||
foreach (WeightedSource source in sources)
|
||||
{
|
||||
source.Accumulator += source.Weight;
|
||||
if (pick is null || source.Accumulator > pick.Accumulator)
|
||||
{
|
||||
pick = source;
|
||||
}
|
||||
}
|
||||
|
||||
pick.Accumulator -= totalWeight;
|
||||
result.Add(pick.Items[pick.Cursor % pick.Items.Length]);
|
||||
pick.Cursor++;
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static MediaItem[] OrderItems(CollectionWithItems collectionWithItems, Random random)
|
||||
{
|
||||
// A custom-ordered collection is an explicit user sequence, so it is honored rather than shuffled
|
||||
// (same rule as ShuffleInOrderCollectionEnumerator). Otherwise the source's items are shuffled per
|
||||
// rotation, which is what makes the reseed on wrap produce a different rotation.
|
||||
if (collectionWithItems.UseCustomOrder)
|
||||
{
|
||||
return collectionWithItems.MediaItems.ToArray();
|
||||
}
|
||||
|
||||
return Shuffle(collectionWithItems.MediaItems, random);
|
||||
}
|
||||
|
||||
private static MediaItem[] Shuffle(IEnumerable<MediaItem> list, Random random)
|
||||
{
|
||||
MediaItem[] copy = list.ToArray();
|
||||
|
||||
int n = copy.Length;
|
||||
while (n > 1)
|
||||
{
|
||||
n--;
|
||||
int k = random.Next(n + 1);
|
||||
(copy[k], copy[n]) = (copy[n], copy[k]);
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
private class WeightedSource
|
||||
{
|
||||
public int Weight { get; init; }
|
||||
public MediaItem[] Items { get; init; }
|
||||
public int Cursor { get; set; }
|
||||
public int Accumulator { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
cancellationToken);
|
||||
break;
|
||||
|
||||
// playlist is handled later
|
||||
// playlist is handled later
|
||||
}
|
||||
|
||||
_mediaItems[content.Key] = items;
|
||||
@@ -178,7 +178,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
switch (Enum.Parse<PlaybackOrder>(content.Order, true))
|
||||
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
|
||||
switch (parsedOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
return new ChronologicalMediaCollectionEnumerator(items, state);
|
||||
@@ -190,6 +191,13 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
}
|
||||
|
||||
// this path schedules nothing for the content, which is indistinguishable from "no items" downstream.
|
||||
// Orders are addressed by name here, so any order the enum knows parses fine and then lands here --
|
||||
// say so, rather than leaving an empty schedule to be explained (#70).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
|
||||
parsedOrder);
|
||||
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MySqlConnector;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies MySQL provider exceptions wrapped by EF Core. Wired to
|
||||
/// <see cref="ErsatzTV.Infrastructure.Data.TvContext.IsUniqueConstraintViolation" /> at startup.
|
||||
/// </summary>
|
||||
public static class MySqlErrorClassifier
|
||||
{
|
||||
// ER_DUP_ENTRY (1062): a duplicate value for a PRIMARY KEY or UNIQUE index.
|
||||
private const int ErDupEntry = 1062;
|
||||
|
||||
public static bool IsUniqueConstraintViolation(DbUpdateException ex) =>
|
||||
ex.InnerException is MySqlException { Number: ErDupEntry };
|
||||
}
|
||||
Generated
+7250
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiCollectionItemWeight : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7260
File diff suppressed because it is too large
Load Diff
+56
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCollectionOwnedByChannelId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OwnedByChannelId",
|
||||
table: "SmartCollection",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OwnedByChannelId",
|
||||
table: "MultiCollection",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SmartCollection_OwnedByChannelId",
|
||||
table: "SmartCollection",
|
||||
column: "OwnedByChannelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MultiCollection_OwnedByChannelId",
|
||||
table: "MultiCollection",
|
||||
column: "OwnedByChannelId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_SmartCollection_OwnedByChannelId",
|
||||
table: "SmartCollection");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MultiCollection_OwnedByChannelId",
|
||||
table: "MultiCollection");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnedByChannelId",
|
||||
table: "SmartCollection");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnedByChannelId",
|
||||
table: "MultiCollection");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -1780,6 +1780,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.UseCollation("utf8mb4_general_ci");
|
||||
|
||||
b.Property<int?>("OwnedByChannelId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("int");
|
||||
@@ -1789,6 +1792,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnedByChannelId");
|
||||
|
||||
b.ToTable("MultiCollection", (string)null);
|
||||
});
|
||||
|
||||
@@ -1806,6 +1811,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "CollectionId");
|
||||
|
||||
b.HasIndex("CollectionId");
|
||||
@@ -1827,6 +1837,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "SmartCollectionId");
|
||||
|
||||
b.HasIndex("SmartCollectionId");
|
||||
@@ -3515,6 +3530,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.UseCollation("utf8mb4_general_ci");
|
||||
|
||||
b.Property<int?>("OwnedByChannelId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Query")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
@@ -3523,6 +3541,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnedByChannelId");
|
||||
|
||||
b.ToTable("SmartCollection", (string)null);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies SQLite provider exceptions wrapped by EF Core. Wired to
|
||||
/// <see cref="ErsatzTV.Infrastructure.Data.TvContext.IsUniqueConstraintViolation" /> at startup.
|
||||
/// </summary>
|
||||
public static class SqliteErrorClassifier
|
||||
{
|
||||
// SQLITE_CONSTRAINT (primary result code 19), narrowed to the two extended codes that mean a
|
||||
// duplicate key: SQLITE_CONSTRAINT_PRIMARYKEY (1555) and SQLITE_CONSTRAINT_UNIQUE (2067). Other
|
||||
// constraint failures (FK 787, NOT NULL 1299, CHECK 275) are deliberately NOT treated as duplicates.
|
||||
private const int SqliteConstraint = 19;
|
||||
private const int SqliteConstraintPrimaryKey = 1555;
|
||||
private const int SqliteConstraintUnique = 2067;
|
||||
|
||||
public static bool IsUniqueConstraintViolation(DbUpdateException ex) =>
|
||||
ex.InnerException is SqliteException { SqliteErrorCode: SqliteConstraint } inner
|
||||
&& inner.SqliteExtendedErrorCode is SqliteConstraintPrimaryKey or SqliteConstraintUnique;
|
||||
}
|
||||
Generated
+7075
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiCollectionItemWeight : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7085
File diff suppressed because it is too large
Load Diff
+56
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCollectionOwnedByChannelId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OwnedByChannelId",
|
||||
table: "SmartCollection",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OwnedByChannelId",
|
||||
table: "MultiCollection",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SmartCollection_OwnedByChannelId",
|
||||
table: "SmartCollection",
|
||||
column: "OwnedByChannelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MultiCollection_OwnedByChannelId",
|
||||
table: "MultiCollection",
|
||||
column: "OwnedByChannelId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_SmartCollection_OwnedByChannelId",
|
||||
table: "SmartCollection");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MultiCollection_OwnedByChannelId",
|
||||
table: "MultiCollection");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnedByChannelId",
|
||||
table: "SmartCollection");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnedByChannelId",
|
||||
table: "MultiCollection");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -1703,6 +1703,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int?>("OwnedByChannelId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("INTEGER");
|
||||
@@ -1712,6 +1715,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnedByChannelId");
|
||||
|
||||
b.ToTable("MultiCollection", (string)null);
|
||||
});
|
||||
|
||||
@@ -1729,6 +1734,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "CollectionId");
|
||||
|
||||
b.HasIndex("CollectionId");
|
||||
@@ -1750,6 +1760,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "SmartCollectionId");
|
||||
|
||||
b.HasIndex("SmartCollectionId");
|
||||
@@ -3362,6 +3377,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int?>("OwnedByChannelId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Query")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -3370,6 +3388,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnedByChannelId");
|
||||
|
||||
b.ToTable("SmartCollection", (string)null);
|
||||
});
|
||||
|
||||
|
||||
+23
-3
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -19,6 +19,10 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
|
||||
builder.HasIndex(mc => mc.Name)
|
||||
.IsUnique();
|
||||
|
||||
// Cleanup + list-hiding of the system multi collection an auto-tune weighted channel owns (#425).
|
||||
// Nullable: null = a normal user multi collection.
|
||||
builder.HasIndex(mc => mc.OwnedByChannelId);
|
||||
|
||||
builder.HasMany(m => m.Collections)
|
||||
.WithMany(m => m.MultiCollections)
|
||||
.UsingEntity<MultiCollectionItem>(
|
||||
@@ -30,7 +34,16 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
|
||||
.WithMany(mc => mc.MultiCollectionItems)
|
||||
.HasForeignKey(mci => mci.MultiCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId }));
|
||||
j =>
|
||||
{
|
||||
j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId });
|
||||
|
||||
// default 1 = fair share (#70). Must be a DB default too, or existing rows migrate to 0 --
|
||||
// a value that means nothing on a share-of-airtime scale. The enumerator clamps such a
|
||||
// row to the floor, so it rotates rather than vanishing; the backfill should still be
|
||||
// right at the source.
|
||||
j.Property(mci => mci.Weight).HasDefaultValue(1);
|
||||
});
|
||||
|
||||
builder.HasMany(m => m.SmartCollections)
|
||||
.WithMany(m => m.MultiCollections)
|
||||
@@ -43,6 +56,13 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
|
||||
.WithMany(mc => mc.MultiCollectionSmartItems)
|
||||
.HasForeignKey(mci => mci.MultiCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId }));
|
||||
j =>
|
||||
{
|
||||
j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId });
|
||||
|
||||
// mirrors MultiCollectionItem above -- omitting it would silently un-weight
|
||||
// every smart-collection member of a multi collection
|
||||
j.Property(mci => mci.Weight).HasDefaultValue(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -16,5 +16,9 @@ public class SmartCollectionConfiguration : IEntityTypeConfiguration<SmartCollec
|
||||
|
||||
builder.HasIndex(sc => sc.Name)
|
||||
.IsUnique();
|
||||
|
||||
// Cleanup + list-hiding of the per-source system smart collections an auto-tune weighted channel
|
||||
// owns (#425). Nullable: null = a normal user smart collection.
|
||||
builder.HasIndex(sc => sc.OwnedByChannelId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -14,6 +14,9 @@ public class ChannelRepository(IDbContextFactory<TvContext> dbContextFactory) :
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.Include(c => c.Playouts)
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.ThenInclude(mc => mc.Playouts)
|
||||
.OrderBy(c => c.Id)
|
||||
.SingleOrDefaultAsync(c => c.Id == id)
|
||||
.Map(Optional);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -738,7 +738,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
||||
sortedItems,
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder));
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder,
|
||||
multiCollectionItem.Weight));
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -751,7 +752,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
||||
items,
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder));
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder,
|
||||
multiCollectionItem.Weight));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,7 +771,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
||||
items,
|
||||
multiCollectionSmartItem.ScheduleAsGroup,
|
||||
multiCollectionSmartItem.PlaybackOrder,
|
||||
false));
|
||||
false,
|
||||
multiCollectionSmartItem.Weight));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user