Compare commits

..
Author SHA1 Message Date
timothyandClaude Opus 4.8 f76f8a939c design(388): mirror full ChicoryTV design system to prod + design-sync reminder hook
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m32s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 6m33s
Bulk mirror sweep (#388): author/rebuild a prototype mockup for every shipped SPA screen so
the Claude Design project (eb3b6122 / local design-system/) faithfully reflects prod.

- 40 screens authored (mockups: visual/layout parity, no logic) via a fan-out workflow;
  8 stale prototypes rebuilt (Dashboard, ChannelBuilder, Guide, Schedules, Playouts, Settings, …),
  32 net-new (media/sources/system/auth). Retired Epg/ScheduleEditor/ScheduleLibrary.
- New screens.js = single source of truth for the inventory; Shell.jsx nav + app.html screen map
  both build from it (nav groups mirror web/src/app/routes.tsx). app.html is now hash-routed.
- All 42 views verified rendering headlessly across the 3 themes (126/126, 0 real errors).
- design-sync-reminder.sh hook (+ settings.json): mechanical, fail-open, once/session nudge to
  pull-first (PreToolUse Write|Edit on web/src/**.{tsx,css}) and mirror/push-back (Stop) — keeps
  the design system from drifting from prod going forward.
- docs/design-sync.md: document the bulk-sweep structure + the reminder hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:14:11 +02:00
213 changed files with 673 additions and 43123 deletions
@@ -1,43 +0,0 @@
#!/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
-99
View File
@@ -1,99 +0,0 @@
#!/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
-5
View File
@@ -14,11 +14,6 @@
"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
}
]
},
-136
View File
@@ -1,136 +0,0 @@
name: Build CI Toolchain Image
# Builds the shared CI toolchain image (.NET 10 SDK + Node 22 + prod-identical ffmpeg) and
# pushes it to the Gitea container registry (ersatztv#390). The toolchain jobs in
# docker-build.yml consume it via `container:`, pinned to an immutable :<sha>.
#
# push touching docker/ci/** -> :<short-sha> (+ :latest only from main)
# workflow_dispatch -> manual rebuild
# schedule (weekly) -> picks up base-image security updates
#
# Deliberately separate from docker-build.yml: this image changes rarely (a Dockerfile edit or
# the weekly cron), while docker-build.yml runs on every push/PR. Coupling them would rebuild a
# ~2GB toolchain image on every commit.
#
# ROLLOUT NOTE: the jobs pin an immutable :<sha>, never :latest — a broken toolchain image would
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is therefore
# a deliberate two-step: merge a docker/ci/Dockerfile change (this workflow publishes a new :<sha>),
# then update the pin in docker-build.yml in a follow-up PR whose CI proves the new image works.
# See docs/ci-cd.md -> "CI toolchain image".
#
# Like docker-build.yml: the Gitea registry is HTTP-only, so BuildKit needs the inline
# `http = true` config (it does not inherit the host daemon's insecure-registries setting).
on:
workflow_dispatch:
push:
paths:
- 'docker/ci/**'
- '.gitea/workflows/ci-image.yml'
schedule:
# Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main).
#
# What this cron does and does NOT do — it does **not** update any running job. The jobs in
# docker-build.yml pin an immutable :<sha> (deliberately), so a rebuilt image is consumed only
# when a human bumps that pin. Its actual value is twofold:
# 1. a weekly CANARY — catches "the toolchain image no longer builds" (a NodeSource/apt/base
# change) at a time of our choosing, rather than when you next need to bump the pin;
# 2. it leaves a freshly-patched :latest so the next pin bump starts from a current base.
# `no-cache` on this path is what makes both real: with the shared :buildcache, the
# `apt-get update && apt-get install` layer would restore from cache and re-fetch nothing.
- cron: '0 5 * * 1'
# Serialize per ref: concurrent builds would race on the shared :buildcache tag.
# No cancel-in-progress — a half-pushed toolchain image is worse than a redundant build.
concurrency:
group: ersatztv-ci-image-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: 192.168.1.95:3000
CI_IMAGE: 192.168.1.95:3000/timothy/ersatztv-ci
jobs:
build:
name: Build & push CI image
# `small` = the small-jobs runner lane. This is a docker-only job (no toolchain needed —
# it *builds* the toolchain), same as docker-build.yml's `build` job.
runs-on: small
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# only docker/ci/Dockerfile is needed; no git describe/log here
fetch-depth: 1
- name: Compute tags
id: meta
run: |
set -euo pipefail
SHORT=$(git rev-parse --short HEAD)
# Always publish the immutable :<sha> — that is what docker-build.yml pins.
TAGS=("${CI_IMAGE}:${SHORT}")
# :latest is a convenience/floating pointer for humans and the weekly rebuild; jobs must
# never consume it. Only main may move it.
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
TAGS+=("${CI_IMAGE}:latest")
fi
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
{
echo "tags<<__EOT__"
printf '%s\n' "${TAGS[@]}"
echo "__EOT__"
} >> "$GITHUB_OUTPUT"
printf 'tag: %s\n' "${TAGS[@]}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."192.168.1.95:3000"]
http = true
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/ci/Dockerfile
platforms: linux/amd64
push: true
provenance: false
tags: ${{ steps.meta.outputs.tags }}
# The scheduled rebuild must bypass the cache or it is pointless: `mode=max` buildcache
# would restore the `apt-get update && apt-get install` layer verbatim and pull in none of
# the base updates the cron exists to collect. Push-triggered builds keep the cache.
no-cache: ${{ github.event_name == 'schedule' }}
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache,mode=max,ignore-error=true
# The Dockerfile's own build-time smoke test (dotnet --info, node, ffmpeg, ...) already ran
# inside the build. This re-checks the *pushed* artifact end-to-end: that the registry copy
# pulls and its toolchain runs, which is exactly what `container:` will do on every job.
- name: Verify the pushed image
run: |
set -euo pipefail
IMG="${CI_IMAGE}:${{ steps.meta.outputs.short }}"
echo "Pulling ${IMG}"
docker pull "$IMG"
docker run --rm --entrypoint /bin/bash "$IMG" -euxc '
dotnet --version
dotnet ef --version
node --version
ffmpeg -version | head -1
git --version
python3 --version
# reportgenerator --version exits 1 ("No report files specified"); probe the shim.
command -v reportgenerator
'
echo "CI image OK. Pin this in .gitea/workflows/docker-build.yml -> CI_IMAGE_REF:"
echo " ${IMG}"
-11
View File
@@ -22,17 +22,6 @@ 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
+57 -339
View File
@@ -12,30 +12,6 @@ name: Build ErsatzTV Image
#
# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins
# `:prod`, never `:latest` (enforced in the prod compose — server-management#481).
#
# TOOLCHAIN IMAGE (ersatztv#390): the jobs that need a toolchain (`test`, `migrations`,
# `functional-e2e`, `api-docs`, `format`) run inside our shared CI image via `container:`
# instead of installing .NET/Node/ffmpeg per run. It ships the .NET 10 SDK, Node 22,
# prod-identical ffmpeg, and the dotnet-ef/reportgenerator global tools — so those jobs carry
# no setup-dotnet, no setup-node, no apt, no `dotnet tool install`. Built by ci-image.yml from
# docker/ci/Dockerfile. Project deps (NuGet/npm) are NOT baked in and stay on actions/cache.
#
# The pin below is an IMMUTABLE :<sha>, never :latest — a bad toolchain push would otherwise
# break every converted job at once. It is repeated per job because `jobs.<id>.container.image`
# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md ->
# "CI toolchain image" for the two-step procedure.
#
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci: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:
@@ -56,49 +32,14 @@ concurrency:
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Inside a `container:`, act_runner does NOT default `run` steps to bash — it falls back to
# `sh -e {0}` (dash), because it can't assume bash exists in an arbitrary image. Every multi-line
# script here is bash (`set -o pipefail`, arrays, `shopt`, `mapfile`), so dash fails them
# immediately: `set: Illegal option -o pipefail`. Declare the shell once for the whole workflow
# rather than per step. Non-container jobs already defaulted to bash, so this changes nothing for
# them. (ersatztv#390 — see docs/ci-cd.md -> "CI toolchain image".)
defaults:
run:
shell: bash
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)
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -107,15 +48,12 @@ 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: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -123,59 +61,46 @@ 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
- name: Setup Node
uses: actions/setup-node@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
node-version: '22.x'
cache: npm
cache-dependency-path: web/package-lock.json
- 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
@@ -186,7 +111,6 @@ 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
@@ -197,8 +121,10 @@ jobs:
exit 0
fi
echo "Found ${#reports[@]} coverage report(s)."
# reportgenerator is baked into the CI toolchain image (docker/ci/Dockerfile) and already
# on PATH — no per-run `dotnet tool` install. Bump its version there (ersatztv#390).
# `update` is install-or-update (idempotent, unlike `install` which errors if the tool
# is already present under set -e); pinned for reproducible summary output.
dotnet tool update --global dotnet-reportgenerator-globaltool --version 5.5.10 >/dev/null
export PATH="$PATH:$HOME/.dotnet/tools"
reportgenerator \
"-reports:coverage/**/coverage.cobertura.xml" \
"-targetdir:coverage/report" \
@@ -210,80 +136,9 @@ 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
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
# can't block image builds until it's proven reliable on the runner. Promote to a
# required check / build dependency once green. (ersatztv#13)
@@ -296,42 +151,7 @@ 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
@@ -342,14 +162,12 @@ 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: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -357,21 +175,19 @@ 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
# — no per-run `dotnet tool install`. Bump its version there (ersatztv#390).
- name: Install dotnet-ef
run: dotnet tool install --global dotnet-ef --version 9.0.12
# 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
export PATH="$PATH:$HOME/.dotnet/tools"
echo "::group::SQLite model drift (has-pending-model-changes)"
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
@@ -385,7 +201,6 @@ 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
@@ -395,6 +210,7 @@ jobs:
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;"
run: |
set -euo pipefail
export PATH="$PATH:$HOME/.dotnet/tools"
echo "::group::MySql model drift (has-pending-model-changes)"
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
@@ -423,32 +239,25 @@ 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
# 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.
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
# the unit-test gate — promote it to a required check / build dependency once it's proven
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
# Runs on PRs and on main (regression net); skipped for v* tag builds.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
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: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -456,40 +265,30 @@ 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
- name: Setup Node
uses: actions/setup-node@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
node-version: '22.x'
cache: npm
cache-dependency-path: web/package-lock.json
- 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. 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: Ensure ffmpeg is available
run: command -v ffmpeg >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y ffmpeg)
- name: Boot instance and run functional-E2E harness
if: steps.detect.outputs.docs_only != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
@@ -518,16 +317,8 @@ 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
@@ -550,7 +341,6 @@ 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: |
@@ -558,7 +348,6 @@ jobs:
http = true
- name: Login to Gitea registry
if: steps.detect.outputs.docs_only != 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -566,7 +355,6 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
if: steps.detect.outputs.docs_only != 'true'
uses: docker/build-push-action@v6
with:
context: .
@@ -582,16 +370,14 @@ 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')) && steps.detect.outputs.docs_only != 'true' }}
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
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"
# --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 \
docker run -d --name "$NAME" --memory 2g \
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
"$IMG"
@@ -642,57 +428,6 @@ jobs:
exit 1
fi
# BLOCKING (ersatztv#390): the CI toolchain image pin in this file must name the image that
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
#
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
# git+grep -> keep it off the build runners.
ci-image-pin:
name: CI image pin matches docker/ci
runs-on: small
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# need real history: `git log -- <path>` on a shallow clone can't find the last
# commit that touched the image sources
fetch-depth: 0
- name: Verify the pin matches the last-published image
run: |
set -euo pipefail
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
# only builds on pushes touching these paths — so the published image is named by the last
# commit to touch them.
#
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
echo "Image sources last changed in: ${expected}"
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
if [ "${#pins[@]}" -ne 1 ]; then
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
exit 1
fi
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
if [ -z "$pin_full" ]; then
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
exit 1
fi
if [ "$pin_full" != "$expected" ]; then
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
exit 1
fi
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
@@ -768,27 +503,7 @@ jobs:
# API path changed, the expensive steps skip and the job passes trivially.
api-docs:
name: API docs in sync (OpenAPI + endpoint index)
# `small` lane (ersatztv#390): this job is ~5s on the ~90% of PRs that touch no API path, but
# it was queueing ~29 min behind the heavy jobs in the contended `ubuntu-latest` lane, which
# only has bumblebee-runner (capacity 2) + ci-runner. `small` has capacity 4, the same base
# image, and answers in ~5s. Moving it here (and `format`) also drops `ubuntu-latest` from 5
# jobs to 3, which shortens the queue for `test`/`migrations`/`functional-e2e` too.
# This is only possible because `container:` makes the job self-contained — it no longer needs
# the runner image to supply .NET/Node.
#
# REVERTED to `ubuntu-latest` (server-management#604 / ersatztv#406). The caveat below the
# original #390 rationale turned out to be the deciding factor: on an API-touching PR this
# job does a full `dotnet build`, so it is NOT a small job, and "capacity 4 absorbs that" was
# only true while nothing enforced the SUM of the lanes' memory caps. It didn't: 6 slots x 10g
# on a 25 GiB host drove bumblebee to load 713 with 21 GiB swapped. The `small` lane is now
# sized for genuinely-tiny jobs, and #604 grew the `ubuntu-latest` lane instead (ci-runner
# 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source.
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
if: github.event_name == 'pull_request'
steps:
- name: Checkout
@@ -811,6 +526,12 @@ jobs:
echo "No API-surface change -> skipping regeneration (job passes)."
fi
- name: Setup .NET
if: steps.detect.outputs.api_changed == 'true'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
if: steps.detect.outputs.api_changed == 'true'
uses: actions/cache@v4
@@ -823,13 +544,13 @@ jobs:
if: steps.detect.outputs.api_changed == 'true'
run: dotnet restore
- name: Cache npm packages
- name: Setup Node
if: steps.detect.outputs.api_changed == 'true'
uses: actions/cache@v4
uses: actions/setup-node@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
node-version: '22.x'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install SPA dependencies
if: steps.detect.outputs.api_changed == 'true'
@@ -864,16 +585,7 @@ jobs:
# a status, so it is safe as a required check).
format:
name: Formatting (changed .cs conform to .editorconfig)
# Was on the `small` lane (ersatztv#390) to dodge a ~29 min queue; reverted to `ubuntu-latest`
# in ersatztv#406 — `dotnet format` needs the .NET SDK and real memory, so it does not belong
# in a lane sized for seconds-long shell jobs. See the api-docs job above for the full
# rationale; server-management#604 grew this lane so the queue it was dodging is gone.
runs-on: ubuntu-latest
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
if: github.event_name == 'pull_request'
steps:
- name: Checkout
@@ -897,6 +609,12 @@ jobs:
echo "No .cs change -> skipping format verify (job passes)."
fi
- name: Setup .NET
if: steps.detect.outputs.cs_changed == 'true'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
if: steps.detect.outputs.cs_changed == 'true'
uses: actions/cache@v4
-6
View File
@@ -15,12 +15,6 @@ 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,58 +38,6 @@ 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,16 +42,6 @@ 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,25 +197,13 @@ public class CreateChannelFromLineupHandler(
bool multiItem = normalized.Count >= 2;
// MultiCollection entries only support Shuffle / ShuffleInOrder / WeightedShuffle
// (mirrors PlayoutModeMustBeValid -- keep the two lists in step).
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder
or PlaybackOrder.WeightedShuffle))
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
{
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,22 +57,9 @@ 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;
@@ -176,13 +176,6 @@ 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);
}
@@ -1,4 +1,3 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
using MediatR;
@@ -9,27 +8,11 @@ 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,
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);
string Number);
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
{
@@ -4,29 +4,17 @@ 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,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ISmartCollectionCache smartCollectionCache)
public class CreateAutoTunedChannelsHandler(ISender mediator)
: 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)
@@ -54,53 +42,8 @@ public class CreateAutoTunedChannelsHandler(
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);
// 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
};
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
// 1. Create the smart collection that drives this channel.
Either<BaseError, SmartCollectionViewModel> scResult =
@@ -124,11 +67,11 @@ public class CreateAutoTunedChannelsHandler(
selection.Number,
group,
string.Empty,
logo,
ArtworkContentTypeModel.None,
IsEnabled: true,
ShowInEpg: true,
effectiveTemplateId,
advanced,
templateId,
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
[
new CreateChannelFromLineupItem(
LibraryBrowseMediaType.SmartCollection,
@@ -170,350 +113,4 @@ public class CreateAutoTunedChannelsHandler(
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,13 +0,0 @@
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
// Read-only enumeration of the distinct content-source members a proposed auto-tune channel's
// server-generated SmartCollection query resolves to (issue #384). The client passes axis+value; the
// server owns query generation (AutoTuneAxisMap.GenerateQuery) — the client never sends Lucene.
public record GetAutoTuneChannelMembers(
AutoTuneAxis Axis,
string Value,
int PageNum,
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -1,168 +0,0 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
// Runs the server-owned SmartCollection query for an axis value through the same search index the
// built channel's playout uses, then rolls the matching leaf items up to their distinct content
// sources: parent shows for the episode axes, movies for the movie-genre axis. Feeds the Auto-Tune
// DetailPanel's read-only-by-default source list (#383/#384).
public class GetAutoTuneChannelMembersHandler(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAutoTuneChannelMembers, PagedLibraryBrowseItemsResponseModel>
{
// Mirrors MediaCollectionRepository.GetSmartCollectionItems: the index dislikes a zero limit, so
// pull up to 10k matching leaf items and group in memory. A source whose matches fall entirely
// beyond this cap would be under-counted (the same staleness bound the smart-collection path
// already accepts) — realistic axis values resolve to far fewer than 10k items.
private const int SearchLimit = 10_000;
public async Task<PagedLibraryBrowseItemsResponseModel> Handle(
GetAutoTuneChannelMembers request,
CancellationToken cancellationToken)
{
// An out-of-range numeric axis binds successfully (ModelState stays valid, so [ApiController]'s
// auto-400 does not fire); treat it as no results rather than letting GenerateQuery's
// ArgumentOutOfRangeException surface as a 500 — matching #69's EnumerateAxis `_ => []`.
if (string.IsNullOrWhiteSpace(request.Value) || !Enum.IsDefined(request.Axis))
{
return new PagedLibraryBrowseItemsResponseModel(0, []);
}
string query = AutoTuneAxisMap.GenerateQuery(request.Axis, request.Value);
SearchResult searchResults = await searchIndex.Search(
query,
string.Empty,
0,
SearchLimit,
cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return request.Axis switch
{
AutoTuneAxis.MovieGenre => await MovieMembers(dbContext, searchResults, request, cancellationToken),
_ => await ShowMembers(dbContext, searchResults, request, cancellationToken)
};
}
// Episode axes (TvShow / TvGenre): roll matching episodes up to their distinct parent shows.
private static async Task<PagedLibraryBrowseItemsResponseModel> ShowMembers(
TvContext dbContext,
SearchResult searchResults,
GetAutoTuneChannelMembers request,
CancellationToken cancellationToken)
{
List<int> episodeIds = searchResults.Items
.Where(i => i.Type == LuceneSearchIndex.EpisodeType)
.Select(i => i.Id)
.ToList();
if (episodeIds.Count == 0)
{
return new PagedLibraryBrowseItemsResponseModel(0, []);
}
// Per-show count is the number of episodes THIS channel's query contributes, not the show's
// total episode count (Episode -> Season -> ShowId; proven query style from LibraryBrowseItemMapper).
Dictionary<int, int> matchCountByShow = (await dbContext.Episodes
.AsNoTracking()
.Where(e => episodeIds.Contains(e.Id))
.Select(e => new { e.Id, e.Season.ShowId })
.ToListAsync(cancellationToken))
.GroupBy(x => x.ShowId)
.ToDictionary(g => g.Key, g => g.Count());
List<int> showIds = matchCountByShow.Keys.ToList();
// Order the distinct shows by title, then page (the show set is bounded — dozens, not thousands).
List<int> orderedShowIds = (await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => showIds.Contains(sm.ShowId))
.Select(sm => new { sm.ShowId, sm.Title })
.ToListAsync(cancellationToken))
.GroupBy(x => x.ShowId)
.Select(g => new { ShowId = g.Key, Title = g.OrderBy(x => x.Title).Select(x => x.Title).FirstOrDefault() })
.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.ShowId)
.Select(x => x.ShowId)
.ToList();
int total = orderedShowIds.Count;
List<int> pageIds = orderedShowIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> hydrated =
await LibraryBrowseItemMapper.GetShows(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = hydrated.ToDictionary(s => s.Id);
// GetShows groups by show id, so restore the requested title order and override its total-episode
// ItemCount with the query-matching count.
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id] with
{
ItemCount = matchCountByShow.TryGetValue(id, out int count) ? count : byId[id].ItemCount
})
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
// Movie-genre axis: the matching movies are themselves the distinct content sources.
private static async Task<PagedLibraryBrowseItemsResponseModel> MovieMembers(
TvContext dbContext,
SearchResult searchResults,
GetAutoTuneChannelMembers request,
CancellationToken cancellationToken)
{
List<int> movieIds = searchResults.Items
.Where(i => i.Type == LuceneSearchIndex.MovieType)
.Select(i => i.Id)
.Distinct()
.ToList();
if (movieIds.Count == 0)
{
return new PagedLibraryBrowseItemsResponseModel(0, []);
}
List<int> orderedMovieIds = (await dbContext.MovieMetadata
.AsNoTracking()
.Where(mm => movieIds.Contains(mm.MovieId))
.Select(mm => new { mm.MovieId, mm.Title })
.ToListAsync(cancellationToken))
.GroupBy(x => x.MovieId)
.Select(g => new { MovieId = g.Key, Title = g.OrderBy(x => x.Title).Select(x => x.Title).FirstOrDefault() })
.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.MovieId)
.Select(x => x.MovieId)
.ToList();
int total = orderedMovieIds.Count;
List<int> pageIds = orderedMovieIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> hydrated =
await LibraryBrowseItemMapper.GetMovies(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = hydrated.ToDictionary(m => m.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
}
+3 -27
View File
@@ -1,4 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
@@ -6,29 +6,6 @@ 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,
@@ -96,7 +73,7 @@ internal static class Mapper
channel.ShowInEpg);
}
internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) =>
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
channel.Id,
channel.Number,
@@ -108,8 +85,7 @@ internal static class Mapper
channel.PreferredAudioLanguageCode,
GetStreamingMode(channel),
channel.IsEnabled,
channel.ShowInEpg,
playoutCount);
channel.ShowInEpg);
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(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
return channels.Map(ProjectToResponseModel).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,4 +11,21 @@ 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, GetPlayoutsCount(channel)));
.MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0));
}
@@ -60,10 +60,7 @@ public partial class GetChannelGuideHandler(
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(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 += $"&amp;access_token={SecurityElement.Escape(request.AccessToken)}";
accessTokenUri += $"&amp;access_token={request.AccessToken}";
}
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
@@ -1,7 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
@@ -71,32 +70,6 @@ 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,10 +26,4 @@
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ErsatzTV.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
+1 -16
View File
@@ -10,11 +10,7 @@ internal static class Mapper
result.Title,
GetStatus(result.Status),
result.Message,
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));
result.Link.MatchUnsafe(l => l.Link, () => null));
private static string GetStatus(HealthCheckStatus status) =>
status switch
@@ -23,17 +19,6 @@ 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,13 +55,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,13 +57,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,13 +54,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,52 +38,23 @@ 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)
{
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)
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await maybeCollection.Match(
Some: async collection =>
{
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.");
}
}
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.")));
}
private async Task<bool> ApplyAddItemsRequest(
private async Task<Unit> ApplyAddItemsRequest(
TvContext dbContext,
Collection collection,
AddItemsToCollection request,
@@ -104,10 +75,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. Terminal success (no retry).
// rebuilds for an idempotent re-add — #269.
if (toAddIds.Count == 0)
{
return true;
return Unit.Default;
}
List<MediaItem> toAdd = await dbContext.MediaItems
@@ -120,15 +91,7 @@ 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++;
// 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;
}
await dbContext.SaveChangesForcingVersion(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
@@ -141,7 +104,7 @@ public class AddItemsToCollectionHandler :
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return true;
return Unit.Default;
}
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,13 +55,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,13 +55,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,13 +57,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,13 +57,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,13 +55,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,13 +55,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,13 +55,7 @@ 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++;
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 dbContext.SaveChangesForcingVersion(CancellationToken.None);
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,8 +7,7 @@ public record CreateMultiCollectionItem(
int? CollectionId,
int? SmartCollectionId,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder,
int Weight = 1);
PlaybackOrder PlaybackOrder);
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,56 +51,42 @@ public class CreateMultiCollectionHandler :
private static Task<Validation<BaseError, MultiCollection>> Validate(
TvContext dbContext,
CreateMultiCollection request) =>
ValidateName(dbContext, request)
.BindT(name => ValidateWeights(request).Map(_ => name))
.MapT(name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Bind(i =>
ValidateName(dbContext, request).MapT(name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Bind(i =>
{
if (i.CollectionId.HasValue)
{
if (i.CollectionId.HasValue)
{
return Some(
new MultiCollectionItem
{
CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder,
Weight = i.Weight
});
}
return Some(
new MultiCollectionItem
{
CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return Option<MultiCollectionItem>.None;
})
return Option<MultiCollectionItem>.None;
})
.ToList(),
MultiCollectionSmartItems = request.Items.Bind(i =>
MultiCollectionSmartItems = request.Items.Bind(i =>
{
if (i.SmartCollectionId.HasValue)
{
if (i.SmartCollectionId.HasValue)
{
return Some(
new MultiCollectionSmartItem
{
SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder,
Weight = i.Weight
});
}
return Some(
new MultiCollectionSmartItem
{
SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
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,35 +73,7 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
ReplacePlaylistItems request,
CancellationToken cancellationToken) =>
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
.BindT(playlist => ValidateName(request).Map(_ => playlist));
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
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;
}
.BindT(playlist => CollectionTypesMustBeValid(request, playlist));
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext,
@@ -7,8 +7,7 @@ public record UpdateMultiCollectionItem(
int? CollectionId,
int? SmartCollectionId,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder,
int Weight = 1);
PlaybackOrder PlaybackOrder);
public record UpdateMultiCollection(
int MultiCollectionId,
@@ -76,8 +76,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
CollectionId = i.CollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder,
Weight = i.Weight
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemove = c.MultiCollectionItems
@@ -95,7 +94,6 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
item.Weight = incoming.Weight;
}
}
@@ -112,8 +110,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
SmartCollectionId = i.SmartCollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder,
Weight = i.Weight
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemoveSmart = c.MultiCollectionSmartItems
@@ -131,7 +128,6 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
item.Weight = incoming.Weight;
}
}
@@ -160,20 +156,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
TvContext dbContext,
UpdateMultiCollection request,
CancellationToken cancellationToken) =>
(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);
(await MultiCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
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,8 +70,7 @@ internal static class Mapper
multiCollectionItem.MultiCollectionId,
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder,
multiCollectionItem.Weight);
multiCollectionItem.PlaybackOrder);
private static MultiCollectionSmartItemViewModel ProjectToViewModel(
MultiCollectionSmartItem multiCollectionSmartItem) =>
@@ -79,8 +78,7 @@ internal static class Mapper
multiCollectionSmartItem.MultiCollectionId,
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder,
multiCollectionSmartItem.Weight);
multiCollectionSmartItem.PlaybackOrder);
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,5 +6,4 @@ public record MultiCollectionItemViewModel(
int MultiCollectionId,
MediaCollectionViewModel Collection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder,
int Weight = 1);
PlaybackOrder PlaybackOrder);
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
@@ -6,5 +6,4 @@ public record MultiCollectionSmartItemViewModel(
int MultiCollectionId,
SmartCollectionViewModel SmartCollection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder,
int Weight = 1);
PlaybackOrder PlaybackOrder);
@@ -1,4 +1,4 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -17,7 +17,6 @@ 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());
}
@@ -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,7 +16,6 @@ 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,7 +17,6 @@ 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,12 +13,9 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.MultiCollections
.CountAsync(mc => mc.OwnedByChannelId == null, cancellationToken);
int count = await dbContext.MultiCollections.CountAsync(cancellationToken);
IQueryable<MultiCollection> query = dbContext.MultiCollections
.AsNoTracking()
.Where(mc => mc.OwnedByChannelId == null);
IQueryable<MultiCollection> query = dbContext.MultiCollections.AsNoTracking();
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,12 +13,9 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.SmartCollections
.CountAsync(sc => sc.OwnedByChannelId == null, cancellationToken);
int count = await dbContext.SmartCollections.CountAsync(cancellationToken);
IQueryable<SmartCollection> query = dbContext.SmartCollections
.AsNoTracking()
.Where(sc => sc.OwnedByChannelId == null);
IQueryable<SmartCollection> query = dbContext.SmartCollections.AsNoTracking();
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,9 +63,6 @@ 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,30 +112,7 @@ 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 => 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;
}
.BindT(block => CollectionTypesMustBeValid(request, block));
private static Task<Validation<BaseError, Block>> BlockMustExist(
TvContext dbContext,
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public record GetSearchFieldCatalog : IRequest<List<SearchFieldResponseModel>>;
@@ -1,11 +0,0 @@
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,4 +1,3 @@
namespace ErsatzTV.Application.Search;
namespace ErsatzTV.Application.Search;
public record QuerySearchIndexAllItems(string Query, int PageNum, int PageSize)
: IRequest<SearchResultAllItemsViewModel>;
public record QuerySearchIndexAllItems(string Query) : IRequest<SearchResultAllItemsViewModel>;
@@ -1,5 +1,4 @@
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
@@ -9,69 +8,21 @@ public class QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex)
{
public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request,
CancellationToken cancellationToken)
{
int skip = request.PageNum * request.PageSize;
int limit = request.PageSize;
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));
(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);
}
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();
}
@@ -15,9 +15,6 @@ 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,9 +15,6 @@ 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)
@@ -1,58 +0,0 @@
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,17 +10,4 @@ public record SearchResultAllItemsViewModel(
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
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);
List<int> RemoteStreamIds);
@@ -1,5 +1,4 @@
using System.Diagnostics;
using System.IO.Abstractions;
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics;
@@ -76,10 +75,6 @@ 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)));
@@ -119,34 +114,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken)
.Map(maybeCount => maybeCount.Match(identity, () => 1));
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);
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
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,12 +55,6 @@ 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,
@@ -306,20 +300,17 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
public async Task<PlaylistSegmentsResult> WaitForPlaylistSegments(
public async Task 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))
{
@@ -327,13 +318,12 @@ 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);
// Phase B: playlist exists -> the requested number of segments are present (or deadline)
var segmentCount = 0;
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
{
@@ -355,14 +345,6 @@ public class HlsSessionWorker : IHlsSessionWorker
segmentCount = result.SegmentCount;
}
}
return new PlaylistSegmentsResult(
processStartup,
sw.Elapsed - processStartup,
segmentCount,
initialSegmentCount,
segmentCount < initialSegmentCount,
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments));
}
finally
{
@@ -551,9 +533,6 @@ 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
@@ -1,84 +0,0 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Channels;
// Issue #77 (XMLTV half): the clock-boundary Pad machinery snaps content to :00/:15/:30/:45 by emitting
// trailing PostRoll filler up to the boundary (proven at the builder level by
// PlayoutBuildGoldenTests.Classic_clock_padded). This test locks the OTHER half: that the guide
// projection — the single source of truth shared by the XMLTV cache builder and the JSON guide query —
// coalesces that trailing filler into the programme window, so each guide programme STOPS on the padded
// clock boundary. Together the two tests cover the full chain: schedule -> PlayoutItems -> guide.
[TestFixture]
public class ChannelGuideProjectorClockPadTests
{
// Deterministic UTC instants mirroring the Classic_clock_padded golden shape: off-boundary content
// (22/37/37-min) each followed by a single PostRoll filler run that ends exactly on the next :15 mark.
private static readonly DateTime Anchor = new(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
[Test]
public void Padded_programmes_stop_on_quarter_hour_boundary()
{
var items = new List<PlayoutItem>
{
// Programme 1: content 00:00-00:22, padded to 00:30 with PostRoll filler.
Content(guideGroup: 1, start: T(0, 0), finish: T(0, 22), title: "Movie 01"),
Pad(guideGroup: 1, start: T(0, 22), finish: T(0, 30)),
// Programme 2: content 00:30-01:07, padded to 01:15.
Content(guideGroup: 2, start: T(0, 30), finish: T(1, 7), title: "Movie 02"),
Pad(guideGroup: 2, start: T(1, 7), finish: T(1, 15)),
// Programme 3: content 01:15-01:52, padded to 02:00.
Content(guideGroup: 3, start: T(1, 15), finish: T(1, 52), title: "Movie 03"),
Pad(guideGroup: 3, start: T(1, 52), finish: T(2, 0))
};
List<ChannelGuideEntry> entries = ChannelGuideProjector
.Project(PlayoutScheduleKind.Classic, items, XmltvTimeZone.Utc, XmltvBlockBehavior.UseActualTimes)
.ToList();
// One programme per content item; trailing filler is coalesced in, not surfaced as its own entry.
entries.Count.ShouldBe(3);
entries.ShouldAllBe(e => e.DisplayItem.FillerKind == FillerKind.None);
// The #77 invariant: every programme STOPS on a :15 clock boundary (the padded target), and the
// 2nd/3rd programmes also START on one (the previous programme padded up to it).
foreach (ChannelGuideEntry entry in entries)
{
(entry.Stop.Minute % 15).ShouldBe(0, $"programme '{entry.DisplayItem.CustomTitle}' stops off-boundary at {entry.Stop:HH:mm:ss}");
entry.Stop.Second.ShouldBe(0);
}
entries[0].Stop.ShouldBe(new DateTimeOffset(T(0, 30), TimeSpan.Zero));
entries[1].Start.ShouldBe(new DateTimeOffset(T(0, 30), TimeSpan.Zero));
entries[1].Stop.ShouldBe(new DateTimeOffset(T(1, 15), TimeSpan.Zero));
entries[2].Start.ShouldBe(new DateTimeOffset(T(1, 15), TimeSpan.Zero));
entries[2].Stop.ShouldBe(new DateTimeOffset(T(2, 0), TimeSpan.Zero));
}
private static DateTime T(int hour, int minute) => Anchor.AddHours(hour).AddMinutes(minute);
private static PlayoutItem Content(int guideGroup, DateTime start, DateTime finish, string title) =>
new()
{
Start = start,
Finish = finish,
FillerKind = FillerKind.None,
GuideGroup = guideGroup,
CustomTitle = title
};
private static PlayoutItem Pad(int guideGroup, DateTime start, DateTime finish) =>
new()
{
Start = start,
Finish = finish,
FillerKind = FillerKind.PostRoll,
GuideGroup = guideGroup
};
}
@@ -1,89 +0,0 @@
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,47 +187,6 @@ 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&amp;&lt;&gt;&quot;");
xml.ShouldNotContain("access_token=tok&<");
}
// --- harness ---
private async Task Verify(string goldenName, GetChannelGuide request)
@@ -1,648 +0,0 @@
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Padded Movie 01
001 | 2026-01-15 00:22:00 - 2026-01-15 00:23:00 | PostRoll | Filler Clip
002 | 2026-01-15 00:23:00 - 2026-01-15 00:24:00 | PostRoll | Filler Clip
003 | 2026-01-15 00:24:00 - 2026-01-15 00:25:00 | PostRoll | Filler Clip
004 | 2026-01-15 00:25:00 - 2026-01-15 00:26:00 | PostRoll | Filler Clip
005 | 2026-01-15 00:26:00 - 2026-01-15 00:27:00 | PostRoll | Filler Clip
006 | 2026-01-15 00:27:00 - 2026-01-15 00:28:00 | PostRoll | Filler Clip
007 | 2026-01-15 00:28:00 - 2026-01-15 00:29:00 | PostRoll | Filler Clip
008 | 2026-01-15 00:29:00 - 2026-01-15 00:30:00 | PostRoll | Filler Clip
009 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Padded Movie 02
010 | 2026-01-15 01:07:00 - 2026-01-15 01:08:00 | PostRoll | Filler Clip
011 | 2026-01-15 01:08:00 - 2026-01-15 01:09:00 | PostRoll | Filler Clip
012 | 2026-01-15 01:09:00 - 2026-01-15 01:10:00 | PostRoll | Filler Clip
013 | 2026-01-15 01:10:00 - 2026-01-15 01:11:00 | PostRoll | Filler Clip
014 | 2026-01-15 01:11:00 - 2026-01-15 01:12:00 | PostRoll | Filler Clip
015 | 2026-01-15 01:12:00 - 2026-01-15 01:13:00 | PostRoll | Filler Clip
016 | 2026-01-15 01:13:00 - 2026-01-15 01:14:00 | PostRoll | Filler Clip
017 | 2026-01-15 01:14:00 - 2026-01-15 01:15:00 | PostRoll | Filler Clip
018 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Padded Movie 03
019 | 2026-01-15 02:07:00 - 2026-01-15 02:08:00 | PostRoll | Filler Clip
020 | 2026-01-15 02:08:00 - 2026-01-15 02:09:00 | PostRoll | Filler Clip
021 | 2026-01-15 02:09:00 - 2026-01-15 02:10:00 | PostRoll | Filler Clip
022 | 2026-01-15 02:10:00 - 2026-01-15 02:11:00 | PostRoll | Filler Clip
023 | 2026-01-15 02:11:00 - 2026-01-15 02:12:00 | PostRoll | Filler Clip
024 | 2026-01-15 02:12:00 - 2026-01-15 02:13:00 | PostRoll | Filler Clip
025 | 2026-01-15 02:13:00 - 2026-01-15 02:14:00 | PostRoll | Filler Clip
026 | 2026-01-15 02:14:00 - 2026-01-15 02:15:00 | PostRoll | Filler Clip
027 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Padded Movie 01
028 | 2026-01-15 02:37:00 - 2026-01-15 02:38:00 | PostRoll | Filler Clip
029 | 2026-01-15 02:38:00 - 2026-01-15 02:39:00 | PostRoll | Filler Clip
030 | 2026-01-15 02:39:00 - 2026-01-15 02:40:00 | PostRoll | Filler Clip
031 | 2026-01-15 02:40:00 - 2026-01-15 02:41:00 | PostRoll | Filler Clip
032 | 2026-01-15 02:41:00 - 2026-01-15 02:42:00 | PostRoll | Filler Clip
033 | 2026-01-15 02:42:00 - 2026-01-15 02:43:00 | PostRoll | Filler Clip
034 | 2026-01-15 02:43:00 - 2026-01-15 02:44:00 | PostRoll | Filler Clip
035 | 2026-01-15 02:44:00 - 2026-01-15 02:45:00 | PostRoll | Filler Clip
036 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Padded Movie 02
037 | 2026-01-15 03:22:00 - 2026-01-15 03:23:00 | PostRoll | Filler Clip
038 | 2026-01-15 03:23:00 - 2026-01-15 03:24:00 | PostRoll | Filler Clip
039 | 2026-01-15 03:24:00 - 2026-01-15 03:25:00 | PostRoll | Filler Clip
040 | 2026-01-15 03:25:00 - 2026-01-15 03:26:00 | PostRoll | Filler Clip
041 | 2026-01-15 03:26:00 - 2026-01-15 03:27:00 | PostRoll | Filler Clip
042 | 2026-01-15 03:27:00 - 2026-01-15 03:28:00 | PostRoll | Filler Clip
043 | 2026-01-15 03:28:00 - 2026-01-15 03:29:00 | PostRoll | Filler Clip
044 | 2026-01-15 03:29:00 - 2026-01-15 03:30:00 | PostRoll | Filler Clip
045 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Padded Movie 03
046 | 2026-01-15 04:22:00 - 2026-01-15 04:23:00 | PostRoll | Filler Clip
047 | 2026-01-15 04:23:00 - 2026-01-15 04:24:00 | PostRoll | Filler Clip
048 | 2026-01-15 04:24:00 - 2026-01-15 04:25:00 | PostRoll | Filler Clip
049 | 2026-01-15 04:25:00 - 2026-01-15 04:26:00 | PostRoll | Filler Clip
050 | 2026-01-15 04:26:00 - 2026-01-15 04:27:00 | PostRoll | Filler Clip
051 | 2026-01-15 04:27:00 - 2026-01-15 04:28:00 | PostRoll | Filler Clip
052 | 2026-01-15 04:28:00 - 2026-01-15 04:29:00 | PostRoll | Filler Clip
053 | 2026-01-15 04:29:00 - 2026-01-15 04:30:00 | PostRoll | Filler Clip
054 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Padded Movie 01
055 | 2026-01-15 04:52:00 - 2026-01-15 04:53:00 | PostRoll | Filler Clip
056 | 2026-01-15 04:53:00 - 2026-01-15 04:54:00 | PostRoll | Filler Clip
057 | 2026-01-15 04:54:00 - 2026-01-15 04:55:00 | PostRoll | Filler Clip
058 | 2026-01-15 04:55:00 - 2026-01-15 04:56:00 | PostRoll | Filler Clip
059 | 2026-01-15 04:56:00 - 2026-01-15 04:57:00 | PostRoll | Filler Clip
060 | 2026-01-15 04:57:00 - 2026-01-15 04:58:00 | PostRoll | Filler Clip
061 | 2026-01-15 04:58:00 - 2026-01-15 04:59:00 | PostRoll | Filler Clip
062 | 2026-01-15 04:59:00 - 2026-01-15 05:00:00 | PostRoll | Filler Clip
063 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Padded Movie 02
064 | 2026-01-15 05:37:00 - 2026-01-15 05:38:00 | PostRoll | Filler Clip
065 | 2026-01-15 05:38:00 - 2026-01-15 05:39:00 | PostRoll | Filler Clip
066 | 2026-01-15 05:39:00 - 2026-01-15 05:40:00 | PostRoll | Filler Clip
067 | 2026-01-15 05:40:00 - 2026-01-15 05:41:00 | PostRoll | Filler Clip
068 | 2026-01-15 05:41:00 - 2026-01-15 05:42:00 | PostRoll | Filler Clip
069 | 2026-01-15 05:42:00 - 2026-01-15 05:43:00 | PostRoll | Filler Clip
070 | 2026-01-15 05:43:00 - 2026-01-15 05:44:00 | PostRoll | Filler Clip
071 | 2026-01-15 05:44:00 - 2026-01-15 05:45:00 | PostRoll | Filler Clip
072 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Padded Movie 03
073 | 2026-01-15 06:37:00 - 2026-01-15 06:38:00 | PostRoll | Filler Clip
074 | 2026-01-15 06:38:00 - 2026-01-15 06:39:00 | PostRoll | Filler Clip
075 | 2026-01-15 06:39:00 - 2026-01-15 06:40:00 | PostRoll | Filler Clip
076 | 2026-01-15 06:40:00 - 2026-01-15 06:41:00 | PostRoll | Filler Clip
077 | 2026-01-15 06:41:00 - 2026-01-15 06:42:00 | PostRoll | Filler Clip
078 | 2026-01-15 06:42:00 - 2026-01-15 06:43:00 | PostRoll | Filler Clip
079 | 2026-01-15 06:43:00 - 2026-01-15 06:44:00 | PostRoll | Filler Clip
080 | 2026-01-15 06:44:00 - 2026-01-15 06:45:00 | PostRoll | Filler Clip
081 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Padded Movie 01
082 | 2026-01-15 07:07:00 - 2026-01-15 07:08:00 | PostRoll | Filler Clip
083 | 2026-01-15 07:08:00 - 2026-01-15 07:09:00 | PostRoll | Filler Clip
084 | 2026-01-15 07:09:00 - 2026-01-15 07:10:00 | PostRoll | Filler Clip
085 | 2026-01-15 07:10:00 - 2026-01-15 07:11:00 | PostRoll | Filler Clip
086 | 2026-01-15 07:11:00 - 2026-01-15 07:12:00 | PostRoll | Filler Clip
087 | 2026-01-15 07:12:00 - 2026-01-15 07:13:00 | PostRoll | Filler Clip
088 | 2026-01-15 07:13:00 - 2026-01-15 07:14:00 | PostRoll | Filler Clip
089 | 2026-01-15 07:14:00 - 2026-01-15 07:15:00 | PostRoll | Filler Clip
090 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Padded Movie 02
091 | 2026-01-15 07:52:00 - 2026-01-15 07:53:00 | PostRoll | Filler Clip
092 | 2026-01-15 07:53:00 - 2026-01-15 07:54:00 | PostRoll | Filler Clip
093 | 2026-01-15 07:54:00 - 2026-01-15 07:55:00 | PostRoll | Filler Clip
094 | 2026-01-15 07:55:00 - 2026-01-15 07:56:00 | PostRoll | Filler Clip
095 | 2026-01-15 07:56:00 - 2026-01-15 07:57:00 | PostRoll | Filler Clip
096 | 2026-01-15 07:57:00 - 2026-01-15 07:58:00 | PostRoll | Filler Clip
097 | 2026-01-15 07:58:00 - 2026-01-15 07:59:00 | PostRoll | Filler Clip
098 | 2026-01-15 07:59:00 - 2026-01-15 08:00:00 | PostRoll | Filler Clip
099 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Padded Movie 03
100 | 2026-01-15 08:52:00 - 2026-01-15 08:53:00 | PostRoll | Filler Clip
101 | 2026-01-15 08:53:00 - 2026-01-15 08:54:00 | PostRoll | Filler Clip
102 | 2026-01-15 08:54:00 - 2026-01-15 08:55:00 | PostRoll | Filler Clip
103 | 2026-01-15 08:55:00 - 2026-01-15 08:56:00 | PostRoll | Filler Clip
104 | 2026-01-15 08:56:00 - 2026-01-15 08:57:00 | PostRoll | Filler Clip
105 | 2026-01-15 08:57:00 - 2026-01-15 08:58:00 | PostRoll | Filler Clip
106 | 2026-01-15 08:58:00 - 2026-01-15 08:59:00 | PostRoll | Filler Clip
107 | 2026-01-15 08:59:00 - 2026-01-15 09:00:00 | PostRoll | Filler Clip
108 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Padded Movie 01
109 | 2026-01-15 09:22:00 - 2026-01-15 09:23:00 | PostRoll | Filler Clip
110 | 2026-01-15 09:23:00 - 2026-01-15 09:24:00 | PostRoll | Filler Clip
111 | 2026-01-15 09:24:00 - 2026-01-15 09:25:00 | PostRoll | Filler Clip
112 | 2026-01-15 09:25:00 - 2026-01-15 09:26:00 | PostRoll | Filler Clip
113 | 2026-01-15 09:26:00 - 2026-01-15 09:27:00 | PostRoll | Filler Clip
114 | 2026-01-15 09:27:00 - 2026-01-15 09:28:00 | PostRoll | Filler Clip
115 | 2026-01-15 09:28:00 - 2026-01-15 09:29:00 | PostRoll | Filler Clip
116 | 2026-01-15 09:29:00 - 2026-01-15 09:30:00 | PostRoll | Filler Clip
117 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Padded Movie 02
118 | 2026-01-15 10:07:00 - 2026-01-15 10:08:00 | PostRoll | Filler Clip
119 | 2026-01-15 10:08:00 - 2026-01-15 10:09:00 | PostRoll | Filler Clip
120 | 2026-01-15 10:09:00 - 2026-01-15 10:10:00 | PostRoll | Filler Clip
121 | 2026-01-15 10:10:00 - 2026-01-15 10:11:00 | PostRoll | Filler Clip
122 | 2026-01-15 10:11:00 - 2026-01-15 10:12:00 | PostRoll | Filler Clip
123 | 2026-01-15 10:12:00 - 2026-01-15 10:13:00 | PostRoll | Filler Clip
124 | 2026-01-15 10:13:00 - 2026-01-15 10:14:00 | PostRoll | Filler Clip
125 | 2026-01-15 10:14:00 - 2026-01-15 10:15:00 | PostRoll | Filler Clip
126 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Padded Movie 03
127 | 2026-01-15 11:07:00 - 2026-01-15 11:08:00 | PostRoll | Filler Clip
128 | 2026-01-15 11:08:00 - 2026-01-15 11:09:00 | PostRoll | Filler Clip
129 | 2026-01-15 11:09:00 - 2026-01-15 11:10:00 | PostRoll | Filler Clip
130 | 2026-01-15 11:10:00 - 2026-01-15 11:11:00 | PostRoll | Filler Clip
131 | 2026-01-15 11:11:00 - 2026-01-15 11:12:00 | PostRoll | Filler Clip
132 | 2026-01-15 11:12:00 - 2026-01-15 11:13:00 | PostRoll | Filler Clip
133 | 2026-01-15 11:13:00 - 2026-01-15 11:14:00 | PostRoll | Filler Clip
134 | 2026-01-15 11:14:00 - 2026-01-15 11:15:00 | PostRoll | Filler Clip
135 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Padded Movie 01
136 | 2026-01-15 11:37:00 - 2026-01-15 11:38:00 | PostRoll | Filler Clip
137 | 2026-01-15 11:38:00 - 2026-01-15 11:39:00 | PostRoll | Filler Clip
138 | 2026-01-15 11:39:00 - 2026-01-15 11:40:00 | PostRoll | Filler Clip
139 | 2026-01-15 11:40:00 - 2026-01-15 11:41:00 | PostRoll | Filler Clip
140 | 2026-01-15 11:41:00 - 2026-01-15 11:42:00 | PostRoll | Filler Clip
141 | 2026-01-15 11:42:00 - 2026-01-15 11:43:00 | PostRoll | Filler Clip
142 | 2026-01-15 11:43:00 - 2026-01-15 11:44:00 | PostRoll | Filler Clip
143 | 2026-01-15 11:44:00 - 2026-01-15 11:45:00 | PostRoll | Filler Clip
144 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Padded Movie 02
145 | 2026-01-15 12:22:00 - 2026-01-15 12:23:00 | PostRoll | Filler Clip
146 | 2026-01-15 12:23:00 - 2026-01-15 12:24:00 | PostRoll | Filler Clip
147 | 2026-01-15 12:24:00 - 2026-01-15 12:25:00 | PostRoll | Filler Clip
148 | 2026-01-15 12:25:00 - 2026-01-15 12:26:00 | PostRoll | Filler Clip
149 | 2026-01-15 12:26:00 - 2026-01-15 12:27:00 | PostRoll | Filler Clip
150 | 2026-01-15 12:27:00 - 2026-01-15 12:28:00 | PostRoll | Filler Clip
151 | 2026-01-15 12:28:00 - 2026-01-15 12:29:00 | PostRoll | Filler Clip
152 | 2026-01-15 12:29:00 - 2026-01-15 12:30:00 | PostRoll | Filler Clip
153 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Padded Movie 03
154 | 2026-01-15 13:22:00 - 2026-01-15 13:23:00 | PostRoll | Filler Clip
155 | 2026-01-15 13:23:00 - 2026-01-15 13:24:00 | PostRoll | Filler Clip
156 | 2026-01-15 13:24:00 - 2026-01-15 13:25:00 | PostRoll | Filler Clip
157 | 2026-01-15 13:25:00 - 2026-01-15 13:26:00 | PostRoll | Filler Clip
158 | 2026-01-15 13:26:00 - 2026-01-15 13:27:00 | PostRoll | Filler Clip
159 | 2026-01-15 13:27:00 - 2026-01-15 13:28:00 | PostRoll | Filler Clip
160 | 2026-01-15 13:28:00 - 2026-01-15 13:29:00 | PostRoll | Filler Clip
161 | 2026-01-15 13:29:00 - 2026-01-15 13:30:00 | PostRoll | Filler Clip
162 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Padded Movie 01
163 | 2026-01-15 13:52:00 - 2026-01-15 13:53:00 | PostRoll | Filler Clip
164 | 2026-01-15 13:53:00 - 2026-01-15 13:54:00 | PostRoll | Filler Clip
165 | 2026-01-15 13:54:00 - 2026-01-15 13:55:00 | PostRoll | Filler Clip
166 | 2026-01-15 13:55:00 - 2026-01-15 13:56:00 | PostRoll | Filler Clip
167 | 2026-01-15 13:56:00 - 2026-01-15 13:57:00 | PostRoll | Filler Clip
168 | 2026-01-15 13:57:00 - 2026-01-15 13:58:00 | PostRoll | Filler Clip
169 | 2026-01-15 13:58:00 - 2026-01-15 13:59:00 | PostRoll | Filler Clip
170 | 2026-01-15 13:59:00 - 2026-01-15 14:00:00 | PostRoll | Filler Clip
171 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Padded Movie 02
172 | 2026-01-15 14:37:00 - 2026-01-15 14:38:00 | PostRoll | Filler Clip
173 | 2026-01-15 14:38:00 - 2026-01-15 14:39:00 | PostRoll | Filler Clip
174 | 2026-01-15 14:39:00 - 2026-01-15 14:40:00 | PostRoll | Filler Clip
175 | 2026-01-15 14:40:00 - 2026-01-15 14:41:00 | PostRoll | Filler Clip
176 | 2026-01-15 14:41:00 - 2026-01-15 14:42:00 | PostRoll | Filler Clip
177 | 2026-01-15 14:42:00 - 2026-01-15 14:43:00 | PostRoll | Filler Clip
178 | 2026-01-15 14:43:00 - 2026-01-15 14:44:00 | PostRoll | Filler Clip
179 | 2026-01-15 14:44:00 - 2026-01-15 14:45:00 | PostRoll | Filler Clip
180 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Padded Movie 03
181 | 2026-01-15 15:37:00 - 2026-01-15 15:38:00 | PostRoll | Filler Clip
182 | 2026-01-15 15:38:00 - 2026-01-15 15:39:00 | PostRoll | Filler Clip
183 | 2026-01-15 15:39:00 - 2026-01-15 15:40:00 | PostRoll | Filler Clip
184 | 2026-01-15 15:40:00 - 2026-01-15 15:41:00 | PostRoll | Filler Clip
185 | 2026-01-15 15:41:00 - 2026-01-15 15:42:00 | PostRoll | Filler Clip
186 | 2026-01-15 15:42:00 - 2026-01-15 15:43:00 | PostRoll | Filler Clip
187 | 2026-01-15 15:43:00 - 2026-01-15 15:44:00 | PostRoll | Filler Clip
188 | 2026-01-15 15:44:00 - 2026-01-15 15:45:00 | PostRoll | Filler Clip
189 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Padded Movie 01
190 | 2026-01-15 16:07:00 - 2026-01-15 16:08:00 | PostRoll | Filler Clip
191 | 2026-01-15 16:08:00 - 2026-01-15 16:09:00 | PostRoll | Filler Clip
192 | 2026-01-15 16:09:00 - 2026-01-15 16:10:00 | PostRoll | Filler Clip
193 | 2026-01-15 16:10:00 - 2026-01-15 16:11:00 | PostRoll | Filler Clip
194 | 2026-01-15 16:11:00 - 2026-01-15 16:12:00 | PostRoll | Filler Clip
195 | 2026-01-15 16:12:00 - 2026-01-15 16:13:00 | PostRoll | Filler Clip
196 | 2026-01-15 16:13:00 - 2026-01-15 16:14:00 | PostRoll | Filler Clip
197 | 2026-01-15 16:14:00 - 2026-01-15 16:15:00 | PostRoll | Filler Clip
198 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Padded Movie 02
199 | 2026-01-15 16:52:00 - 2026-01-15 16:53:00 | PostRoll | Filler Clip
200 | 2026-01-15 16:53:00 - 2026-01-15 16:54:00 | PostRoll | Filler Clip
201 | 2026-01-15 16:54:00 - 2026-01-15 16:55:00 | PostRoll | Filler Clip
202 | 2026-01-15 16:55:00 - 2026-01-15 16:56:00 | PostRoll | Filler Clip
203 | 2026-01-15 16:56:00 - 2026-01-15 16:57:00 | PostRoll | Filler Clip
204 | 2026-01-15 16:57:00 - 2026-01-15 16:58:00 | PostRoll | Filler Clip
205 | 2026-01-15 16:58:00 - 2026-01-15 16:59:00 | PostRoll | Filler Clip
206 | 2026-01-15 16:59:00 - 2026-01-15 17:00:00 | PostRoll | Filler Clip
207 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Padded Movie 03
208 | 2026-01-15 17:52:00 - 2026-01-15 17:53:00 | PostRoll | Filler Clip
209 | 2026-01-15 17:53:00 - 2026-01-15 17:54:00 | PostRoll | Filler Clip
210 | 2026-01-15 17:54:00 - 2026-01-15 17:55:00 | PostRoll | Filler Clip
211 | 2026-01-15 17:55:00 - 2026-01-15 17:56:00 | PostRoll | Filler Clip
212 | 2026-01-15 17:56:00 - 2026-01-15 17:57:00 | PostRoll | Filler Clip
213 | 2026-01-15 17:57:00 - 2026-01-15 17:58:00 | PostRoll | Filler Clip
214 | 2026-01-15 17:58:00 - 2026-01-15 17:59:00 | PostRoll | Filler Clip
215 | 2026-01-15 17:59:00 - 2026-01-15 18:00:00 | PostRoll | Filler Clip
216 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Padded Movie 01
217 | 2026-01-15 18:22:00 - 2026-01-15 18:23:00 | PostRoll | Filler Clip
218 | 2026-01-15 18:23:00 - 2026-01-15 18:24:00 | PostRoll | Filler Clip
219 | 2026-01-15 18:24:00 - 2026-01-15 18:25:00 | PostRoll | Filler Clip
220 | 2026-01-15 18:25:00 - 2026-01-15 18:26:00 | PostRoll | Filler Clip
221 | 2026-01-15 18:26:00 - 2026-01-15 18:27:00 | PostRoll | Filler Clip
222 | 2026-01-15 18:27:00 - 2026-01-15 18:28:00 | PostRoll | Filler Clip
223 | 2026-01-15 18:28:00 - 2026-01-15 18:29:00 | PostRoll | Filler Clip
224 | 2026-01-15 18:29:00 - 2026-01-15 18:30:00 | PostRoll | Filler Clip
225 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Padded Movie 02
226 | 2026-01-15 19:07:00 - 2026-01-15 19:08:00 | PostRoll | Filler Clip
227 | 2026-01-15 19:08:00 - 2026-01-15 19:09:00 | PostRoll | Filler Clip
228 | 2026-01-15 19:09:00 - 2026-01-15 19:10:00 | PostRoll | Filler Clip
229 | 2026-01-15 19:10:00 - 2026-01-15 19:11:00 | PostRoll | Filler Clip
230 | 2026-01-15 19:11:00 - 2026-01-15 19:12:00 | PostRoll | Filler Clip
231 | 2026-01-15 19:12:00 - 2026-01-15 19:13:00 | PostRoll | Filler Clip
232 | 2026-01-15 19:13:00 - 2026-01-15 19:14:00 | PostRoll | Filler Clip
233 | 2026-01-15 19:14:00 - 2026-01-15 19:15:00 | PostRoll | Filler Clip
234 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Padded Movie 03
235 | 2026-01-15 20:07:00 - 2026-01-15 20:08:00 | PostRoll | Filler Clip
236 | 2026-01-15 20:08:00 - 2026-01-15 20:09:00 | PostRoll | Filler Clip
237 | 2026-01-15 20:09:00 - 2026-01-15 20:10:00 | PostRoll | Filler Clip
238 | 2026-01-15 20:10:00 - 2026-01-15 20:11:00 | PostRoll | Filler Clip
239 | 2026-01-15 20:11:00 - 2026-01-15 20:12:00 | PostRoll | Filler Clip
240 | 2026-01-15 20:12:00 - 2026-01-15 20:13:00 | PostRoll | Filler Clip
241 | 2026-01-15 20:13:00 - 2026-01-15 20:14:00 | PostRoll | Filler Clip
242 | 2026-01-15 20:14:00 - 2026-01-15 20:15:00 | PostRoll | Filler Clip
243 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Padded Movie 01
244 | 2026-01-15 20:37:00 - 2026-01-15 20:38:00 | PostRoll | Filler Clip
245 | 2026-01-15 20:38:00 - 2026-01-15 20:39:00 | PostRoll | Filler Clip
246 | 2026-01-15 20:39:00 - 2026-01-15 20:40:00 | PostRoll | Filler Clip
247 | 2026-01-15 20:40:00 - 2026-01-15 20:41:00 | PostRoll | Filler Clip
248 | 2026-01-15 20:41:00 - 2026-01-15 20:42:00 | PostRoll | Filler Clip
249 | 2026-01-15 20:42:00 - 2026-01-15 20:43:00 | PostRoll | Filler Clip
250 | 2026-01-15 20:43:00 - 2026-01-15 20:44:00 | PostRoll | Filler Clip
251 | 2026-01-15 20:44:00 - 2026-01-15 20:45:00 | PostRoll | Filler Clip
252 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Padded Movie 02
253 | 2026-01-15 21:22:00 - 2026-01-15 21:23:00 | PostRoll | Filler Clip
254 | 2026-01-15 21:23:00 - 2026-01-15 21:24:00 | PostRoll | Filler Clip
255 | 2026-01-15 21:24:00 - 2026-01-15 21:25:00 | PostRoll | Filler Clip
256 | 2026-01-15 21:25:00 - 2026-01-15 21:26:00 | PostRoll | Filler Clip
257 | 2026-01-15 21:26:00 - 2026-01-15 21:27:00 | PostRoll | Filler Clip
258 | 2026-01-15 21:27:00 - 2026-01-15 21:28:00 | PostRoll | Filler Clip
259 | 2026-01-15 21:28:00 - 2026-01-15 21:29:00 | PostRoll | Filler Clip
260 | 2026-01-15 21:29:00 - 2026-01-15 21:30:00 | PostRoll | Filler Clip
261 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Padded Movie 03
262 | 2026-01-15 22:22:00 - 2026-01-15 22:23:00 | PostRoll | Filler Clip
263 | 2026-01-15 22:23:00 - 2026-01-15 22:24:00 | PostRoll | Filler Clip
264 | 2026-01-15 22:24:00 - 2026-01-15 22:25:00 | PostRoll | Filler Clip
265 | 2026-01-15 22:25:00 - 2026-01-15 22:26:00 | PostRoll | Filler Clip
266 | 2026-01-15 22:26:00 - 2026-01-15 22:27:00 | PostRoll | Filler Clip
267 | 2026-01-15 22:27:00 - 2026-01-15 22:28:00 | PostRoll | Filler Clip
268 | 2026-01-15 22:28:00 - 2026-01-15 22:29:00 | PostRoll | Filler Clip
269 | 2026-01-15 22:29:00 - 2026-01-15 22:30:00 | PostRoll | Filler Clip
270 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Padded Movie 01
271 | 2026-01-15 22:52:00 - 2026-01-15 22:53:00 | PostRoll | Filler Clip
272 | 2026-01-15 22:53:00 - 2026-01-15 22:54:00 | PostRoll | Filler Clip
273 | 2026-01-15 22:54:00 - 2026-01-15 22:55:00 | PostRoll | Filler Clip
274 | 2026-01-15 22:55:00 - 2026-01-15 22:56:00 | PostRoll | Filler Clip
275 | 2026-01-15 22:56:00 - 2026-01-15 22:57:00 | PostRoll | Filler Clip
276 | 2026-01-15 22:57:00 - 2026-01-15 22:58:00 | PostRoll | Filler Clip
277 | 2026-01-15 22:58:00 - 2026-01-15 22:59:00 | PostRoll | Filler Clip
278 | 2026-01-15 22:59:00 - 2026-01-15 23:00:00 | PostRoll | Filler Clip
279 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Padded Movie 02
280 | 2026-01-15 23:37:00 - 2026-01-15 23:38:00 | PostRoll | Filler Clip
281 | 2026-01-15 23:38:00 - 2026-01-15 23:39:00 | PostRoll | Filler Clip
282 | 2026-01-15 23:39:00 - 2026-01-15 23:40:00 | PostRoll | Filler Clip
283 | 2026-01-15 23:40:00 - 2026-01-15 23:41:00 | PostRoll | Filler Clip
284 | 2026-01-15 23:41:00 - 2026-01-15 23:42:00 | PostRoll | Filler Clip
285 | 2026-01-15 23:42:00 - 2026-01-15 23:43:00 | PostRoll | Filler Clip
286 | 2026-01-15 23:43:00 - 2026-01-15 23:44:00 | PostRoll | Filler Clip
287 | 2026-01-15 23:44:00 - 2026-01-15 23:45:00 | PostRoll | Filler Clip
288 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Padded Movie 03
289 | 2026-01-16 00:37:00 - 2026-01-16 00:38:00 | PostRoll | Filler Clip
290 | 2026-01-16 00:38:00 - 2026-01-16 00:39:00 | PostRoll | Filler Clip
291 | 2026-01-16 00:39:00 - 2026-01-16 00:40:00 | PostRoll | Filler Clip
292 | 2026-01-16 00:40:00 - 2026-01-16 00:41:00 | PostRoll | Filler Clip
293 | 2026-01-16 00:41:00 - 2026-01-16 00:42:00 | PostRoll | Filler Clip
294 | 2026-01-16 00:42:00 - 2026-01-16 00:43:00 | PostRoll | Filler Clip
295 | 2026-01-16 00:43:00 - 2026-01-16 00:44:00 | PostRoll | Filler Clip
296 | 2026-01-16 00:44:00 - 2026-01-16 00:45:00 | PostRoll | Filler Clip
297 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Padded Movie 01
298 | 2026-01-16 01:07:00 - 2026-01-16 01:08:00 | PostRoll | Filler Clip
299 | 2026-01-16 01:08:00 - 2026-01-16 01:09:00 | PostRoll | Filler Clip
300 | 2026-01-16 01:09:00 - 2026-01-16 01:10:00 | PostRoll | Filler Clip
301 | 2026-01-16 01:10:00 - 2026-01-16 01:11:00 | PostRoll | Filler Clip
302 | 2026-01-16 01:11:00 - 2026-01-16 01:12:00 | PostRoll | Filler Clip
303 | 2026-01-16 01:12:00 - 2026-01-16 01:13:00 | PostRoll | Filler Clip
304 | 2026-01-16 01:13:00 - 2026-01-16 01:14:00 | PostRoll | Filler Clip
305 | 2026-01-16 01:14:00 - 2026-01-16 01:15:00 | PostRoll | Filler Clip
306 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Padded Movie 02
307 | 2026-01-16 01:52:00 - 2026-01-16 01:53:00 | PostRoll | Filler Clip
308 | 2026-01-16 01:53:00 - 2026-01-16 01:54:00 | PostRoll | Filler Clip
309 | 2026-01-16 01:54:00 - 2026-01-16 01:55:00 | PostRoll | Filler Clip
310 | 2026-01-16 01:55:00 - 2026-01-16 01:56:00 | PostRoll | Filler Clip
311 | 2026-01-16 01:56:00 - 2026-01-16 01:57:00 | PostRoll | Filler Clip
312 | 2026-01-16 01:57:00 - 2026-01-16 01:58:00 | PostRoll | Filler Clip
313 | 2026-01-16 01:58:00 - 2026-01-16 01:59:00 | PostRoll | Filler Clip
314 | 2026-01-16 01:59:00 - 2026-01-16 02:00:00 | PostRoll | Filler Clip
315 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Padded Movie 03
316 | 2026-01-16 02:52:00 - 2026-01-16 02:53:00 | PostRoll | Filler Clip
317 | 2026-01-16 02:53:00 - 2026-01-16 02:54:00 | PostRoll | Filler Clip
318 | 2026-01-16 02:54:00 - 2026-01-16 02:55:00 | PostRoll | Filler Clip
319 | 2026-01-16 02:55:00 - 2026-01-16 02:56:00 | PostRoll | Filler Clip
320 | 2026-01-16 02:56:00 - 2026-01-16 02:57:00 | PostRoll | Filler Clip
321 | 2026-01-16 02:57:00 - 2026-01-16 02:58:00 | PostRoll | Filler Clip
322 | 2026-01-16 02:58:00 - 2026-01-16 02:59:00 | PostRoll | Filler Clip
323 | 2026-01-16 02:59:00 - 2026-01-16 03:00:00 | PostRoll | Filler Clip
324 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Padded Movie 01
325 | 2026-01-16 03:22:00 - 2026-01-16 03:23:00 | PostRoll | Filler Clip
326 | 2026-01-16 03:23:00 - 2026-01-16 03:24:00 | PostRoll | Filler Clip
327 | 2026-01-16 03:24:00 - 2026-01-16 03:25:00 | PostRoll | Filler Clip
328 | 2026-01-16 03:25:00 - 2026-01-16 03:26:00 | PostRoll | Filler Clip
329 | 2026-01-16 03:26:00 - 2026-01-16 03:27:00 | PostRoll | Filler Clip
330 | 2026-01-16 03:27:00 - 2026-01-16 03:28:00 | PostRoll | Filler Clip
331 | 2026-01-16 03:28:00 - 2026-01-16 03:29:00 | PostRoll | Filler Clip
332 | 2026-01-16 03:29:00 - 2026-01-16 03:30:00 | PostRoll | Filler Clip
333 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Padded Movie 02
334 | 2026-01-16 04:07:00 - 2026-01-16 04:08:00 | PostRoll | Filler Clip
335 | 2026-01-16 04:08:00 - 2026-01-16 04:09:00 | PostRoll | Filler Clip
336 | 2026-01-16 04:09:00 - 2026-01-16 04:10:00 | PostRoll | Filler Clip
337 | 2026-01-16 04:10:00 - 2026-01-16 04:11:00 | PostRoll | Filler Clip
338 | 2026-01-16 04:11:00 - 2026-01-16 04:12:00 | PostRoll | Filler Clip
339 | 2026-01-16 04:12:00 - 2026-01-16 04:13:00 | PostRoll | Filler Clip
340 | 2026-01-16 04:13:00 - 2026-01-16 04:14:00 | PostRoll | Filler Clip
341 | 2026-01-16 04:14:00 - 2026-01-16 04:15:00 | PostRoll | Filler Clip
342 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Padded Movie 03
343 | 2026-01-16 05:07:00 - 2026-01-16 05:08:00 | PostRoll | Filler Clip
344 | 2026-01-16 05:08:00 - 2026-01-16 05:09:00 | PostRoll | Filler Clip
345 | 2026-01-16 05:09:00 - 2026-01-16 05:10:00 | PostRoll | Filler Clip
346 | 2026-01-16 05:10:00 - 2026-01-16 05:11:00 | PostRoll | Filler Clip
347 | 2026-01-16 05:11:00 - 2026-01-16 05:12:00 | PostRoll | Filler Clip
348 | 2026-01-16 05:12:00 - 2026-01-16 05:13:00 | PostRoll | Filler Clip
349 | 2026-01-16 05:13:00 - 2026-01-16 05:14:00 | PostRoll | Filler Clip
350 | 2026-01-16 05:14:00 - 2026-01-16 05:15:00 | PostRoll | Filler Clip
351 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Padded Movie 01
352 | 2026-01-16 05:37:00 - 2026-01-16 05:38:00 | PostRoll | Filler Clip
353 | 2026-01-16 05:38:00 - 2026-01-16 05:39:00 | PostRoll | Filler Clip
354 | 2026-01-16 05:39:00 - 2026-01-16 05:40:00 | PostRoll | Filler Clip
355 | 2026-01-16 05:40:00 - 2026-01-16 05:41:00 | PostRoll | Filler Clip
356 | 2026-01-16 05:41:00 - 2026-01-16 05:42:00 | PostRoll | Filler Clip
357 | 2026-01-16 05:42:00 - 2026-01-16 05:43:00 | PostRoll | Filler Clip
358 | 2026-01-16 05:43:00 - 2026-01-16 05:44:00 | PostRoll | Filler Clip
359 | 2026-01-16 05:44:00 - 2026-01-16 05:45:00 | PostRoll | Filler Clip
360 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Padded Movie 02
361 | 2026-01-16 06:22:00 - 2026-01-16 06:23:00 | PostRoll | Filler Clip
362 | 2026-01-16 06:23:00 - 2026-01-16 06:24:00 | PostRoll | Filler Clip
363 | 2026-01-16 06:24:00 - 2026-01-16 06:25:00 | PostRoll | Filler Clip
364 | 2026-01-16 06:25:00 - 2026-01-16 06:26:00 | PostRoll | Filler Clip
365 | 2026-01-16 06:26:00 - 2026-01-16 06:27:00 | PostRoll | Filler Clip
366 | 2026-01-16 06:27:00 - 2026-01-16 06:28:00 | PostRoll | Filler Clip
367 | 2026-01-16 06:28:00 - 2026-01-16 06:29:00 | PostRoll | Filler Clip
368 | 2026-01-16 06:29:00 - 2026-01-16 06:30:00 | PostRoll | Filler Clip
369 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Padded Movie 03
370 | 2026-01-16 07:22:00 - 2026-01-16 07:23:00 | PostRoll | Filler Clip
371 | 2026-01-16 07:23:00 - 2026-01-16 07:24:00 | PostRoll | Filler Clip
372 | 2026-01-16 07:24:00 - 2026-01-16 07:25:00 | PostRoll | Filler Clip
373 | 2026-01-16 07:25:00 - 2026-01-16 07:26:00 | PostRoll | Filler Clip
374 | 2026-01-16 07:26:00 - 2026-01-16 07:27:00 | PostRoll | Filler Clip
375 | 2026-01-16 07:27:00 - 2026-01-16 07:28:00 | PostRoll | Filler Clip
376 | 2026-01-16 07:28:00 - 2026-01-16 07:29:00 | PostRoll | Filler Clip
377 | 2026-01-16 07:29:00 - 2026-01-16 07:30:00 | PostRoll | Filler Clip
378 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Padded Movie 01
379 | 2026-01-16 07:52:00 - 2026-01-16 07:53:00 | PostRoll | Filler Clip
380 | 2026-01-16 07:53:00 - 2026-01-16 07:54:00 | PostRoll | Filler Clip
381 | 2026-01-16 07:54:00 - 2026-01-16 07:55:00 | PostRoll | Filler Clip
382 | 2026-01-16 07:55:00 - 2026-01-16 07:56:00 | PostRoll | Filler Clip
383 | 2026-01-16 07:56:00 - 2026-01-16 07:57:00 | PostRoll | Filler Clip
384 | 2026-01-16 07:57:00 - 2026-01-16 07:58:00 | PostRoll | Filler Clip
385 | 2026-01-16 07:58:00 - 2026-01-16 07:59:00 | PostRoll | Filler Clip
386 | 2026-01-16 07:59:00 - 2026-01-16 08:00:00 | PostRoll | Filler Clip
387 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Padded Movie 02
388 | 2026-01-16 08:37:00 - 2026-01-16 08:38:00 | PostRoll | Filler Clip
389 | 2026-01-16 08:38:00 - 2026-01-16 08:39:00 | PostRoll | Filler Clip
390 | 2026-01-16 08:39:00 - 2026-01-16 08:40:00 | PostRoll | Filler Clip
391 | 2026-01-16 08:40:00 - 2026-01-16 08:41:00 | PostRoll | Filler Clip
392 | 2026-01-16 08:41:00 - 2026-01-16 08:42:00 | PostRoll | Filler Clip
393 | 2026-01-16 08:42:00 - 2026-01-16 08:43:00 | PostRoll | Filler Clip
394 | 2026-01-16 08:43:00 - 2026-01-16 08:44:00 | PostRoll | Filler Clip
395 | 2026-01-16 08:44:00 - 2026-01-16 08:45:00 | PostRoll | Filler Clip
396 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Padded Movie 03
397 | 2026-01-16 09:37:00 - 2026-01-16 09:38:00 | PostRoll | Filler Clip
398 | 2026-01-16 09:38:00 - 2026-01-16 09:39:00 | PostRoll | Filler Clip
399 | 2026-01-16 09:39:00 - 2026-01-16 09:40:00 | PostRoll | Filler Clip
400 | 2026-01-16 09:40:00 - 2026-01-16 09:41:00 | PostRoll | Filler Clip
401 | 2026-01-16 09:41:00 - 2026-01-16 09:42:00 | PostRoll | Filler Clip
402 | 2026-01-16 09:42:00 - 2026-01-16 09:43:00 | PostRoll | Filler Clip
403 | 2026-01-16 09:43:00 - 2026-01-16 09:44:00 | PostRoll | Filler Clip
404 | 2026-01-16 09:44:00 - 2026-01-16 09:45:00 | PostRoll | Filler Clip
405 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Padded Movie 01
406 | 2026-01-16 10:07:00 - 2026-01-16 10:08:00 | PostRoll | Filler Clip
407 | 2026-01-16 10:08:00 - 2026-01-16 10:09:00 | PostRoll | Filler Clip
408 | 2026-01-16 10:09:00 - 2026-01-16 10:10:00 | PostRoll | Filler Clip
409 | 2026-01-16 10:10:00 - 2026-01-16 10:11:00 | PostRoll | Filler Clip
410 | 2026-01-16 10:11:00 - 2026-01-16 10:12:00 | PostRoll | Filler Clip
411 | 2026-01-16 10:12:00 - 2026-01-16 10:13:00 | PostRoll | Filler Clip
412 | 2026-01-16 10:13:00 - 2026-01-16 10:14:00 | PostRoll | Filler Clip
413 | 2026-01-16 10:14:00 - 2026-01-16 10:15:00 | PostRoll | Filler Clip
414 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Padded Movie 02
415 | 2026-01-16 10:52:00 - 2026-01-16 10:53:00 | PostRoll | Filler Clip
416 | 2026-01-16 10:53:00 - 2026-01-16 10:54:00 | PostRoll | Filler Clip
417 | 2026-01-16 10:54:00 - 2026-01-16 10:55:00 | PostRoll | Filler Clip
418 | 2026-01-16 10:55:00 - 2026-01-16 10:56:00 | PostRoll | Filler Clip
419 | 2026-01-16 10:56:00 - 2026-01-16 10:57:00 | PostRoll | Filler Clip
420 | 2026-01-16 10:57:00 - 2026-01-16 10:58:00 | PostRoll | Filler Clip
421 | 2026-01-16 10:58:00 - 2026-01-16 10:59:00 | PostRoll | Filler Clip
422 | 2026-01-16 10:59:00 - 2026-01-16 11:00:00 | PostRoll | Filler Clip
423 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Padded Movie 03
424 | 2026-01-16 11:52:00 - 2026-01-16 11:53:00 | PostRoll | Filler Clip
425 | 2026-01-16 11:53:00 - 2026-01-16 11:54:00 | PostRoll | Filler Clip
426 | 2026-01-16 11:54:00 - 2026-01-16 11:55:00 | PostRoll | Filler Clip
427 | 2026-01-16 11:55:00 - 2026-01-16 11:56:00 | PostRoll | Filler Clip
428 | 2026-01-16 11:56:00 - 2026-01-16 11:57:00 | PostRoll | Filler Clip
429 | 2026-01-16 11:57:00 - 2026-01-16 11:58:00 | PostRoll | Filler Clip
430 | 2026-01-16 11:58:00 - 2026-01-16 11:59:00 | PostRoll | Filler Clip
431 | 2026-01-16 11:59:00 - 2026-01-16 12:00:00 | PostRoll | Filler Clip
432 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Padded Movie 01
433 | 2026-01-16 12:22:00 - 2026-01-16 12:23:00 | PostRoll | Filler Clip
434 | 2026-01-16 12:23:00 - 2026-01-16 12:24:00 | PostRoll | Filler Clip
435 | 2026-01-16 12:24:00 - 2026-01-16 12:25:00 | PostRoll | Filler Clip
436 | 2026-01-16 12:25:00 - 2026-01-16 12:26:00 | PostRoll | Filler Clip
437 | 2026-01-16 12:26:00 - 2026-01-16 12:27:00 | PostRoll | Filler Clip
438 | 2026-01-16 12:27:00 - 2026-01-16 12:28:00 | PostRoll | Filler Clip
439 | 2026-01-16 12:28:00 - 2026-01-16 12:29:00 | PostRoll | Filler Clip
440 | 2026-01-16 12:29:00 - 2026-01-16 12:30:00 | PostRoll | Filler Clip
441 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Padded Movie 02
442 | 2026-01-16 13:07:00 - 2026-01-16 13:08:00 | PostRoll | Filler Clip
443 | 2026-01-16 13:08:00 - 2026-01-16 13:09:00 | PostRoll | Filler Clip
444 | 2026-01-16 13:09:00 - 2026-01-16 13:10:00 | PostRoll | Filler Clip
445 | 2026-01-16 13:10:00 - 2026-01-16 13:11:00 | PostRoll | Filler Clip
446 | 2026-01-16 13:11:00 - 2026-01-16 13:12:00 | PostRoll | Filler Clip
447 | 2026-01-16 13:12:00 - 2026-01-16 13:13:00 | PostRoll | Filler Clip
448 | 2026-01-16 13:13:00 - 2026-01-16 13:14:00 | PostRoll | Filler Clip
449 | 2026-01-16 13:14:00 - 2026-01-16 13:15:00 | PostRoll | Filler Clip
450 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Padded Movie 03
451 | 2026-01-16 14:07:00 - 2026-01-16 14:08:00 | PostRoll | Filler Clip
452 | 2026-01-16 14:08:00 - 2026-01-16 14:09:00 | PostRoll | Filler Clip
453 | 2026-01-16 14:09:00 - 2026-01-16 14:10:00 | PostRoll | Filler Clip
454 | 2026-01-16 14:10:00 - 2026-01-16 14:11:00 | PostRoll | Filler Clip
455 | 2026-01-16 14:11:00 - 2026-01-16 14:12:00 | PostRoll | Filler Clip
456 | 2026-01-16 14:12:00 - 2026-01-16 14:13:00 | PostRoll | Filler Clip
457 | 2026-01-16 14:13:00 - 2026-01-16 14:14:00 | PostRoll | Filler Clip
458 | 2026-01-16 14:14:00 - 2026-01-16 14:15:00 | PostRoll | Filler Clip
459 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Padded Movie 01
460 | 2026-01-16 14:37:00 - 2026-01-16 14:38:00 | PostRoll | Filler Clip
461 | 2026-01-16 14:38:00 - 2026-01-16 14:39:00 | PostRoll | Filler Clip
462 | 2026-01-16 14:39:00 - 2026-01-16 14:40:00 | PostRoll | Filler Clip
463 | 2026-01-16 14:40:00 - 2026-01-16 14:41:00 | PostRoll | Filler Clip
464 | 2026-01-16 14:41:00 - 2026-01-16 14:42:00 | PostRoll | Filler Clip
465 | 2026-01-16 14:42:00 - 2026-01-16 14:43:00 | PostRoll | Filler Clip
466 | 2026-01-16 14:43:00 - 2026-01-16 14:44:00 | PostRoll | Filler Clip
467 | 2026-01-16 14:44:00 - 2026-01-16 14:45:00 | PostRoll | Filler Clip
468 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Padded Movie 02
469 | 2026-01-16 15:22:00 - 2026-01-16 15:23:00 | PostRoll | Filler Clip
470 | 2026-01-16 15:23:00 - 2026-01-16 15:24:00 | PostRoll | Filler Clip
471 | 2026-01-16 15:24:00 - 2026-01-16 15:25:00 | PostRoll | Filler Clip
472 | 2026-01-16 15:25:00 - 2026-01-16 15:26:00 | PostRoll | Filler Clip
473 | 2026-01-16 15:26:00 - 2026-01-16 15:27:00 | PostRoll | Filler Clip
474 | 2026-01-16 15:27:00 - 2026-01-16 15:28:00 | PostRoll | Filler Clip
475 | 2026-01-16 15:28:00 - 2026-01-16 15:29:00 | PostRoll | Filler Clip
476 | 2026-01-16 15:29:00 - 2026-01-16 15:30:00 | PostRoll | Filler Clip
477 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Padded Movie 03
478 | 2026-01-16 16:22:00 - 2026-01-16 16:23:00 | PostRoll | Filler Clip
479 | 2026-01-16 16:23:00 - 2026-01-16 16:24:00 | PostRoll | Filler Clip
480 | 2026-01-16 16:24:00 - 2026-01-16 16:25:00 | PostRoll | Filler Clip
481 | 2026-01-16 16:25:00 - 2026-01-16 16:26:00 | PostRoll | Filler Clip
482 | 2026-01-16 16:26:00 - 2026-01-16 16:27:00 | PostRoll | Filler Clip
483 | 2026-01-16 16:27:00 - 2026-01-16 16:28:00 | PostRoll | Filler Clip
484 | 2026-01-16 16:28:00 - 2026-01-16 16:29:00 | PostRoll | Filler Clip
485 | 2026-01-16 16:29:00 - 2026-01-16 16:30:00 | PostRoll | Filler Clip
486 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Padded Movie 01
487 | 2026-01-16 16:52:00 - 2026-01-16 16:53:00 | PostRoll | Filler Clip
488 | 2026-01-16 16:53:00 - 2026-01-16 16:54:00 | PostRoll | Filler Clip
489 | 2026-01-16 16:54:00 - 2026-01-16 16:55:00 | PostRoll | Filler Clip
490 | 2026-01-16 16:55:00 - 2026-01-16 16:56:00 | PostRoll | Filler Clip
491 | 2026-01-16 16:56:00 - 2026-01-16 16:57:00 | PostRoll | Filler Clip
492 | 2026-01-16 16:57:00 - 2026-01-16 16:58:00 | PostRoll | Filler Clip
493 | 2026-01-16 16:58:00 - 2026-01-16 16:59:00 | PostRoll | Filler Clip
494 | 2026-01-16 16:59:00 - 2026-01-16 17:00:00 | PostRoll | Filler Clip
495 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Padded Movie 02
496 | 2026-01-16 17:37:00 - 2026-01-16 17:38:00 | PostRoll | Filler Clip
497 | 2026-01-16 17:38:00 - 2026-01-16 17:39:00 | PostRoll | Filler Clip
498 | 2026-01-16 17:39:00 - 2026-01-16 17:40:00 | PostRoll | Filler Clip
499 | 2026-01-16 17:40:00 - 2026-01-16 17:41:00 | PostRoll | Filler Clip
500 | 2026-01-16 17:41:00 - 2026-01-16 17:42:00 | PostRoll | Filler Clip
501 | 2026-01-16 17:42:00 - 2026-01-16 17:43:00 | PostRoll | Filler Clip
502 | 2026-01-16 17:43:00 - 2026-01-16 17:44:00 | PostRoll | Filler Clip
503 | 2026-01-16 17:44:00 - 2026-01-16 17:45:00 | PostRoll | Filler Clip
504 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Padded Movie 03
505 | 2026-01-16 18:37:00 - 2026-01-16 18:38:00 | PostRoll | Filler Clip
506 | 2026-01-16 18:38:00 - 2026-01-16 18:39:00 | PostRoll | Filler Clip
507 | 2026-01-16 18:39:00 - 2026-01-16 18:40:00 | PostRoll | Filler Clip
508 | 2026-01-16 18:40:00 - 2026-01-16 18:41:00 | PostRoll | Filler Clip
509 | 2026-01-16 18:41:00 - 2026-01-16 18:42:00 | PostRoll | Filler Clip
510 | 2026-01-16 18:42:00 - 2026-01-16 18:43:00 | PostRoll | Filler Clip
511 | 2026-01-16 18:43:00 - 2026-01-16 18:44:00 | PostRoll | Filler Clip
512 | 2026-01-16 18:44:00 - 2026-01-16 18:45:00 | PostRoll | Filler Clip
513 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Padded Movie 01
514 | 2026-01-16 19:07:00 - 2026-01-16 19:08:00 | PostRoll | Filler Clip
515 | 2026-01-16 19:08:00 - 2026-01-16 19:09:00 | PostRoll | Filler Clip
516 | 2026-01-16 19:09:00 - 2026-01-16 19:10:00 | PostRoll | Filler Clip
517 | 2026-01-16 19:10:00 - 2026-01-16 19:11:00 | PostRoll | Filler Clip
518 | 2026-01-16 19:11:00 - 2026-01-16 19:12:00 | PostRoll | Filler Clip
519 | 2026-01-16 19:12:00 - 2026-01-16 19:13:00 | PostRoll | Filler Clip
520 | 2026-01-16 19:13:00 - 2026-01-16 19:14:00 | PostRoll | Filler Clip
521 | 2026-01-16 19:14:00 - 2026-01-16 19:15:00 | PostRoll | Filler Clip
522 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Padded Movie 02
523 | 2026-01-16 19:52:00 - 2026-01-16 19:53:00 | PostRoll | Filler Clip
524 | 2026-01-16 19:53:00 - 2026-01-16 19:54:00 | PostRoll | Filler Clip
525 | 2026-01-16 19:54:00 - 2026-01-16 19:55:00 | PostRoll | Filler Clip
526 | 2026-01-16 19:55:00 - 2026-01-16 19:56:00 | PostRoll | Filler Clip
527 | 2026-01-16 19:56:00 - 2026-01-16 19:57:00 | PostRoll | Filler Clip
528 | 2026-01-16 19:57:00 - 2026-01-16 19:58:00 | PostRoll | Filler Clip
529 | 2026-01-16 19:58:00 - 2026-01-16 19:59:00 | PostRoll | Filler Clip
530 | 2026-01-16 19:59:00 - 2026-01-16 20:00:00 | PostRoll | Filler Clip
531 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Padded Movie 03
532 | 2026-01-16 20:52:00 - 2026-01-16 20:53:00 | PostRoll | Filler Clip
533 | 2026-01-16 20:53:00 - 2026-01-16 20:54:00 | PostRoll | Filler Clip
534 | 2026-01-16 20:54:00 - 2026-01-16 20:55:00 | PostRoll | Filler Clip
535 | 2026-01-16 20:55:00 - 2026-01-16 20:56:00 | PostRoll | Filler Clip
536 | 2026-01-16 20:56:00 - 2026-01-16 20:57:00 | PostRoll | Filler Clip
537 | 2026-01-16 20:57:00 - 2026-01-16 20:58:00 | PostRoll | Filler Clip
538 | 2026-01-16 20:58:00 - 2026-01-16 20:59:00 | PostRoll | Filler Clip
539 | 2026-01-16 20:59:00 - 2026-01-16 21:00:00 | PostRoll | Filler Clip
540 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Padded Movie 01
541 | 2026-01-16 21:22:00 - 2026-01-16 21:23:00 | PostRoll | Filler Clip
542 | 2026-01-16 21:23:00 - 2026-01-16 21:24:00 | PostRoll | Filler Clip
543 | 2026-01-16 21:24:00 - 2026-01-16 21:25:00 | PostRoll | Filler Clip
544 | 2026-01-16 21:25:00 - 2026-01-16 21:26:00 | PostRoll | Filler Clip
545 | 2026-01-16 21:26:00 - 2026-01-16 21:27:00 | PostRoll | Filler Clip
546 | 2026-01-16 21:27:00 - 2026-01-16 21:28:00 | PostRoll | Filler Clip
547 | 2026-01-16 21:28:00 - 2026-01-16 21:29:00 | PostRoll | Filler Clip
548 | 2026-01-16 21:29:00 - 2026-01-16 21:30:00 | PostRoll | Filler Clip
549 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Padded Movie 02
550 | 2026-01-16 22:07:00 - 2026-01-16 22:08:00 | PostRoll | Filler Clip
551 | 2026-01-16 22:08:00 - 2026-01-16 22:09:00 | PostRoll | Filler Clip
552 | 2026-01-16 22:09:00 - 2026-01-16 22:10:00 | PostRoll | Filler Clip
553 | 2026-01-16 22:10:00 - 2026-01-16 22:11:00 | PostRoll | Filler Clip
554 | 2026-01-16 22:11:00 - 2026-01-16 22:12:00 | PostRoll | Filler Clip
555 | 2026-01-16 22:12:00 - 2026-01-16 22:13:00 | PostRoll | Filler Clip
556 | 2026-01-16 22:13:00 - 2026-01-16 22:14:00 | PostRoll | Filler Clip
557 | 2026-01-16 22:14:00 - 2026-01-16 22:15:00 | PostRoll | Filler Clip
558 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Padded Movie 03
559 | 2026-01-16 23:07:00 - 2026-01-16 23:08:00 | PostRoll | Filler Clip
560 | 2026-01-16 23:08:00 - 2026-01-16 23:09:00 | PostRoll | Filler Clip
561 | 2026-01-16 23:09:00 - 2026-01-16 23:10:00 | PostRoll | Filler Clip
562 | 2026-01-16 23:10:00 - 2026-01-16 23:11:00 | PostRoll | Filler Clip
563 | 2026-01-16 23:11:00 - 2026-01-16 23:12:00 | PostRoll | Filler Clip
564 | 2026-01-16 23:12:00 - 2026-01-16 23:13:00 | PostRoll | Filler Clip
565 | 2026-01-16 23:13:00 - 2026-01-16 23:14:00 | PostRoll | Filler Clip
566 | 2026-01-16 23:14:00 - 2026-01-16 23:15:00 | PostRoll | Filler Clip
567 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Padded Movie 01
568 | 2026-01-16 23:37:00 - 2026-01-16 23:38:00 | PostRoll | Filler Clip
569 | 2026-01-16 23:38:00 - 2026-01-16 23:39:00 | PostRoll | Filler Clip
570 | 2026-01-16 23:39:00 - 2026-01-16 23:40:00 | PostRoll | Filler Clip
571 | 2026-01-16 23:40:00 - 2026-01-16 23:41:00 | PostRoll | Filler Clip
572 | 2026-01-16 23:41:00 - 2026-01-16 23:42:00 | PostRoll | Filler Clip
573 | 2026-01-16 23:42:00 - 2026-01-16 23:43:00 | PostRoll | Filler Clip
574 | 2026-01-16 23:43:00 - 2026-01-16 23:44:00 | PostRoll | Filler Clip
575 | 2026-01-16 23:44:00 - 2026-01-16 23:45:00 | PostRoll | Filler Clip
576 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Padded Movie 02
577 | 2026-01-17 00:22:00 - 2026-01-17 00:23:00 | PostRoll | Filler Clip
578 | 2026-01-17 00:23:00 - 2026-01-17 00:24:00 | PostRoll | Filler Clip
579 | 2026-01-17 00:24:00 - 2026-01-17 00:25:00 | PostRoll | Filler Clip
580 | 2026-01-17 00:25:00 - 2026-01-17 00:26:00 | PostRoll | Filler Clip
581 | 2026-01-17 00:26:00 - 2026-01-17 00:27:00 | PostRoll | Filler Clip
582 | 2026-01-17 00:27:00 - 2026-01-17 00:28:00 | PostRoll | Filler Clip
583 | 2026-01-17 00:28:00 - 2026-01-17 00:29:00 | PostRoll | Filler Clip
584 | 2026-01-17 00:29:00 - 2026-01-17 00:30:00 | PostRoll | Filler Clip
585 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Padded Movie 03
586 | 2026-01-17 01:22:00 - 2026-01-17 01:23:00 | PostRoll | Filler Clip
587 | 2026-01-17 01:23:00 - 2026-01-17 01:24:00 | PostRoll | Filler Clip
588 | 2026-01-17 01:24:00 - 2026-01-17 01:25:00 | PostRoll | Filler Clip
589 | 2026-01-17 01:25:00 - 2026-01-17 01:26:00 | PostRoll | Filler Clip
590 | 2026-01-17 01:26:00 - 2026-01-17 01:27:00 | PostRoll | Filler Clip
591 | 2026-01-17 01:27:00 - 2026-01-17 01:28:00 | PostRoll | Filler Clip
592 | 2026-01-17 01:28:00 - 2026-01-17 01:29:00 | PostRoll | Filler Clip
593 | 2026-01-17 01:29:00 - 2026-01-17 01:30:00 | PostRoll | Filler Clip
594 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Padded Movie 01
595 | 2026-01-17 01:52:00 - 2026-01-17 01:53:00 | PostRoll | Filler Clip
596 | 2026-01-17 01:53:00 - 2026-01-17 01:54:00 | PostRoll | Filler Clip
597 | 2026-01-17 01:54:00 - 2026-01-17 01:55:00 | PostRoll | Filler Clip
598 | 2026-01-17 01:55:00 - 2026-01-17 01:56:00 | PostRoll | Filler Clip
599 | 2026-01-17 01:56:00 - 2026-01-17 01:57:00 | PostRoll | Filler Clip
600 | 2026-01-17 01:57:00 - 2026-01-17 01:58:00 | PostRoll | Filler Clip
601 | 2026-01-17 01:58:00 - 2026-01-17 01:59:00 | PostRoll | Filler Clip
602 | 2026-01-17 01:59:00 - 2026-01-17 02:00:00 | PostRoll | Filler Clip
603 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Padded Movie 02
604 | 2026-01-17 02:37:00 - 2026-01-17 02:38:00 | PostRoll | Filler Clip
605 | 2026-01-17 02:38:00 - 2026-01-17 02:39:00 | PostRoll | Filler Clip
606 | 2026-01-17 02:39:00 - 2026-01-17 02:40:00 | PostRoll | Filler Clip
607 | 2026-01-17 02:40:00 - 2026-01-17 02:41:00 | PostRoll | Filler Clip
608 | 2026-01-17 02:41:00 - 2026-01-17 02:42:00 | PostRoll | Filler Clip
609 | 2026-01-17 02:42:00 - 2026-01-17 02:43:00 | PostRoll | Filler Clip
610 | 2026-01-17 02:43:00 - 2026-01-17 02:44:00 | PostRoll | Filler Clip
611 | 2026-01-17 02:44:00 - 2026-01-17 02:45:00 | PostRoll | Filler Clip
612 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Padded Movie 03
613 | 2026-01-17 03:37:00 - 2026-01-17 03:38:00 | PostRoll | Filler Clip
614 | 2026-01-17 03:38:00 - 2026-01-17 03:39:00 | PostRoll | Filler Clip
615 | 2026-01-17 03:39:00 - 2026-01-17 03:40:00 | PostRoll | Filler Clip
616 | 2026-01-17 03:40:00 - 2026-01-17 03:41:00 | PostRoll | Filler Clip
617 | 2026-01-17 03:41:00 - 2026-01-17 03:42:00 | PostRoll | Filler Clip
618 | 2026-01-17 03:42:00 - 2026-01-17 03:43:00 | PostRoll | Filler Clip
619 | 2026-01-17 03:43:00 - 2026-01-17 03:44:00 | PostRoll | Filler Clip
620 | 2026-01-17 03:44:00 - 2026-01-17 03:45:00 | PostRoll | Filler Clip
621 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Padded Movie 01
622 | 2026-01-17 04:07:00 - 2026-01-17 04:08:00 | PostRoll | Filler Clip
623 | 2026-01-17 04:08:00 - 2026-01-17 04:09:00 | PostRoll | Filler Clip
624 | 2026-01-17 04:09:00 - 2026-01-17 04:10:00 | PostRoll | Filler Clip
625 | 2026-01-17 04:10:00 - 2026-01-17 04:11:00 | PostRoll | Filler Clip
626 | 2026-01-17 04:11:00 - 2026-01-17 04:12:00 | PostRoll | Filler Clip
627 | 2026-01-17 04:12:00 - 2026-01-17 04:13:00 | PostRoll | Filler Clip
628 | 2026-01-17 04:13:00 - 2026-01-17 04:14:00 | PostRoll | Filler Clip
629 | 2026-01-17 04:14:00 - 2026-01-17 04:15:00 | PostRoll | Filler Clip
630 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Padded Movie 02
631 | 2026-01-17 04:52:00 - 2026-01-17 04:53:00 | PostRoll | Filler Clip
632 | 2026-01-17 04:53:00 - 2026-01-17 04:54:00 | PostRoll | Filler Clip
633 | 2026-01-17 04:54:00 - 2026-01-17 04:55:00 | PostRoll | Filler Clip
634 | 2026-01-17 04:55:00 - 2026-01-17 04:56:00 | PostRoll | Filler Clip
635 | 2026-01-17 04:56:00 - 2026-01-17 04:57:00 | PostRoll | Filler Clip
636 | 2026-01-17 04:57:00 - 2026-01-17 04:58:00 | PostRoll | Filler Clip
637 | 2026-01-17 04:58:00 - 2026-01-17 04:59:00 | PostRoll | Filler Clip
638 | 2026-01-17 04:59:00 - 2026-01-17 05:00:00 | PostRoll | Filler Clip
639 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Padded Movie 03
640 | 2026-01-17 05:52:00 - 2026-01-17 05:53:00 | PostRoll | Filler Clip
641 | 2026-01-17 05:53:00 - 2026-01-17 05:54:00 | PostRoll | Filler Clip
642 | 2026-01-17 05:54:00 - 2026-01-17 05:55:00 | PostRoll | Filler Clip
643 | 2026-01-17 05:55:00 - 2026-01-17 05:56:00 | PostRoll | Filler Clip
644 | 2026-01-17 05:56:00 - 2026-01-17 05:57:00 | PostRoll | Filler Clip
645 | 2026-01-17 05:57:00 - 2026-01-17 05:58:00 | PostRoll | Filler Clip
646 | 2026-01-17 05:58:00 - 2026-01-17 05:59:00 | PostRoll | Filler Clip
647 | 2026-01-17 05:59:00 - 2026-01-17 06:00:00 | PostRoll | Filler Clip
@@ -1,72 +0,0 @@
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Shuffle Movie 04
001 | 2026-01-15 00:30:00 - 2026-01-15 01:30:00 | None | Shuffle Movie 06
002 | 2026-01-15 01:30:00 - 2026-01-15 02:30:00 | None | Shuffle Movie 03
003 | 2026-01-15 02:30:00 - 2026-01-15 03:00:00 | None | Shuffle Movie 01
004 | 2026-01-15 03:00:00 - 2026-01-15 03:45:00 | None | Shuffle Movie 02
005 | 2026-01-15 03:45:00 - 2026-01-15 04:30:00 | None | Shuffle Movie 05
006 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Shuffle Movie 01
007 | 2026-01-15 05:00:00 - 2026-01-15 05:45:00 | None | Shuffle Movie 05
008 | 2026-01-15 05:45:00 - 2026-01-15 06:45:00 | None | Shuffle Movie 06
009 | 2026-01-15 06:45:00 - 2026-01-15 07:15:00 | None | Shuffle Movie 04
010 | 2026-01-15 07:15:00 - 2026-01-15 08:00:00 | None | Shuffle Movie 02
011 | 2026-01-15 08:00:00 - 2026-01-15 09:00:00 | None | Shuffle Movie 03
012 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Shuffle Movie 01
013 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Shuffle Movie 02
014 | 2026-01-15 10:15:00 - 2026-01-15 11:15:00 | None | Shuffle Movie 06
015 | 2026-01-15 11:15:00 - 2026-01-15 11:45:00 | None | Shuffle Movie 04
016 | 2026-01-15 11:45:00 - 2026-01-15 12:30:00 | None | Shuffle Movie 05
017 | 2026-01-15 12:30:00 - 2026-01-15 13:30:00 | None | Shuffle Movie 03
018 | 2026-01-15 13:30:00 - 2026-01-15 14:15:00 | None | Shuffle Movie 05
019 | 2026-01-15 14:15:00 - 2026-01-15 14:45:00 | None | Shuffle Movie 01
020 | 2026-01-15 14:45:00 - 2026-01-15 15:45:00 | None | Shuffle Movie 06
021 | 2026-01-15 15:45:00 - 2026-01-15 16:15:00 | None | Shuffle Movie 04
022 | 2026-01-15 16:15:00 - 2026-01-15 17:00:00 | None | Shuffle Movie 02
023 | 2026-01-15 17:00:00 - 2026-01-15 18:00:00 | None | Shuffle Movie 03
024 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Shuffle Movie 01
025 | 2026-01-15 18:30:00 - 2026-01-15 19:00:00 | None | Shuffle Movie 04
026 | 2026-01-15 19:00:00 - 2026-01-15 19:45:00 | None | Shuffle Movie 05
027 | 2026-01-15 19:45:00 - 2026-01-15 20:30:00 | None | Shuffle Movie 02
028 | 2026-01-15 20:30:00 - 2026-01-15 21:30:00 | None | Shuffle Movie 03
029 | 2026-01-15 21:30:00 - 2026-01-15 22:30:00 | None | Shuffle Movie 06
030 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Shuffle Movie 04
031 | 2026-01-15 23:00:00 - 2026-01-15 23:30:00 | None | Shuffle Movie 01
032 | 2026-01-15 23:30:00 - 2026-01-16 00:30:00 | None | Shuffle Movie 06
033 | 2026-01-16 00:30:00 - 2026-01-16 01:15:00 | None | Shuffle Movie 02
034 | 2026-01-16 01:15:00 - 2026-01-16 02:15:00 | None | Shuffle Movie 03
035 | 2026-01-16 02:15:00 - 2026-01-16 03:00:00 | None | Shuffle Movie 05
036 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Shuffle Movie 01
037 | 2026-01-16 03:30:00 - 2026-01-16 04:00:00 | None | Shuffle Movie 04
038 | 2026-01-16 04:00:00 - 2026-01-16 04:45:00 | None | Shuffle Movie 02
039 | 2026-01-16 04:45:00 - 2026-01-16 05:45:00 | None | Shuffle Movie 06
040 | 2026-01-16 05:45:00 - 2026-01-16 06:45:00 | None | Shuffle Movie 03
041 | 2026-01-16 06:45:00 - 2026-01-16 07:30:00 | None | Shuffle Movie 05
042 | 2026-01-16 07:30:00 - 2026-01-16 08:30:00 | None | Shuffle Movie 06
043 | 2026-01-16 08:30:00 - 2026-01-16 09:00:00 | None | Shuffle Movie 01
044 | 2026-01-16 09:00:00 - 2026-01-16 10:00:00 | None | Shuffle Movie 03
045 | 2026-01-16 10:00:00 - 2026-01-16 10:45:00 | None | Shuffle Movie 05
046 | 2026-01-16 10:45:00 - 2026-01-16 11:15:00 | None | Shuffle Movie 04
047 | 2026-01-16 11:15:00 - 2026-01-16 12:00:00 | None | Shuffle Movie 02
048 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Shuffle Movie 04
049 | 2026-01-16 12:30:00 - 2026-01-16 13:15:00 | None | Shuffle Movie 02
050 | 2026-01-16 13:15:00 - 2026-01-16 13:45:00 | None | Shuffle Movie 01
051 | 2026-01-16 13:45:00 - 2026-01-16 14:45:00 | None | Shuffle Movie 03
052 | 2026-01-16 14:45:00 - 2026-01-16 15:30:00 | None | Shuffle Movie 05
053 | 2026-01-16 15:30:00 - 2026-01-16 16:30:00 | None | Shuffle Movie 06
054 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Shuffle Movie 01
055 | 2026-01-16 17:00:00 - 2026-01-16 18:00:00 | None | Shuffle Movie 06
056 | 2026-01-16 18:00:00 - 2026-01-16 18:45:00 | None | Shuffle Movie 02
057 | 2026-01-16 18:45:00 - 2026-01-16 19:45:00 | None | Shuffle Movie 03
058 | 2026-01-16 19:45:00 - 2026-01-16 20:15:00 | None | Shuffle Movie 04
059 | 2026-01-16 20:15:00 - 2026-01-16 21:00:00 | None | Shuffle Movie 05
060 | 2026-01-16 21:00:00 - 2026-01-16 21:45:00 | None | Shuffle Movie 02
061 | 2026-01-16 21:45:00 - 2026-01-16 22:45:00 | None | Shuffle Movie 03
062 | 2026-01-16 22:45:00 - 2026-01-16 23:15:00 | None | Shuffle Movie 01
063 | 2026-01-16 23:15:00 - 2026-01-16 23:45:00 | None | Shuffle Movie 04
064 | 2026-01-16 23:45:00 - 2026-01-17 00:45:00 | None | Shuffle Movie 06
065 | 2026-01-17 00:45:00 - 2026-01-17 01:30:00 | None | Shuffle Movie 05
066 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Shuffle Movie 01
067 | 2026-01-17 02:00:00 - 2026-01-17 02:45:00 | None | Shuffle Movie 02
068 | 2026-01-17 02:45:00 - 2026-01-17 03:30:00 | None | Shuffle Movie 05
069 | 2026-01-17 03:30:00 - 2026-01-17 04:30:00 | None | Shuffle Movie 06
070 | 2026-01-17 04:30:00 - 2026-01-17 05:00:00 | None | Shuffle Movie 04
071 | 2026-01-17 05:00:00 - 2026-01-17 06:00:00 | None | Shuffle Movie 03
@@ -1,108 +0,0 @@
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
@@ -3,7 +3,6 @@ using System.Runtime.CompilerServices;
using System.Text;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Scheduling;
@@ -87,53 +86,6 @@ public class PlayoutBuildGoldenTests
[Test]
public Task Block_playout() => Verify("block.txt", BuildBlockPlayout);
// Issue #77: clock-boundary padding. A PostRoll FillerPreset with FillerMode.Pad +
// PadToNearestMinute = 15 snaps each ragged content item up to the next :00/:15/:30/:45 mark, filling
// the gap with filler. This locks BOTH the exact builder output (golden) AND the invariant that every
// content item after the first begins on a quarter-hour boundary — the end-to-end proof that the
// existing Pad machinery already delivers #77's "clean clock-aligned guides" for Classic playouts.
[Test]
public async Task Classic_clock_padded()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout();
List<PlayoutItem> content = items
.Where(i => i.FillerKind == FillerKind.None)
.OrderBy(i => i.Start)
.ToList();
// Sanity: the fixture must actually produce several content items and pad filler between them.
content.Count.ShouldBeGreaterThan(3);
items.ShouldContain(i => i.FillerKind == FillerKind.PostRoll);
// The invariant: content items (the first is the raw anchor at :00) start on a :15 boundary because
// the preceding item was padded up to it. Raw Start is UTC here, matching the deterministic fixture.
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
await CompareGolden("classic-clock-padded.txt", items, titles);
}
// Classic + PlaybackOrder.Shuffle: exercises PlayoutBuilder's call into the shuffle-source helper
// (GetGroupedMediaItemsForShuffle) that #380 moves to ShuffleSourceBuilder, plus the wiring into
// ShuffledMediaCollectionEnumerator. Unlike the chronological fixture, shuffle output depends on the
// seed, so this build pins playout.Seed and uses Continue mode (Reset would randomize the seed).
[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()
@@ -141,8 +93,7 @@ public class PlayoutBuildGoldenTests
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", "1");
try
{
foreach (Func<Task> regen in new Func<Task>[]
{ Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle })
foreach (Func<Task> regen in new Func<Task>[] { Classic_chronological, Block_playout })
{
try
{
@@ -167,13 +118,7 @@ public class PlayoutBuildGoldenTests
Func<Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)>> build)
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await build();
await CompareGolden(goldenName, items, titles);
}
// Snapshot + golden-compare (or regenerate). Split out of Verify so a test that also runs explicit
// assertions on the built items (e.g. Classic_clock_padded) can reuse the same golden mechanics.
private async Task CompareGolden(string goldenName, List<PlayoutItem> items, Dictionary<int, string> titles)
{
string actual = Canonicalize(Snapshot(items, titles));
string path = Path.Combine(GoldenDir(), goldenName);
@@ -240,329 +185,6 @@ public class PlayoutBuildGoldenTests
return (buildResult.AddedItems, titles);
}
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildShufflePlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedShuffleData(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);
// Continue (not Reset): Reset randomizes playout.Seed, which would make the shuffle
// nondeterministic. The pinned Seed (set in SeedShuffleData) flows to the enumerator seed, so the
// shuffled order is stable. Anchor is null and there are no existing items, so Continue still
// builds the full pinned 2-day window from the start.
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)> SeedShuffleData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Shuffle 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);
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Shuffle Movie {i:D2}",
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
var collection = new Collection
{
Name = "Shuffle Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItems = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
Collection = collection,
CollectionId = collection.Id,
CollectionType = CollectionType.Collection,
PlayoutDuration = TimeSpan.FromHours(1),
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Shuffle
}
};
var ffmpegProfile = new FFmpegProfile { Name = "Shuffle FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000004"))
{
Name = "Shuffle Test Channel",
Number = "4",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = "Shuffle 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 shuffle is deterministic; Continue mode preserves it (Reset would overwrite).
Seed = 1234567
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
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();
@@ -687,210 +309,6 @@ public class PlayoutBuildGoldenTests
TimeSpan.Zero);
}
// --- Clock-boundary pad builder (issue #77) ---
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedPaddedData(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 GetPaddedReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
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)> SeedPaddedData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Padded 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);
// Content: three movies with OFF-boundary durations (22/37/52 min) so padding to :15 is visible.
int[] durationsMinutes = [22, 37, 52];
var movies = new List<Movie>();
for (var i = 1; i <= 3; i++)
{
movies.Add(new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Padded Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
// Filler: a SINGLE 1-minute clip. The builder shuffles filler collections (seed-dependent), so a
// one-item collection keeps the golden deterministic while still filling any integer-minute gap.
var fillerClip = new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = "Filler Clip", ReleaseDate = new DateTime(2019, 1, 1) }
},
LibraryPath = path,
LibraryPathId = path.Id
};
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.Movies.AddAsync(fillerClip, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
titles[fillerClip.Id] = fillerClip.MovieMetadata[0].Title;
var contentCollection = new Collection
{
Name = "Padded Content Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
var fillerCollection = new Collection
{
Name = "Padded Filler Collection",
MediaItems = new List<MediaItem> { fillerClip }
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
await context.Collections.AddAsync(fillerCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// TZ-INDEPENDENCE INVARIANT: the pad ceiling is computed on the LOCAL minute-of-hour
// (PlayoutModeSchedulerBase uses StartOffset = ToLocalTime()), yet this golden snapshots raw UTC
// Start/Finish. Those agree across machine timezones ONLY because PadToNearestMinute = 15 divides
// every real IANA UTC offset (all are multiples of 15 min — Kolkata +5:30, Nepal +5:45, Chatham
// +12:45), so the pad delta is offset-invariant. Do NOT regenerate this golden with a non-15-divisor
// increment (e.g. 10) — it would become machine-TZ dependent and need the Block-style Assume guard.
var padFiller = new FillerPreset
{
Name = "Pad To Quarter Hour",
FillerKind = FillerKind.PostRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 15,
CollectionType = CollectionType.Collection,
Collection = fillerCollection,
CollectionId = fillerCollection.Id
};
await context.FillerPresets.AddAsync(padFiller, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItems = new List<ProgramScheduleItem>
{
new ProgramScheduleItemOne
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
PlaybackOrder = PlaybackOrder.Chronological,
PostRollFiller = padFiller,
PostRollFillerId = padFiller.Id
}
};
var ffmpegProfile = new FFmpegProfile { Name = "Padded FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000003"))
{
Name = "Padded Test Channel",
Number = "3",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = "Padded 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
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetPaddedReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
.AsNoTracking()
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
.Include(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.PostRollFiller)
.FirstOrDefaultAsync();
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
programSchedule,
[],
[],
TimeSpan.Zero);
}
// --- Block builder ---
//
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
@@ -357,82 +357,6 @@ public class PlaylistEnumeratorTests
enumerator.CountForFiller.ShouldBe(7);
}
// Characterization of the cross-engine reach-in surface (#380): a playlist whose items use Shuffle
// and ShuffleInOrder exercises PlaylistEnumerator.Create's two calls into PlayoutBuilder's static
// shuffle-source helpers (GetGroupedMediaItemsForShuffle @ :174, GetCollectionItemsForShuffleInOrder
// @ :185). Those statics move to ShuffleSourceBuilder; this pins the emitted sequence so the move is
// proven behavior-preserving. Fixed seed => deterministic. If this changes, the refactor drifted.
[Test]
public async Task ReachIn_Shuffle_And_ShuffleInOrder_Items_Are_Characterized()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
// ShuffleInOrder (collection 2) reads its source from the repo's fake-multi-collection lookup.
repo.GetFakeMultiCollectionCollections(2, null, Arg.Any<CancellationToken>())
.Returns(
new List<CollectionWithItems>
{
new(
ShowId: 0,
ArtistId: 0,
Key: "collection-2",
MediaItems: [FakeMovie(20), FakeMovie(21), FakeMovie(22)],
ScheduleAsGroup: false,
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
UseCustomOrder: false)
});
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
Index = 0,
PlaybackOrder = PlaybackOrder.Shuffle,
PlayAll = true,
CollectionType = CollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10), FakeMovie(11), FakeMovie(12)]
},
{
new PlaylistItem
{
Id = 2,
Index = 1,
PlaybackOrder = PlaybackOrder.ShuffleInOrder,
PlayAll = true,
CollectionType = CollectionType.Collection,
CollectionId = 2
},
[FakeMovie(20), FakeMovie(21), FakeMovie(22)]
}
};
var state = new CollectionEnumeratorState { Seed = 12345 };
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None);
var items = new List<int>();
for (var i = 0; i < 12; i++)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
// Pinned from current behavior: Shuffle collection (10-12) and ShuffleInOrder collection (20-22)
// each cycle in a seed-stable shuffled order. This is the reach-in output; the ShuffleSourceBuilder
// extraction must reproduce it byte-for-byte.
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
}
private static Movie FakeMovie(int id) => new()
{
Id = id,
@@ -1,185 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
// Direct unit coverage for the shuffle-source helper extracted from PlayoutBuilder (#380). The builder
// goldens exercise the common non-multi-collection path; these pin the branch selection (multi-collection
// vs fake-multi-collection lookup, multi-part grouping on/off) that the goldens don't reach, so the
// "one well-tested place per family" the issue asks for is genuinely tested here.
[TestFixture]
public class ShuffleSourceBuilderTests
{
[Test]
public async Task GetGroupedMediaItemsForShuffle_Single_Collection_No_MultiPart_Groups_Each_Item()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
List<MediaItem> items = [FakeMovie(1), FakeMovie(2), FakeMovie(3)];
var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 7 };
List<GroupedMediaItem> result = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
repo,
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
items,
key,
CancellationToken.None);
// No multi-part grouping and no multi-collection => each item is its own group, no repo call.
result.Count.ShouldBe(3);
result.Select(g => g.First.Id).ShouldBe([1, 2, 3]);
result.ShouldAllBe(g => g.Additional.Count == 0);
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
}
[Test]
public async Task GetGroupedMediaItemsForShuffle_MultiCollection_Reads_From_Repo()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.GetMultiCollectionCollections(42, Arg.Any<CancellationToken>())
.Returns(
new List<CollectionWithItems>
{
new(
ShowId: 0,
ArtistId: 0,
Key: "mc",
MediaItems: [FakeMovie(10), FakeMovie(11)],
ScheduleAsGroup: false,
PlaybackOrder: PlaybackOrder.Shuffle,
UseCustomOrder: false)
});
var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 42 };
List<GroupedMediaItem> result = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
repo,
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
// the passed items list is ignored on the multi-collection branch
[],
key,
CancellationToken.None);
// The multi-collection branch reads from the repo and feeds MultiCollectionGrouper (whose grouping
// behavior is covered by its own tests); here we only pin the branch selection + passthrough.
await repo.Received(1).GetMultiCollectionCollections(42, Arg.Any<CancellationToken>());
GroupedMediaItem.FlattenGroups(result, 2).Select(mi => mi.Id).OrderBy(id => id).ShouldBe([10, 11]);
}
[Test]
public async Task GetCollectionItemsForShuffleInOrder_Single_Collection_Uses_Fake_MultiCollection_Lookup()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var expected = new List<CollectionWithItems>
{
new(
ShowId: 0,
ArtistId: 0,
Key: "fake",
MediaItems: [FakeMovie(20)],
ScheduleAsGroup: false,
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
UseCustomOrder: false)
};
repo.GetFakeMultiCollectionCollections(5, null, Arg.Any<CancellationToken>()).Returns(expected);
var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 5 };
List<CollectionWithItems> result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
repo,
key,
CancellationToken.None);
result.ShouldBe(expected);
await repo.Received(1).GetFakeMultiCollectionCollections(5, null, Arg.Any<CancellationToken>());
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
}
[Test]
public async Task GetCollectionItemsForShuffleInOrder_MultiCollection_Uses_MultiCollection_Lookup()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var expected = new List<CollectionWithItems>
{
new(
ShowId: 0,
ArtistId: 0,
Key: "mc",
MediaItems: [FakeMovie(30), FakeMovie(31)],
ScheduleAsGroup: true,
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
UseCustomOrder: false)
};
repo.GetMultiCollectionCollections(9, Arg.Any<CancellationToken>()).Returns(expected);
var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 9 };
List<CollectionWithItems> result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
repo,
key,
CancellationToken.None);
result.ShouldBe(expected);
await repo.Received(1).GetMultiCollectionCollections(9, Arg.Any<CancellationToken>());
await repo.DidNotReceiveWithAnyArgs().GetFakeMultiCollectionCollections(default, default, default);
}
[Test]
public async Task GetGroupedMediaItemsForShuffle_KeepMultiPart_Groups_Adjacent_Parts()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
// "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together.
List<MediaItem> items =
[
NamedEpisode("Episode 1", 1),
NamedEpisode("Episode 2 (1)", 2),
NamedEpisode("Episode 3 (2)", 3),
NamedEpisode("Episode 4", 4)
];
List<GroupedMediaItem> kept = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
repo,
keepMultiPartEpisodesTogether: true,
treatCollectionsAsShows: false,
items,
new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 1 },
CancellationToken.None);
// keep=true routes through MultiPartEpisodeGrouper -> the two parts collapse into one group (3 total).
kept.Count.ShouldBe(3);
List<GroupedMediaItem> notKept = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
repo,
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
items,
new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 1 },
CancellationToken.None);
// keep=false leaves each item its own group (4 total) — proves the flag observably selects the branch.
notKept.Count.ShouldBe(4);
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
}
private static Episode NamedEpisode(string title, int id) => new()
{
Id = id,
EpisodeMetadata = [new EpisodeMetadata { Title = title, EpisodeNumber = id }],
Season = new Season { SeasonNumber = 1, Show = new Show { Id = 1 }, ShowId = 1 }
};
private static Movie FakeMovie(int id) => new()
{
Id = id,
MediaVersions = [],
MovieMetadata =
[
new MovieMetadata { ReleaseDate = new DateTime(2020, 1, id) }
]
};
}
@@ -1,355 +0,0 @@
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,5 +15,4 @@ public record ChannelResponseModel(
string Language,
string StreamingMode,
bool IsEnabled,
bool ShowInEpg,
int PlayoutCount);
bool ShowInEpg);
@@ -5,14 +5,4 @@ public record HealthCheckResponseModel(
string Title,
string Status,
string Detail,
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);
string? Link);
@@ -13,8 +13,7 @@ public record MultiCollectionItemResponseModel(
int? SmartCollectionId,
string Name,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder,
int Weight = 1);
PlaybackOrder PlaybackOrder);
public record PagedMultiCollectionsResponseModel(
int TotalCount,
@@ -1,8 +0,0 @@
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,5 +11,4 @@ public record SearchResultAllItemsResponseModel(
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
List<int> RemoteStreamIds,
SearchResultAllItemsTotalsResponseModel Totals);
List<int> RemoteStreamIds);
@@ -1,17 +0,0 @@
#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,12 +12,4 @@ 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,10 +8,4 @@ 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;
}
@@ -1,32 +0,0 @@
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,10 +8,4 @@ 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,12 +10,4 @@ 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; }
}
+2 -6
View File
@@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Domain;
public enum PlaybackOrder
{
@@ -11,9 +11,5 @@ public enum PlaybackOrder
MultiEpisodeShuffle = 5,
SeasonEpisode = 6,
RandomRotation = 7,
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
Marathon = 8
}
-68
View File
@@ -1,68 +0,0 @@
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-&gt;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";
}
}
@@ -1,16 +0,0 @@
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 -&gt; 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 -&gt; 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 -9
View File
@@ -1,11 +1,3 @@
namespace ErsatzTV.Core.Health;
// 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);
}
public record HealthCheckLink(string Link);
@@ -1,10 +0,0 @@
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<PlaylistSegmentsResult> WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
Task WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Scheduling;
@@ -9,5 +9,4 @@ public record CollectionWithItems(
List<MediaItem> MediaItems,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder,
bool UseCustomOrder,
int Weight = 1);
bool UseCustomOrder);
@@ -1217,8 +1217,7 @@ public class SchedulingEngine(
return _state;
}
// instance rather than static so an unsupported order can be reported (#70)
private Option<IMediaCollectionEnumerator> EnumeratorForContent(
private static Option<IMediaCollectionEnumerator> EnumeratorForContent(
List<MediaItem> items,
CollectionEnumeratorState state,
PlaybackOrder playbackOrder,
@@ -1236,12 +1235,6 @@ 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;
}
@@ -171,11 +171,10 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
// TODO: fix multi episode shuffle?
case PlaybackOrder.MultiEpisodeShuffle:
case PlaybackOrder.Shuffle:
List<GroupedMediaItem> i = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
List<GroupedMediaItem> i = await PlayoutBuilder.GetGroupedMediaItemsForShuffle(
mediaCollectionRepository,
// playlist items don't (yet) carry these schedule flags; preserve prior behavior
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
// TODO: fix this
new ProgramSchedule { KeepMultiPartEpisodesTogether = false },
items,
CollectionKey.ForPlaylistItem(playlistItem),
cancellationToken);
@@ -183,7 +182,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
break;
case PlaybackOrder.ShuffleInOrder:
enumerator = new ShuffleInOrderCollectionEnumerator(
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
await PlayoutBuilder.GetCollectionItemsForShuffleInOrder(
mediaCollectionRepository,
CollectionKey.ForPlaylistItem(playlistItem),
cancellationToken),
+59 -24
View File
@@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
@@ -594,7 +594,7 @@ public class PlayoutBuilder : IPlayoutBuilder
var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList();
CollectionEnumeratorState scheduleItemsEnumeratorState =
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState
{ Seed = random.Next(), Index = 0 };
{ Seed = random.Next(), Index = 0 };
IScheduleItemsEnumerator scheduleItemsEnumerator = activeSchedule.ShuffleScheduleItems
? new ShuffledScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState)
: new OrderedScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState);
@@ -735,17 +735,17 @@ public class PlayoutBuilder : IPlayoutBuilder
switch (scheduleItem.FillWithGroupMode)
{
case FillWithGroupMode.FillWithOrderedGroups:
{
var enumerator = new OrderedScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
break;
}
{
var enumerator = new OrderedScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
break;
}
case FillWithGroupMode.FillWithShuffledGroups:
{
var enumerator = new ShuffledScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
break;
}
{
var enumerator = new ShuffledScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
break;
}
}
}
@@ -1376,21 +1376,13 @@ public class PlayoutBuilder : IPlayoutBuilder
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
case PlaybackOrder.ShuffleInOrder:
return new ShuffleInOrderCollectionEnumerator(
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
await GetCollectionItemsForShuffleInOrder(
_mediaCollectionRepository,
collectionKey,
cancellationToken),
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:
@@ -1428,10 +1420,9 @@ public class PlayoutBuilder : IPlayoutBuilder
case PlaybackOrder.MultiEpisodeShuffle:
case PlaybackOrder.Shuffle:
return new ShuffledMediaCollectionEnumerator(
await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
await GetGroupedMediaItemsForShuffle(
_mediaCollectionRepository,
activeSchedule.KeepMultiPartEpisodesTogether,
activeSchedule.TreatCollectionsAsShows,
activeSchedule,
mediaItems,
collectionKey,
cancellationToken),
@@ -1463,6 +1454,50 @@ public class PlayoutBuilder : IPlayoutBuilder
}
}
internal static async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
IMediaCollectionRepository mediaCollectionRepository,
ProgramSchedule activeSchedule,
List<MediaItem> mediaItems,
CollectionKey collectionKey,
CancellationToken cancellationToken)
{
if (collectionKey.MultiCollectionId != null)
{
List<CollectionWithItems> collections = await mediaCollectionRepository
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value, cancellationToken);
return MultiCollectionGrouper.GroupMediaItems(collections);
}
return activeSchedule.KeepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, activeSchedule.TreatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
}
internal static async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(
IMediaCollectionRepository mediaCollectionRepository,
CollectionKey collectionKey,
CancellationToken cancellationToken)
{
List<CollectionWithItems> result;
if (collectionKey.MultiCollectionId != null)
{
result = await mediaCollectionRepository.GetMultiCollectionCollections(
collectionKey.MultiCollectionId.Value,
cancellationToken);
}
else
{
result = await mediaCollectionRepository.GetFakeMultiCollectionCollections(
collectionKey.CollectionId,
collectionKey.SmartCollectionId,
cancellationToken);
}
return result;
}
internal static string DisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
@@ -1,66 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// Builds the shuffle "source" for a collection key — the grouped media items fed to
/// <see cref="ShuffledMediaCollectionEnumerator" /> and the per-collection lists fed to
/// <see cref="ShuffleInOrderCollectionEnumerator" />.
/// <para>
/// Extracted from <c>PlayoutBuilder</c> (issue #380) so both the Classic builder and
/// <see cref="PlaylistEnumerator" /> share ONE stateless place to construct shuffle sources,
/// eliminating <see cref="PlaylistEnumerator" />'s cross-engine reach-in into
/// <c>PlayoutBuilder</c>'s statics. Kept as a static helper (dependencies passed as parameters)
/// to match the sibling <see cref="MultiCollectionGrouper" /> / <see cref="MultiPartEpisodeGrouper" />
/// (also <c>public static</c>) and because <see cref="PlaylistEnumerator.Create" /> is itself a
/// static factory.
/// </para>
/// </summary>
public static class ShuffleSourceBuilder
{
public static async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
IMediaCollectionRepository mediaCollectionRepository,
bool keepMultiPartEpisodesTogether,
bool treatCollectionsAsShows,
List<MediaItem> mediaItems,
CollectionKey collectionKey,
CancellationToken cancellationToken)
{
if (collectionKey.MultiCollectionId != null)
{
List<CollectionWithItems> collections = await mediaCollectionRepository
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value, cancellationToken);
return MultiCollectionGrouper.GroupMediaItems(collections);
}
return keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
}
public static async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(
IMediaCollectionRepository mediaCollectionRepository,
CollectionKey collectionKey,
CancellationToken cancellationToken)
{
List<CollectionWithItems> result;
if (collectionKey.MultiCollectionId != null)
{
result = await mediaCollectionRepository.GetMultiCollectionCollections(
collectionKey.MultiCollectionId.Value,
cancellationToken);
}
else
{
result = await mediaCollectionRepository.GetFakeMultiCollectionCollections(
collectionKey.CollectionId,
collectionKey.SmartCollectionId,
cancellationToken);
}
return result;
}
}
@@ -1,260 +0,0 @@
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,8 +178,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
cancellationToken);
}
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
switch (parsedOrder)
switch (Enum.Parse<PlaybackOrder>(content.Order, true))
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
@@ -191,13 +190,6 @@ 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;
}
@@ -1,17 +0,0 @@
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 };
}
@@ -1,40 +0,0 @@
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");
}
}
}
@@ -1,56 +0,0 @@
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,9 +1780,6 @@ 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");
@@ -1792,8 +1789,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("MultiCollection", (string)null);
});
@@ -1811,11 +1806,6 @@ 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");
@@ -1837,11 +1827,6 @@ 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");
@@ -3530,9 +3515,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("varchar(50)")
.UseCollation("utf8mb4_general_ci");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("int");
b.Property<string>("Query")
.HasColumnType("longtext");
@@ -3541,8 +3523,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("SmartCollection", (string)null);
});

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