Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76f8a939c |
@@ -1,12 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# ersatztv#521 — the line-level append-only mechanic is retired. Decision integrity is now enforced by
|
||||
# the lifecycle validator. `[decisions-edit]` survives ONLY for rationale-prose edits (validator
|
||||
# body-diff, CI). This shim runs the structural validator over the working tree; the body-diff/no-
|
||||
# vanish checks run in CI where a base/head is available. Fail-open on any tooling trouble.
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 0
|
||||
command -v python3 >/dev/null 2>&1 || exit 0 # no python -> fail-open
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py
|
||||
rc=$?
|
||||
[ "$rc" -eq 1 ] && exit 1 # only a real validation failure blocks
|
||||
exit 0 # crashes/other codes -> fail-open
|
||||
# ersatztv#303 H9 — docs/decisions.md is append-only. This blocks a commit / PR that DELETES or
|
||||
# MODIFIES an existing line of that file; pure INSERTIONS anywhere are always allowed (adding a new
|
||||
# entry inserts a TOC line near the top AND appends a block at the bottom — both are insertions, so
|
||||
# numstat reports 0 deleted lines). A genuine factual fix to a past entry is the one legitimate edit:
|
||||
# put the literal token [decisions-edit] in the commit message to override.
|
||||
#
|
||||
# Fail-open: any tooling trouble (unknown mode, non-numeric numstat, missing refs) -> allow. The point
|
||||
# is to catch the accidental rewrite-history case, never to wedge a legitimate commit.
|
||||
#
|
||||
# Assumes decisions.md ends with a trailing newline (it does; .editorconfig enforces it). If that final
|
||||
# newline were ever dropped, git would render the next append as a modify of the last line (deleted=1)
|
||||
# and this would false-block the append until the author adds [decisions-edit] — cheap and self-correcting.
|
||||
#
|
||||
# Modes:
|
||||
# staged <msgfile> pre-commit/commit-msg — staged diff vs HEAD; trailer read from <msgfile>
|
||||
# range <base> <head> CI (PR) — merge-base diff base...head; trailer scanned across base..head msgs
|
||||
set -euo pipefail
|
||||
|
||||
FILE="docs/decisions.md"
|
||||
mode="${1:-}"
|
||||
|
||||
case "$mode" in
|
||||
staged)
|
||||
deleted=$(git diff --cached --numstat -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
|
||||
msg=$(cat "${2:-/dev/null}" 2>/dev/null || true)
|
||||
;;
|
||||
range)
|
||||
base="${2:-}"; head="${3:-}"
|
||||
[ -n "$base" ] && [ -n "$head" ] || exit 0 # missing refs -> fail-open
|
||||
deleted=$(git diff --numstat "$base...$head" -- "$FILE" 2>/dev/null | awk '{print $2}' | head -1)
|
||||
msg=$(git log --format='%B' "$base..$head" 2>/dev/null || true)
|
||||
;;
|
||||
*)
|
||||
exit 0 # unknown mode -> fail-open
|
||||
;;
|
||||
esac
|
||||
|
||||
# Empty (no change to the file) or '-' (binary) -> treat as 0 (fail-open / nothing to guard).
|
||||
deleted="${deleted:-0}"
|
||||
case "$deleted" in ''|*[!0-9]*) deleted=0 ;; esac
|
||||
[ "$deleted" -gt 0 ] || exit 0 # pure insertion / no change -> allow
|
||||
|
||||
# Explicit override for a documented factual fix.
|
||||
if printf '%s' "$msg" | grep -qiF '[decisions-edit]'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "decisions-guard (ersatztv#303 H9): docs/decisions.md is append-only — this change deletes/modifies ${deleted} existing line(s)."
|
||||
echo " Append new entries at the bottom (plus a TOC line in the Index); do not rewrite settled entries."
|
||||
echo " To fix a genuine factual error in a past entry, add the token [decisions-edit] to the commit message."
|
||||
} >&2
|
||||
exit 1
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,141 +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
|
||||
# Moved off `small` with docker-build.yml's `build` (server-management#639). Being
|
||||
# "docker-only" made it look lightweight, but it is a full buildx of the .NET
|
||||
# toolchain image — the heaviest thing that ran in that lane. `small` is now
|
||||
# git-only and capped at 1g per job, which would OOM this build.
|
||||
#
|
||||
# Rare trigger (pushes touching docker/ci + a weekly cron), so it costs the
|
||||
# ubuntu-latest lane almost nothing, and ci-runner (.127) runs no prod workload.
|
||||
runs-on: ubuntu-latest
|
||||
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}"
|
||||
@@ -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
|
||||
|
||||
+106
-385
@@ -12,38 +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:4263cf7
|
||||
#
|
||||
# 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".
|
||||
#
|
||||
# ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and
|
||||
# `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs
|
||||
# `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is
|
||||
# byte-identical to a PR head that already has a green Gitea combined status — i.e. the exact
|
||||
# source was already validated in the PR run. Every heavy step in those three jobs additionally
|
||||
# gates on `steps.revalidate.outputs.skip != 'true'`. `build` is untouched and always runs on
|
||||
# main, so the image is still built (from already-validated source) even when the skip fires.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -64,71 +32,28 @@ 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:4263cf7
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and,
|
||||
# here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit.
|
||||
fetch-depth: 2
|
||||
# only the test job's steps below need the working tree; git history/tags
|
||||
# are only needed by the `build` job's `git describe` (ersatztv#190)
|
||||
fetch-depth: 1
|
||||
|
||||
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
|
||||
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
|
||||
# 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: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -136,69 +61,46 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
|
||||
# the SPA's package downloads are project deps, so they stay cached per lockfile.
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
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' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
# Start the true peak-anon sampler just before the memory-heavy dotnet Build/Test/Coverage so
|
||||
# its high-water mark spans them (SPA build/test above are comparatively light). Paired with the
|
||||
# "Report peak container memory" step below. continue-on-error + a fail-open script => this
|
||||
# instrumentation never reddens a build. Why anon and not memory.peak: ersatztv#412 /
|
||||
# scripts/ci-peak-anon.sh header / docs/ci-cd.md "CI build memory".
|
||||
- name: Start peak-anon sampler (ersatztv#412)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: scripts/ci-peak-anon.sh start
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
@@ -209,7 +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' && steps.revalidate.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -220,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" \
|
||||
@@ -233,35 +136,9 @@ jobs:
|
||||
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Memory of THIS job container, reported every run (ersatztv#406/#412, server-management#604).
|
||||
# #604 sizes the runners' per-job caps on these numbers. The headline is the TRUE PEAK ANON
|
||||
# sampled by the "Start peak-anon sampler" step above — NOT `memory.peak`, which is the
|
||||
# high-water mark of memory.current and charges reclaimable page cache to the cgroup (a build
|
||||
# job does heavy NuGet/npm/obj/bin/coverage I/O, so cache can dominate the peak). Page cache is
|
||||
# reclaimed under a tighter cap, not OOM-killed, so sizing a cap off `memory.peak` inverts the
|
||||
# decision. peak anon is the OOM-forcing number. Full rationale + the bumblebee demo:
|
||||
# scripts/ci-peak-anon.sh header and docs/ci-cd.md "CI build memory".
|
||||
#
|
||||
# Runs LAST on purpose (after Coverage summary / reportgenerator, the job's last real workload)
|
||||
# and stops the sampler. `always()` so a failed Build/Test still gets a peak reading; the split
|
||||
# is read here (end-of-job = composition then, not at the peak instant — that is exactly why the
|
||||
# sampler exists). Skipped on docs-only/already-validated runs (nothing ran to measure).
|
||||
- name: Report peak container memory
|
||||
# `always()` controls whether this step RUNS, not whether its failure fails the job. With
|
||||
# `defaults.run.shell: bash` (`-e -o pipefail`) a stray non-zero here would redden a green
|
||||
# test job, so `continue-on-error` makes it advisory — the same guarantee Coverage summary uses.
|
||||
if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }}
|
||||
continue-on-error: true
|
||||
run: scripts/ci-peak-anon.sh report
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
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)
|
||||
@@ -274,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
|
||||
@@ -317,24 +159,15 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate
|
||||
# step's `HEAD^2` tree comparison can resolve on a main merge commit.
|
||||
fetch-depth: 2
|
||||
# default fetch-depth: 1 -- this job never runs git describe/log, only
|
||||
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
|
||||
|
||||
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
|
||||
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -342,21 +175,19 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != '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' && steps.revalidate.outputs.skip != '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
|
||||
@@ -370,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' && steps.revalidate.outputs.skip != '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
|
||||
@@ -380,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
|
||||
@@ -408,39 +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:4263cf7
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree
|
||||
# comparison can resolve on a main merge commit.
|
||||
fetch-depth: 2
|
||||
fetch-depth: 1
|
||||
|
||||
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -448,40 +265,30 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != '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' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != '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' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
@@ -496,20 +303,12 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# Moved back off `small` (server-management#639). This is the one HEAVY job that
|
||||
# was still in that lane, and its 10g requirement was what pinned the lane's
|
||||
# per-job cap at 10g — which in turn capped the lane at ONE slot on a 25 GiB
|
||||
# host. Four jobs sharing one slot is what starved the git-only checks in act's
|
||||
# setup phase (>10 min, no logs, then fail). With this job gone, `small` is
|
||||
# git-only and can run wide and tiny on two hosts.
|
||||
#
|
||||
# The `ubuntu-latest` queueing that sent it to `small` in the first place
|
||||
# (server-management#574: a PR-run skip stuck 31 min behind long builds) does not
|
||||
# come back, because `needs: [test, migrations]` means this job cannot be
|
||||
# dispatched until those two have already finished — by which point the lane it
|
||||
# was queueing behind has drained. Real builds (main/tags) get the full
|
||||
# ubuntu-latest allotment: 4 CPUs / 10g on ci-runner (.127).
|
||||
runs-on: ubuntu-latest
|
||||
# `small` = the dedicated small-jobs runner lane (server-management#574).
|
||||
# On PR runs this job only resolves its skip, but Gitea still dispatches it
|
||||
# as a task — on the ubuntu-latest runners that skip queued behind long
|
||||
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
|
||||
# capped at 4 CPUs / 10g.
|
||||
runs-on: small
|
||||
needs: [test, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
@@ -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
|
||||
@@ -726,14 +461,12 @@ jobs:
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
||||
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
||||
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
|
||||
# vanishing from the active set without an archive copy) and that the generated active catalog
|
||||
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
|
||||
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
|
||||
# BLOCKING (ersatztv#303 H9): docs/decisions.md is an append-only log. Fails a PR that deletes or
|
||||
# rewrites a settled entry (numstat reports >0 deleted lines) unless a commit in the range carries
|
||||
# the [decisions-edit] override token for a documented factual fix. Same script the Husky commit-msg
|
||||
# hook calls, so local and CI enforcement can't drift. Seconds-long git diff -> keep it off the build runners.
|
||||
decisions-guard:
|
||||
name: decisions lifecycle
|
||||
name: decisions.md append-only
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
@@ -741,19 +474,22 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Validate decision lifecycle
|
||||
- name: Enforce append-only
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
||||
- name: Active catalog in sync
|
||||
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
||||
- name: Kickoff guard
|
||||
run: bash scripts/check-kickoff-guard.sh
|
||||
./.claude/hooks/decisions-guard.sh range "origin/${base_ref}" HEAD
|
||||
- name: Consolidation-floor reminder (non-blocking)
|
||||
run: |
|
||||
# Consolidation is primarily a release step; this is the between-releases floor. The metric is
|
||||
# the file's LINE COUNT — the context an agent actually burns reading the log — not entry count.
|
||||
# Floor 1800 keeps the whole log inside one default 2000-line Read (headroom for the reader's
|
||||
# own overhead). Nudge (never fail) past it so append-only can't grow past what agents can read.
|
||||
n=$(wc -l < docs/decisions.md | tr -d ' ')
|
||||
echo "docs/decisions.md is ${n} lines (consolidation floor: 1800; one Read caps at 2000)."
|
||||
if [ "${n:-0}" -gt 1800 ]; then
|
||||
echo "::warning::docs/decisions.md is ${n} lines (>1800) — larger than agents can comfortably read in one pass. Do a consolidation pass (prune/merge superseded entries with [decisions-edit]); don't wait for the next release. See the decisions.md header."
|
||||
fi
|
||||
|
||||
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
|
||||
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
|
||||
@@ -767,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:4263cf7
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -810,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
|
||||
@@ -822,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'
|
||||
@@ -857,32 +579,13 @@ jobs:
|
||||
echo "Generated API artifacts are in sync."
|
||||
|
||||
# Formatting-as-you-touch gate (ersatztv#311): verify the .cs files THIS PR changed conform to
|
||||
# .editorconfig whitespace + charset=utf-8 (i.e. no UTF-8 BOM). Scoped to changed files so it
|
||||
# enforces "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500
|
||||
# pre-existing BOM files. A PR that touches no .cs skips the check and passes trivially (always
|
||||
# reports a status, so it is safe as a required check).
|
||||
#
|
||||
# ersatztv#469: uses `dotnet format whitespace . --folder`, NOT the full `dotnet format <sln>`.
|
||||
# `--folder` treats the tree as a plain folder of files and skips the MSBuild/Roslyn workspace load
|
||||
# + per-project compilation that dominated the old recipe (~8 min locally on a whole-solution run) —
|
||||
# `--include` only ever narrowed *which* files were checked, never what got loaded. Folder mode
|
||||
# reads .editorconfig and still flags WHITESPACE (indent/EOL/trailing/final-newline) and CHARSET
|
||||
# (BOM) violations — exactly what this gate exists to catch — in ~0.5s with no `dotnet restore`.
|
||||
# What it drops is the style/analyzer pass (naming/`var`/qualification), which this gate never
|
||||
# meaningfully enforced: those .editorconfig rules are :suggestion/:none severity. Full rationale +
|
||||
# non-vacuity evidence: docs/ci-cd.md → Formatting; docs/decisions.md.
|
||||
# .editorconfig (style + charset=utf-8, i.e. no UTF-8 BOM). Scoped to changed files so it enforces
|
||||
# "normalize a legacy file when you touch it" WITHOUT a big-bang reformat of the ~2500 pre-existing
|
||||
# BOM files. A PR that touches no .cs skips the expensive steps and passes trivially (always reports
|
||||
# a status, so it is safe as a required check).
|
||||
format:
|
||||
name: Formatting (changed .cs conform to .editorconfig)
|
||||
# Folder-mode whitespace is now a seconds-long, low-memory job (no Roslyn workspace, unlike the
|
||||
# 3.95 GiB full `dotnet format` measured in #406), so it no longer needs the memory headroom that
|
||||
# kept it on `ubuntu-latest`. Left here to avoid re-touching the lane/memory-cap accounting; a
|
||||
# move to a lighter lane is a server-management capacity call (#604).
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -906,14 +609,32 @@ 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
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.cs_changed == 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Verify formatting of changed .cs files
|
||||
if: steps.detect.outputs.cs_changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
mapfile -t files < /tmp/changed-cs.txt
|
||||
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig (whitespace + charset)..."
|
||||
if ! dotnet format whitespace . --folder --verify-no-changes --include "${files[@]}"; then
|
||||
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (whitespace or a UTF-8 BOM). Run 'dotnet format whitespace . --folder --include <files>' (or the full 'dotnet format ErsatzTV.sln --include <files>') and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
|
||||
echo "Verifying ${#files[@]} changed .cs file(s) against .editorconfig..."
|
||||
if ! dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"; then
|
||||
echo "::error::One or more .cs files this PR touches don't conform to .editorconfig (formatting or a UTF-8 BOM). Run 'dotnet format ErsatzTV.sln --include <files>' and commit the result in THIS PR — the fix-as-you-touch convention (docs/contributing.md §7; ersatztv#311). Legacy files you did NOT touch are unaffected."
|
||||
exit 1
|
||||
fi
|
||||
echo "All changed .cs files conform to .editorconfig."
|
||||
|
||||
@@ -8,3 +8,8 @@ grep -q '^Co-Authored-By:' "$1" || {
|
||||
echo 'husky - commit message missing Co-Authored-By trailer'
|
||||
exit 1
|
||||
}
|
||||
|
||||
# H9 (ersatztv#303) — docs/decisions.md is append-only. Block a commit that rewrites a settled
|
||||
# entry unless the message carries [decisions-edit]. commit-msg runs after the index is final, so
|
||||
# the staged diff is what's being committed; the message file ($1) supplies the override token.
|
||||
./.claude/hooks/decisions-guard.sh staged "$1" || exit 1
|
||||
|
||||
+6
-14
@@ -1,12 +1,6 @@
|
||||
cd web && npx lint-staged || exit 1
|
||||
cd ..
|
||||
|
||||
# ersatztv#521 — decision-record lifecycle structural validator (replaces the old H9 append-only
|
||||
# line guard). Runs the same validator the CI `decisions lifecycle` job uses, over the working
|
||||
# tree (no base/head here, so only structural checks run; the body-diff/no-vanish checks run in
|
||||
# CI where a base ref exists). Fail-open shim — see .claude/hooks/decisions-guard.sh.
|
||||
./.claude/hooks/decisions-guard.sh || exit 1
|
||||
|
||||
# H3 (ersatztv#303) — never commit a screenshot dropped at the repo root. Belt-and-suspenders with
|
||||
# .gitignore (catches a forced `git add -f`). Root-level *.png only; nested paths are legit assets.
|
||||
root_png=$(git diff --cached --name-only --diff-filter=ACM | grep -iE '^[^/]+\.png$' || true)
|
||||
@@ -17,17 +11,15 @@ if [ -n "$root_png" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# dotnet format on staged .cs files (repo root). Uses `whitespace . --folder` — same recipe as
|
||||
# the CI `format` job (ersatztv#469): folder mode checks .editorconfig whitespace + charset (BOM)
|
||||
# without the MSBuild/Roslyn workspace load, so it runs in ~0.5s instead of the old ~20-40s sln
|
||||
# load. Keeping this identical to CI avoids a local hook that blocks on rules CI no longer enforces.
|
||||
# Skip entirely when no .cs is staged (avoids any cost for web-only commits).
|
||||
# dotnet format on staged .cs files (repo root). Scoped to the staged files so we
|
||||
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
|
||||
# ~20-40s sln load for web-only commits).
|
||||
cs_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.cs')
|
||||
if [ -n "$cs_files" ]; then
|
||||
echo "husky - dotnet format (whitespace verify) on staged .cs files"
|
||||
echo "husky - dotnet format (verify) on staged .cs files"
|
||||
# shellcheck disable=SC2086
|
||||
dotnet format whitespace . --folder --verify-no-changes --include $cs_files || {
|
||||
echo "husky - dotnet format found whitespace/BOM issues in staged .cs files; run 'dotnet format whitespace . --folder --include <files>' to fix"
|
||||
dotnet format ErsatzTV.sln --verify-no-changes --include $cs_files || {
|
||||
echo "husky - dotnet format found issues in staged .cs files; run 'dotnet format ErsatzTV.sln --include <files>' to fix"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,10 +35,10 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: **jazz (192.168.1.29)**, container `ersatztv`, port 8409. Media transcoders (Jellyfin, `ersatztv`, `ersatztv-test`) moved here from bumblebee on 2026-07-20 (server-management#633); bumblebee (192.168.1.99) still hosts the **CI runners** and the rest of the stacks. **Name-reuse trap**: `jazz` was an *earlier* name for the .99 host, so pre-2026-07-20 docs/commits saying "jazz" mean today's **bumblebee** — go by the IP, not the name.
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **Docker host**: bumblebee (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on bumblebee → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** stack — named **`jazz-media`** (the compose *project* is still `media-servers`; a dead `media-servers` stack lingers on bumblebee) — follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually `DeployStack jazz-media`. There is **no** auto-update fallback (`auto_update: false`) — promotion is manual. Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod's **Komodo GitOps** `media-servers` stack follows floating `:prod`; after the immutable `:<version>` candidate passes the release scans, manually deploy the stack (Global Auto Update is the daily fallback). Both paths run the fail-closed pre-deploy backup and prod-copy migration smoke before recreation. Test tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -56,7 +56,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
## Conventions
|
||||
|
||||
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
|
||||
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read the `docs/README.md` **task-signal map** and only the sections it points to for your task — not the whole corpus. **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code. **Decision/convention lookups start at the active catalog**, `docs/decisions/README.md` — resolve by topic/key, never by chasing a file path named in a historical comment (the breadcrumb rule; see `docs/README.md` → "Knowledge retrieval").
|
||||
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
|
||||
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
|
||||
|
||||
| Change | Update in the same PR |
|
||||
@@ -64,7 +64,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
|
||||
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
|
||||
| Change a SPA screen convention | `docs/spa-conventions.md` |
|
||||
| Establish / reverse a convention or decision | `docs/decisions.md` (lifecycle: add record, relocate predecessor to archive/) + the affected doc |
|
||||
| Establish / reverse a convention or decision | `docs/decisions.md` (append-only) + the affected doc |
|
||||
| Add / remove / retitle a doc | `docs/README.md` index |
|
||||
|
||||
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
|
||||
@@ -91,24 +91,11 @@ Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pa
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
|
||||
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
**`## Closing record` template** (step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval contract this feeds):
|
||||
|
||||
```markdown
|
||||
## Closing record
|
||||
**Outcome:** <what shipped / what didn't; PR link>
|
||||
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
|
||||
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
|
||||
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
|
||||
**Verification:** <tests run, live-E2E, CI status>
|
||||
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
|
||||
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
|
||||
```
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
|
||||
|
||||
@@ -9,13 +9,8 @@ namespace ErsatzTV.Application.Artworks;
|
||||
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
|
||||
{
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly IRemoteImageValidator _validator;
|
||||
|
||||
public UploadArtworkHandler(IImageCache imageCache, IRemoteImageValidator validator)
|
||||
{
|
||||
_imageCache = imageCache;
|
||||
_validator = validator;
|
||||
}
|
||||
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
|
||||
|
||||
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
|
||||
UploadArtwork request,
|
||||
@@ -43,22 +38,6 @@ public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseEr
|
||||
|
||||
string contentType = maybeContentType.IfNone(string.Empty);
|
||||
|
||||
// One rule: anything entering the logo cache is decode-budget-checked. A supported format is
|
||||
// not enough — a small header can declare a multi-gigabyte canvas (a decompression bomb), so
|
||||
// reject it here before it lands in the cache. The synthetic upload:// Uri is only for the
|
||||
// exception message text. (ersatztv#525)
|
||||
using (var probe = new MemoryStream(bytes, writable: false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _validator.Validate(probe, new Uri("upload://artwork"), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New($"Image cannot be used: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
using var toCache = new MemoryStream(bytes, writable: false);
|
||||
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
|
||||
toCache,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -8,7 +8,6 @@ using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -22,7 +21,6 @@ public class CreateChannelFromLineupHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
IRemoteLogoCacher remoteLogoCacher,
|
||||
ILogger<CreateChannelFromLineupHandler> logger)
|
||||
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
|
||||
{
|
||||
@@ -39,42 +37,7 @@ public class CreateChannelFromLineupHandler(
|
||||
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Match(
|
||||
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
|
||||
Right: async prepared =>
|
||||
{
|
||||
Either<BaseError, PreparedCreate> resolved =
|
||||
await ResolveExternalLogo(request, prepared, cancellationToken);
|
||||
return await resolved.Match(
|
||||
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
|
||||
Right: p => PersistAndDispatch(dbContext, p, cancellationToken));
|
||||
});
|
||||
}
|
||||
|
||||
// The lineup logo artwork is built (in BuildChannel) with the raw request path. When that path is
|
||||
// an external http(s) URL, download + cache it and swap the cache name onto the logo artwork before
|
||||
// persisting (a cacher Left fails the whole create); a blank or already-local/cached path is left
|
||||
// unchanged. (ersatztv#525)
|
||||
private async Task<Either<BaseError, PreparedCreate>> ResolveExternalLogo(
|
||||
CreateChannelFromLineup request,
|
||||
PreparedCreate prepared,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = request.Logo?.Path ?? string.Empty;
|
||||
|
||||
if (!Artwork.IsExternalUrl(path))
|
||||
{
|
||||
return prepared;
|
||||
}
|
||||
|
||||
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
|
||||
return cached.Map(name =>
|
||||
{
|
||||
foreach (Artwork logo in prepared.Channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
{
|
||||
logo.Path = name;
|
||||
}
|
||||
|
||||
return prepared;
|
||||
});
|
||||
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
|
||||
@@ -234,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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -17,8 +16,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class CreateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
IRemoteLogoCacher remoteLogoCacher)
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
|
||||
{
|
||||
public async Task<Either<BaseError, CreateChannelResult>> Handle(
|
||||
@@ -27,52 +25,7 @@ public class CreateChannelHandler(
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Match(
|
||||
Succ: async channel =>
|
||||
{
|
||||
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
|
||||
return await resolvedLogo.Match(
|
||||
Right: async logoPath =>
|
||||
{
|
||||
ApplyResolvedLogo(request, channel, logoPath);
|
||||
return Right<BaseError, CreateChannelResult>(await PersistChannel(dbContext, channel));
|
||||
},
|
||||
Left: e => Task.FromResult(Left<BaseError, CreateChannelResult>(e)));
|
||||
},
|
||||
Fail: errors => Task.FromResult(Left<BaseError, CreateChannelResult>(errors.Join())));
|
||||
}
|
||||
|
||||
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
|
||||
// downloaded and cached (a cacher Left fails the whole save); an empty path or an
|
||||
// already-local/cached path passes through unchanged. (ersatztv#525)
|
||||
private async Task<Either<BaseError, string>> ResolveLogoPath(
|
||||
CreateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = request.Logo?.Path ?? string.Empty;
|
||||
|
||||
if (!Artwork.IsExternalUrl(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// When the incoming logo was an external URL, swap the downloaded cache name onto the logo
|
||||
// artwork built during validation so no URL is ever persisted in Artwork.Path. (ersatztv#525)
|
||||
private static void ApplyResolvedLogo(CreateChannel request, Channel channel, string resolvedLogoPath)
|
||||
{
|
||||
if (!Artwork.IsExternalUrl(request.Logo?.Path ?? string.Empty))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Artwork logo in channel.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
{
|
||||
logo.Path = resolvedLogoPath;
|
||||
}
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
|
||||
@@ -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;
|
||||
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -20,8 +19,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class UpdateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
IRemoteLogoCacher remoteLogoCacher)
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelViewModel>> Handle(
|
||||
@@ -41,47 +39,29 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Validation<BaseError, Channel> validation =
|
||||
await Validate(dbContext, request, channel, cancellationToken);
|
||||
return await validation.Match(
|
||||
Succ: async c =>
|
||||
{
|
||||
Either<BaseError, string> resolvedLogo = await ResolveLogoPath(request, cancellationToken);
|
||||
return await resolvedLogo.Match(
|
||||
Right: async logoPath => Right<BaseError, ChannelViewModel>(
|
||||
await ApplyUpdateRequest(dbContext, c, request, logoPath, cancellationToken)),
|
||||
Left: e => Task.FromResult(Left<BaseError, ChannelViewModel>(e)));
|
||||
},
|
||||
Fail: errors => Task.FromResult(Left<BaseError, ChannelViewModel>(errors.Join())));
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, ChannelViewModel>(
|
||||
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
|
||||
}
|
||||
|
||||
// Resolve the incoming logo path into a value safe to persist. An external http(s) URL is
|
||||
// downloaded and cached (a cacher Left fails the whole save); an empty path (logo removal) or an
|
||||
// already-local/cached path passes through unchanged. (ersatztv#525)
|
||||
private async Task<Either<BaseError, string>> ResolveLogoPath(
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string path = request.Logo?.Path ?? string.Empty;
|
||||
|
||||
if (!Artwork.IsExternalUrl(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
Either<BaseError, string> cached = await remoteLogoCacher.CacheFromUrl(new Uri(path), cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Channel c,
|
||||
UpdateChannel update,
|
||||
string resolvedLogoPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// don't save mirror when playout exists
|
||||
if (c.Playouts.Count > 0)
|
||||
{
|
||||
update = update with
|
||||
{
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
MirrorSourceChannelId = null
|
||||
};
|
||||
}
|
||||
|
||||
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
|
||||
|
||||
c.Name = update.Name;
|
||||
@@ -106,9 +86,9 @@ public class UpdateChannelHandler(
|
||||
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
|
||||
c.Artwork ??= [];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedLogoPath))
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
|
||||
{
|
||||
string logo = resolvedLogoPath;
|
||||
string logo = update.Logo.Path;
|
||||
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
|
||||
{
|
||||
logo = logo.Replace("iptv/logos/", string.Empty);
|
||||
@@ -160,8 +140,6 @@ public class UpdateChannelHandler(
|
||||
c.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
|
||||
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -169,6 +147,8 @@ public class UpdateChannelHandler(
|
||||
c.PlayoutOffset = null;
|
||||
}
|
||||
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
@@ -196,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);
|
||||
}
|
||||
|
||||
@@ -214,7 +187,7 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
@@ -289,7 +262,6 @@ public class UpdateChannelHandler(
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
|
||||
@@ -297,18 +269,6 @@ public class UpdateChannelHandler(
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// a channel with its own playout already built (Generated mode) cannot become a Mirror —
|
||||
// Mirror channels relay another channel's playout and never build one of their own, so
|
||||
// switching this transition on would strand the existing playout. This used to be
|
||||
// silently coerced back to Generated (issue #401); reject the transition instead so the
|
||||
// caller sees why the requested Mirror source was not applied. A round-trip that keeps
|
||||
// PlayoutSource as Generated never reaches this check.
|
||||
if (channel.Playouts.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
"Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first.");
|
||||
}
|
||||
|
||||
Option<Channel> maybeMirrorSource = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,9 +85,7 @@ internal static class Mapper
|
||||
channel.PreferredAudioLanguageCode,
|
||||
GetStreamingMode(channel),
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg,
|
||||
playoutCount,
|
||||
GetLogoUrl(channel));
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
@@ -124,31 +99,6 @@ internal static class Mapper
|
||||
channel.FFmpegProfile.VideoProfile,
|
||||
channel.FFmpegProfile.AudioFormat);
|
||||
|
||||
// Rooted, directly-usable channel-logo URL for the SPA's <img src> on browse surfaces (guide grid +
|
||||
// channels list), following the #181 artwork convention (docs/api-conventions.md §4): the SPA does no
|
||||
// client-side path building. External logo URLs pass through as-is; an uploaded logo ("iptv/logos/{file}")
|
||||
// is rooted with a leading slash so it resolves against the site root regardless of the current SPA route.
|
||||
// Returns null when the channel has no logo, so the SPA falls back to the generated initials "bug".
|
||||
#nullable enable
|
||||
internal static string? GetLogoUrl(Channel channel)
|
||||
{
|
||||
// Browse surfaces must not crash the whole list over a missing Artwork include; GetLogo assumes
|
||||
// the caller included Channel.Artwork (GetAll + the guide query do), but stay defensive here.
|
||||
if (channel.Artwork is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ArtworkContentTypeModel logo = GetLogo(channel);
|
||||
if (string.IsNullOrWhiteSpace(logo.Path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return logo.IsExternalUrl || logo.Path.StartsWith('/') ? logo.Path : $"/{logo.Path}";
|
||||
}
|
||||
#nullable restore
|
||||
|
||||
private static ArtworkContentTypeModel GetLogo(Channel channel)
|
||||
{
|
||||
Option<Artwork> maybeArtwork = channel.Artwork
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ public class GetChannelGuideDataHandler(
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.ShowInEpg)
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -122,7 +121,6 @@ public class GetChannelGuideDataHandler(
|
||||
new ChannelGuideChannelResponseModel(
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
Mapper.GetLogoUrl(channel),
|
||||
programmes.OrderBy(p => p.Start).ToList()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 += $"&access_token={SecurityElement.Escape(request.AccessToken)}";
|
||||
accessTokenUri += $"&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,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -34,5 +34,4 @@ public record CreateFFmpegProfile(
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -68,11 +67,7 @@ public class CreateFFmpegProfileHandler :
|
||||
HardwareAcceleration = hwAccel,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
// store what the pipeline will actually use, never a pool size FFmpegState would
|
||||
// floor away at render time (ersatztv#529)
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames is { } frames
|
||||
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
|
||||
: null,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
|
||||
@@ -110,8 +105,7 @@ public class CreateFFmpegProfileHandler :
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
NormalizeColors = request.NormalizeColors,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo,
|
||||
QsvPreferNativeDecoder = request.QsvPreferNativeDecoder
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -35,5 +35,4 @@ public record UpdateFFmpegProfile(
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
|
||||
@@ -3,7 +3,6 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -55,11 +54,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
// store what the pipeline will actually use, so a profile doesn't keep displaying a pool
|
||||
// size that FFmpegState floors away at render time (ersatztv#529)
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames is { } frames
|
||||
? Math.Max(frames, FFmpegState.MinimumQsvExtraHardwareFrames)
|
||||
: null;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.PadMode = update.PadMode;
|
||||
@@ -107,7 +102,6 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.NormalizeColors = update.NormalizeColors;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
p.QsvPreferNativeDecoder = update.QsvPreferNativeDecoder;
|
||||
|
||||
// don't save invalid preset
|
||||
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Resolutions;
|
||||
using ErsatzTV.Application.Resolutions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -35,5 +35,4 @@ public record FFmpegProfileViewModel(
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo,
|
||||
bool QsvPreferNativeDecoder);
|
||||
bool DeinterlaceVideo);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
@@ -37,8 +37,7 @@ internal static class Mapper
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeFramerate,
|
||||
profile.NormalizeColors,
|
||||
profile.DeinterlaceVideo == true,
|
||||
profile.QsvPreferNativeDecoder != false);
|
||||
profile.DeinterlaceVideo == true);
|
||||
|
||||
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
new(
|
||||
@@ -81,6 +80,5 @@ internal static class Mapper
|
||||
ffmpegProfile.AudioSampleRate,
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
ffmpegProfile.NormalizeColors,
|
||||
ffmpegProfile.DeinterlaceVideo == true,
|
||||
ffmpegProfile.QsvPreferNativeDecoder != false);
|
||||
ffmpegProfile.DeinterlaceVideo == true);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ using ErsatzTV.Core.Api.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
public record GetAllHealthCheckResultsForApi(bool Refresh = false) : IRequest<List<HealthCheckResponseModel>>;
|
||||
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
|
||||
|
||||
@@ -18,8 +18,7 @@ public class GetAllHealthCheckResultsForApiHandler
|
||||
{
|
||||
try
|
||||
{
|
||||
List<HealthCheckResult> results =
|
||||
await _healthCheckService.PerformHealthChecks(request.Refresh, cancellationToken);
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results
|
||||
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
|
||||
.Map(ProjectToResponseModel)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Health;
|
||||
using ErsatzTV.Core.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
|
||||
{
|
||||
try
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(false, cancellationToken);
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
@@ -70,23 +70,9 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CreateLocalLibrary request) =>
|
||||
MediaSourceMustExist(dbContext, request)
|
||||
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
|
||||
.BindT(MediaKindMustBeSupportedLocally)
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
|
||||
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
|
||||
|
||||
/// <summary>
|
||||
/// Mixed is only ever produced for remote (Jellyfin) libraries, where the media server classifies
|
||||
/// each item for us. No local folder scanner handles it, so a local Mixed library would fail every
|
||||
/// scan forever. The API takes a raw LibraryMediaKind, so this must be enforced here rather than
|
||||
/// left to the SPA's media-kind options.
|
||||
/// </summary>
|
||||
private static Validation<BaseError, LocalLibrary> MediaKindMustBeSupportedLocally(
|
||||
LocalLibrary localLibrary) =>
|
||||
localLibrary.MediaKind is LibraryMediaKind.Mixed
|
||||
? BaseError.New(
|
||||
"Local libraries cannot use the Mixed media kind; it is only valid for Jellyfin libraries.")
|
||||
: localLibrary;
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
|
||||
TvContext dbContext,
|
||||
CreateLocalLibrary request) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -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
-2
@@ -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))
|
||||
{
|
||||
|
||||
@@ -10,16 +10,6 @@ public class GetAllMediaSourcesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
|
||||
{
|
||||
// A never-scanned library has a null LastScan at runtime, but historical DB rows still carry the
|
||||
// 0001-01-01 MinValue sentinel written by the old Reset_* migrations. Coerce any such residual
|
||||
// sentinel to null so the API/MCP surface reports "never scanned" as null (parity with the UI),
|
||||
// regardless of DB history or provider. Belt-and-suspenders alongside the NullOutNeverScannedLastScan
|
||||
// data migration.
|
||||
private static readonly DateTime NeverScannedThreshold = new(2000, 1, 1);
|
||||
|
||||
private static DateTime? NormalizeLastScan(DateTime? lastScan) =>
|
||||
lastScan is { } value && value < NeverScannedThreshold ? null : lastScan;
|
||||
|
||||
public async Task<List<MediaSourceResponseModel>> Handle(
|
||||
GetAllMediaSourcesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -46,7 +36,7 @@ public class GetAllMediaSourcesForApiHandler(
|
||||
l.Id,
|
||||
l.Name,
|
||||
l.MediaKind,
|
||||
NormalizeLastScan(l.LastScan),
|
||||
l.LastScan,
|
||||
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -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,48 +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);
|
||||
// #472 sub-splits the startup work (81% of total, all of the variance) into the ErsatzTV-side
|
||||
// prep before FFmpeg is launched, FFmpeg's own init (input open+probe and decoder/encoder
|
||||
// init), and the wait for the playlist once FFmpeg is reporting progress. splitKind says how
|
||||
// much of that was actually observable for this sample. NOTE these buckets span the worker's
|
||||
// Run entry rather than the startup stopwatch, so they do NOT sum to startupMs — prep overlaps
|
||||
// the tail of setup. The log says "spans runEntry" so a reader can't miss it.
|
||||
// See ColdStartStartupSplit for the full set of caveats.
|
||||
ColdStartStartupSplit split = segments.StartupSplit;
|
||||
_logger.LogInformation(
|
||||
"HLS cold-start channel {Channel} mode {Mode}: total {TotalMs}ms " +
|
||||
"(setup {SetupMs}ms + startup {ProcessStartupMs}ms + fill {SegmentFillMs}ms), " +
|
||||
"startup split {SplitKind} spans runEntry (prep {PrepMs}ms + ffmpegInit {FFmpegInitMs}ms " +
|
||||
"+ firstGop {FirstGopMs}ms), " +
|
||||
"segments {SegmentsReached}/{InitialSegmentCount}, " +
|
||||
"deadlineExpired {DeadlineExpired}, subtitleBurnIn {SubtitleBurnIn}, hwaccel {HwAccel}",
|
||||
request.ChannelNumber,
|
||||
request.Mode,
|
||||
totalMs,
|
||||
setupMs,
|
||||
startupMs,
|
||||
fillMs,
|
||||
split.Kind,
|
||||
(long)split.Prep.TotalMilliseconds,
|
||||
(long)split.FFmpegInit.TotalMilliseconds,
|
||||
(long)split.FirstGop.TotalMilliseconds,
|
||||
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,20 +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;
|
||||
|
||||
// Stopwatch timestamps of the cold-start milestones used to sub-split the "startup" phase (#472).
|
||||
// Each is written once on the sequential Run loop and read on the handler thread from
|
||||
// WaitForPlaylistSegments; long fields cannot be volatile, so access goes through Volatile/
|
||||
// Interlocked. Zero means "never reached", which ColdStartStartupSplit degrades gracefully on.
|
||||
private long _coldStartRunTicks;
|
||||
private long _coldStartProcessLaunchedTicks;
|
||||
private long _coldStartFirstProgressTicks;
|
||||
|
||||
public HlsSessionWorker(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
@@ -195,10 +181,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken);
|
||||
|
||||
// anchor for the cold-start startup sub-split (#472); this runs before any later milestone,
|
||||
// so every sub-phase derived from it is non-negative by construction
|
||||
Volatile.Write(ref _coldStartRunTicks, Stopwatch.GetTimestamp());
|
||||
|
||||
try
|
||||
{
|
||||
_channelNumber = channelNumber;
|
||||
@@ -318,21 +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 startupSplit = ColdStartStartupSplit.Unavailable;
|
||||
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))
|
||||
{
|
||||
@@ -340,20 +318,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
|
||||
_logger.LogDebug("Playlist exists");
|
||||
processStartup = sw.Elapsed;
|
||||
|
||||
// #472: sub-split the phase that #350 measured as 81% of cold-start and all of its variance
|
||||
startupSplit = ColdStartStartupSplit.FromTimestamps(
|
||||
Volatile.Read(ref _coldStartRunTicks),
|
||||
Volatile.Read(ref _coldStartProcessLaunchedTicks),
|
||||
Volatile.Read(ref _coldStartFirstProgressTicks),
|
||||
Stopwatch.GetTimestamp());
|
||||
|
||||
// start the segment-wait deadline only after the playlist file appears,
|
||||
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
|
||||
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
|
||||
|
||||
// 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)
|
||||
{
|
||||
@@ -375,15 +345,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
segmentCount = result.SegmentCount;
|
||||
}
|
||||
}
|
||||
|
||||
return new PlaylistSegmentsResult(
|
||||
processStartup,
|
||||
sw.Elapsed - processStartup,
|
||||
segmentCount,
|
||||
initialSegmentCount,
|
||||
segmentCount < initialSegmentCount,
|
||||
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments),
|
||||
startupSplit);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -572,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
|
||||
@@ -597,30 +555,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
var progressParser = new FFmpegProgress();
|
||||
|
||||
// #472: the first -progress line is the only cold-start milestone FFmpeg gives us
|
||||
// for free (the pipeline runs -loglevel error -nostats, so stderr stays silent on a
|
||||
// healthy run). It means the input is open and probed and the decoder/encoder are
|
||||
// initialized. Record-once, so only the session's first process is measured.
|
||||
void ParseProgressLine(string line)
|
||||
{
|
||||
// the read short-circuits the timestamp call for every line after the first,
|
||||
// which is every line for the life of the session
|
||||
if (Volatile.Read(ref _coldStartFirstProgressTicks) == 0)
|
||||
{
|
||||
Interlocked.CompareExchange(ref _coldStartFirstProgressTicks, Stopwatch.GetTimestamp(), 0);
|
||||
}
|
||||
|
||||
progressParser.ParseLine(line);
|
||||
}
|
||||
|
||||
// everything before this point is ErsatzTV-side "prep" (playout item resolution,
|
||||
// pipeline build, graphics engine spawn); FFmpeg's own clock starts here
|
||||
Interlocked.CompareExchange(ref _coldStartProcessLaunchedTicks, Stopwatch.GetTimestamp(), 0);
|
||||
|
||||
CommandResult commandResult = await processWithPipe
|
||||
.WithWorkingDirectory(_workingDirectory)
|
||||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(ParseProgressLine))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(linkedCts.Token);
|
||||
|
||||
@@ -714,20 +652,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException
|
||||
&& cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// a cancellation anywhere in this method (including inside the mediator sends, which sit
|
||||
// outside the inner ffmpeg try below) is a shutdown or a client disconnect, not a fault.
|
||||
// Without this it reaches the catch-all and logs a channel-level ERROR with a stack
|
||||
// trace on every graceful teardown. The token check is load-bearing: TaskCanceledException
|
||||
// is also what HttpClient throws on ITS OWN timeout, and a real timeout inside ffprobe, a
|
||||
// media-server call or subtitle extraction must keep its ERROR-level signal rather than
|
||||
// being downgraded to a routine teardown. (ersatztv#473 review)
|
||||
_logger.LogInformation("Terminating HLS session for channel {Channel}", _channelNumber);
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error transcoding channel {Channel} - {Message}", _channelNumber, ex.Message);
|
||||
|
||||
+10
-30
@@ -1,4 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using CliWrap;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
@@ -42,7 +42,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
private readonly IGraphicsElementSelector _graphicsElementSelector;
|
||||
private readonly IDecoSelector _decoSelector;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IRemoteStreamProber _remoteStreamProber;
|
||||
private readonly ISongVideoGenerator _songVideoGenerator;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
private readonly bool _isDebugNoSync;
|
||||
@@ -63,11 +62,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
IWatermarkSelector watermarkSelector,
|
||||
IGraphicsElementSelector graphicsElementSelector,
|
||||
IDecoSelector decoSelector,
|
||||
IRemoteStreamProber remoteStreamProber,
|
||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
_remoteStreamProber = remoteStreamProber;
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_fileSystem = fileSystem;
|
||||
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
|
||||
@@ -552,7 +549,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Optional(channel.PlayoutOffset),
|
||||
!request.HlsRealtime);
|
||||
case PlayoutItemDoesNotExistOnDisk:
|
||||
case PlayoutItemNotAvailableFromMediaServer:
|
||||
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
@@ -854,15 +850,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
pmf.Path,
|
||||
pmf.Key);
|
||||
|
||||
var plexUrl =
|
||||
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
|
||||
|
||||
if (!await _remoteStreamProber.IsAvailable(plexUrl, cancellationToken))
|
||||
{
|
||||
return new PlayoutItemNotAvailableFromMediaServer(plexUrl);
|
||||
}
|
||||
|
||||
return new PlayoutItemWithPath(playoutItem, plexUrl);
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -878,14 +868,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
foreach (string itemId in jellyfinItemId)
|
||||
{
|
||||
var jellyfinUrl = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
|
||||
|
||||
if (!await _remoteStreamProber.IsAvailable(jellyfinUrl, cancellationToken))
|
||||
{
|
||||
return new PlayoutItemNotAvailableFromMediaServer(jellyfinUrl);
|
||||
}
|
||||
|
||||
return new PlayoutItemWithPath(playoutItem, jellyfinUrl);
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}");
|
||||
}
|
||||
|
||||
// attempt to remotely stream emby
|
||||
@@ -898,14 +883,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
foreach (string itemId in embyItemId)
|
||||
{
|
||||
var embyUrl = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
|
||||
|
||||
if (!await _remoteStreamProber.IsAvailable(embyUrl, cancellationToken))
|
||||
{
|
||||
return new PlayoutItemNotAvailableFromMediaServer(embyUrl);
|
||||
}
|
||||
|
||||
return new PlayoutItemWithPath(playoutItem, embyUrl);
|
||||
return new PlayoutItemWithPath(
|
||||
playoutItem,
|
||||
$"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}");
|
||||
}
|
||||
|
||||
return new PlayoutItemDoesNotExistOnDisk(path);
|
||||
|
||||
@@ -45,8 +45,7 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
|
||||
|
||||
public async Task<TroubleshootingInfo> Handle(GetTroubleshootingInfo request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Support bundle wants current state, so force a fresh run rather than serving the poll cache.
|
||||
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(true, cancellationToken);
|
||||
List<HealthCheckResult> healthCheckResults = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
|
||||
string version = Assembly.GetEntryAssembly()?
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
@@ -119,22 +118,22 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
|
||||
{ VaapiDriver.iHD, VaapiDriver.i965, VaapiDriver.RadeonSI, VaapiDriver.Nouveau };
|
||||
|
||||
foreach (string display in vaapiDisplays)
|
||||
foreach (VaapiDriver activeDriver in allDrivers)
|
||||
foreach (string vaapiDevice in vaapiDevices)
|
||||
{
|
||||
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
|
||||
display,
|
||||
Optional(GetDriverName(activeDriver)),
|
||||
vaapiDevice))
|
||||
{
|
||||
vaapiCapabilities.AppendLine(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}");
|
||||
vaapiCapabilities.AppendLine();
|
||||
vaapiCapabilities.AppendLine(output);
|
||||
vaapiCapabilities.AppendLine();
|
||||
}
|
||||
}
|
||||
foreach (VaapiDriver activeDriver in allDrivers)
|
||||
foreach (string vaapiDevice in vaapiDevices)
|
||||
{
|
||||
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
|
||||
display,
|
||||
Optional(GetDriverName(activeDriver)),
|
||||
vaapiDevice))
|
||||
{
|
||||
vaapiCapabilities.AppendLine(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}");
|
||||
vaapiCapabilities.AppendLine();
|
||||
vaapiCapabilities.AppendLine(output);
|
||||
vaapiCapabilities.AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_runtimeInfo.IsOSPlatform(OSPlatform.OSX))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.Watermarks;
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
|
||||
new(watermark.Id, watermark.Name, watermark.ImageSource);
|
||||
new(watermark.Id, watermark.Name);
|
||||
|
||||
internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) =>
|
||||
new(
|
||||
|
||||
@@ -1,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");
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class ColdStartStartupSplitTests
|
||||
{
|
||||
// milestones are Stopwatch.GetTimestamp() values; build them from a base + millisecond offsets
|
||||
private const long Base = 1_000_000_000;
|
||||
|
||||
private static long At(double milliseconds) =>
|
||||
Base + (long)(milliseconds / 1000.0 * Stopwatch.Frequency);
|
||||
|
||||
[Test]
|
||||
public void Should_Split_Three_Ways_When_All_Milestones_Present()
|
||||
{
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(150),
|
||||
At(1200),
|
||||
At(1600));
|
||||
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
|
||||
split.Prep.TotalMilliseconds.ShouldBe(150, 1);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1);
|
||||
split.FirstGop.TotalMilliseconds.ShouldBe(400, 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sub_Phases_Should_Sum_To_Run_Entry_Through_Playlist()
|
||||
{
|
||||
// deliberately NOT "should sum to startup": the buckets span the worker's Run entry, which
|
||||
// begins before the request thread's startup stopwatch, so prep overlaps the tail of setup
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(150),
|
||||
At(1200),
|
||||
At(1600));
|
||||
|
||||
(split.Prep + split.FFmpegInit + split.FirstGop).TotalMilliseconds.ShouldBe(1600, 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Be_Unavailable_When_The_Playlist_Predates_The_Process_Launch()
|
||||
{
|
||||
// a stale live.m3u8 survives when the handler's pre-session folder wipe fails (EmptyFolder
|
||||
// swallows the failure into a warning). Every bucket would be meaningless, so report nothing
|
||||
// rather than a plausible-looking sample with a prep that exceeds the whole measured phase
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(1600),
|
||||
0,
|
||||
At(150));
|
||||
|
||||
split.ShouldBe(ColdStartStartupSplit.Unavailable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Stale_Playlist_Guard_Should_Take_Precedence_Over_The_Progress_Branches()
|
||||
{
|
||||
// without the guard, this input would be classified TwoWayLateProgress; the guard must be
|
||||
// evaluated first. (It can never preempt a ThreeWay: that requires processLaunched <=
|
||||
// playlistExists, which is exactly the negation of the guard condition.)
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(1600),
|
||||
At(1700),
|
||||
At(150));
|
||||
|
||||
split.ShouldBe(ColdStartStartupSplit.Unavailable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fall_Back_To_Two_Way_Split_When_Progress_Predates_The_Process_Launch()
|
||||
{
|
||||
// a progress timestamp older than the launch cannot belong to this process
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(100),
|
||||
At(150),
|
||||
At(120),
|
||||
At(1600));
|
||||
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
split.FirstGop.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Stay_Three_Way_When_Progress_Coincides_With_A_Boundary()
|
||||
{
|
||||
ColdStartStartupSplit atLaunch = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(150), At(1600));
|
||||
atLaunch.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
|
||||
atLaunch.FFmpegInit.ShouldBe(TimeSpan.Zero);
|
||||
atLaunch.FirstGop.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
|
||||
ColdStartStartupSplit atPlaylist = ColdStartStartupSplit.FromTimestamps(At(0), At(150), At(1600), At(1600));
|
||||
atPlaylist.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
|
||||
atPlaylist.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
atPlaylist.FirstGop.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fall_Back_To_Two_Way_Split_When_FFmpeg_Never_Reported_Progress()
|
||||
{
|
||||
// no -progress output before the playlist appeared: ffmpegInit must absorb the remainder
|
||||
// rather than the split inventing a firstGop boundary that was never observed
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(150),
|
||||
0,
|
||||
At(1600));
|
||||
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWay);
|
||||
split.Prep.TotalMilliseconds.ShouldBe(150, 1);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
split.FirstGop.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Report_Late_Progress_Distinctly_When_Progress_Arrived_After_The_Playlist()
|
||||
{
|
||||
// the playlist is observed on the request thread while progress is recorded on the worker
|
||||
// thread; a progress milestone outside the phase must not produce a negative bucket
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(0),
|
||||
At(150),
|
||||
At(1800),
|
||||
At(1600));
|
||||
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.TwoWayLateProgress);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1450, 1);
|
||||
split.FirstGop.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[TestCase(0L, 150L, 1200L, 1600L, TestName = "Run never started")]
|
||||
[TestCase(100L, 0L, 0L, 1600L, TestName = "Process never launched")]
|
||||
[TestCase(100L, 150L, 1200L, 0L, TestName = "Playlist never appeared")]
|
||||
public void Should_Be_Unavailable_When_A_Required_Milestone_Is_Missing(
|
||||
long runStarted,
|
||||
long processLaunched,
|
||||
long firstProgress,
|
||||
long playlistExists)
|
||||
{
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
runStarted == 0 ? 0 : At(runStarted),
|
||||
processLaunched == 0 ? 0 : At(processLaunched),
|
||||
firstProgress == 0 ? 0 : At(firstProgress),
|
||||
playlistExists == 0 ? 0 : At(playlistExists));
|
||||
|
||||
split.ShouldBe(ColdStartStartupSplit.Unavailable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Clamp_Rather_Than_Report_A_Negative_Prep()
|
||||
{
|
||||
// defensive: launch cannot precede Run entry, but telemetry must never show a negative
|
||||
ColdStartStartupSplit split = ColdStartStartupSplit.FromTimestamps(
|
||||
At(500),
|
||||
At(150),
|
||||
At(1200),
|
||||
At(1600));
|
||||
|
||||
split.Prep.ShouldBe(TimeSpan.Zero);
|
||||
split.Kind.ShouldBe(ColdStartStartupSplitKind.ThreeWay);
|
||||
split.FFmpegInit.TotalMilliseconds.ShouldBe(1050, 1);
|
||||
split.FirstGop.TotalMilliseconds.ShouldBe(400, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Pins which watermarks ffmpeg may carry natively and which must go to the graphics engine.
|
||||
/// The remote-URL rule is the second half of the #502 fix: resolving the URL is useless if the
|
||||
/// resolved path is then handed to ffmpeg as a bare <c>-i</c> argument.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class FFmpegNativeWatermarkRoutingTests
|
||||
{
|
||||
private const string LocalPath = "/cache/logos/ab/abc123.png";
|
||||
|
||||
private static WatermarkOptions Options(
|
||||
string imagePath,
|
||||
ChannelWatermarkMode mode = ChannelWatermarkMode.Permanent) =>
|
||||
new(new ChannelWatermark { Id = 1, Name = "wm", Mode = mode }, imagePath, Option<int>.None);
|
||||
|
||||
[Test]
|
||||
public void Local_Path_Single_Permanent_Watermark_Uses_FFmpeg()
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath)])
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[TestCase("https://cdn.example.com/logos/channel.png")]
|
||||
[TestCase("http://cdn.example.com/logos/channel.png")]
|
||||
public void Remote_Url_Watermark_Goes_To_Graphics_Engine(string url)
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(url)])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The generated-initials fallback is a localhost URL. Only the deco path still emits it, and it is
|
||||
/// routed by its resolved path like any other URL — see the #502 entry in docs/decisions.md and #510.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Generated_Localhost_Logo_Url_Goes_To_Graphics_Engine()
|
||||
{
|
||||
FFmpegLibraryProcessService
|
||||
.CanUseFFmpegNativeWatermark(0, [Options("http://localhost:8409/iptv/logos/gen?text=Test")])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Graphics_Elements_Present_Goes_To_Graphics_Engine()
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(1, [Options(LocalPath)])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Multiple_Watermarks_Go_To_Graphics_Engine()
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath), Options(LocalPath)])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Watermarks_Does_Not_Use_FFmpeg()
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, []).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestCase(ChannelWatermarkMode.Intermittent)]
|
||||
[TestCase(ChannelWatermarkMode.None)]
|
||||
public void Non_Permanent_Watermark_Goes_To_Graphics_Engine(ChannelWatermarkMode mode)
|
||||
{
|
||||
FFmpegLibraryProcessService.CanUseFFmpegNativeWatermark(0, [Options(LocalPath, mode)])
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="ChannelWatermarkImageSource.ChannelLogo" /> resolution at all three watermark
|
||||
/// precedence levels (playout item, channel, global). The shared fixture in
|
||||
/// <see cref="WatermarkSelectorTests" /> deliberately makes every watermark file exist, so it cannot
|
||||
/// express the "logo is an external URL" or "logo file is gone" cases this fixture exists for (#502).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WatermarkSelectorChannelLogoTests
|
||||
{
|
||||
private const string ExternalLogoUrl = "https://cdn.example.com/logos/channel.png";
|
||||
private const string LocalLogoPath = "abc123.png";
|
||||
private const string LocalLogoCachePath = "/cache/logos/ab/abc123.png";
|
||||
|
||||
private WatermarkSelector _selector;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var mockFileSystem = new MockFileSystem();
|
||||
mockFileSystem.Initialize().WithFile(LocalLogoCachePath);
|
||||
|
||||
var fakeImageCache = Substitute.For<IImageCache>();
|
||||
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
|
||||
.Returns(_ => LocalLogoCachePath);
|
||||
|
||||
_selector = new WatermarkSelector(
|
||||
mockFileSystem,
|
||||
fakeImageCache,
|
||||
Substitute.For<IDecoSelector>(),
|
||||
NullLogger<WatermarkSelector>.Instance);
|
||||
}
|
||||
|
||||
private static ChannelWatermark ChannelLogoWatermark(int id, string name) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
ImageSource = ChannelWatermarkImageSource.ChannelLogo,
|
||||
Mode = ChannelWatermarkMode.Permanent
|
||||
};
|
||||
|
||||
private static Channel ChannelWithLogo(string logoPath, ChannelWatermark channelWatermark = null)
|
||||
{
|
||||
var channel = new Channel(Guid.Empty)
|
||||
{
|
||||
Id = 1,
|
||||
Number = "1",
|
||||
Name = "Test",
|
||||
StreamingMode = StreamingMode.TransportStream,
|
||||
Artwork = [],
|
||||
Watermark = channelWatermark,
|
||||
WatermarkId = channelWatermark?.Id
|
||||
};
|
||||
|
||||
if (logoPath is not null)
|
||||
{
|
||||
channel.Artwork.Add(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = logoPath });
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
// ---- external URL logo: render path must degrade to no bug, never fetch (#525) --------------
|
||||
//
|
||||
// As of #525 an external-URL logo is downloaded and cached at save time, so a URL path can only be a
|
||||
// row that failed migration. The render/watermark path must NOT fetch at compositing time: it degrades
|
||||
// to None (no on-screen bug) with a warning, rather than handing the URL downstream as a renderable
|
||||
// ImagePath (the #502 behavior these tests previously pinned).
|
||||
|
||||
[Test]
|
||||
public void PlayoutItemWatermark_Should_Ignore_External_Url_Channel_Logo()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(1, "PlayoutItem");
|
||||
Channel channel = ChannelWithLogo(ExternalLogoUrl);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
watermark,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(ExternalLogoUrl, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
// never hand the URL downstream as a renderable path
|
||||
result.IfSome(o => o.ImagePath.ShouldNotBe(ExternalLogoUrl));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GlobalWatermark_Should_Ignore_External_Url_Channel_Logo()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(3, "Global");
|
||||
Channel channel = ChannelWithLogo(ExternalLogoUrl);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
watermark);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scheme comparison goes through <see cref="Uri" />, which lower-cases it. Pinned because the fix
|
||||
/// turns on <c>Artwork.IsExternalUrl</c>, and a case-sensitive check would silently fall back to the
|
||||
/// existence-gated branch and re-introduce the defect for an oddly-cased URL.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Ignore_External_Url_Channel_Logo_Regardless_Of_Scheme_Case()
|
||||
{
|
||||
const string UpperCaseUrl = "HTTPS://cdn.example.com/logos/channel.png";
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(UpperCaseUrl, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- regressions: local-file behavior must not change ---------------------------------------
|
||||
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Use_Cached_Path_For_Local_Channel_Logo()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
result.IfNone(() => throw new InvalidOperationException()).ImagePath.ShouldBe(LocalLogoCachePath);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Be_Ignored_When_Local_Channel_Logo_File_Is_Missing()
|
||||
{
|
||||
var mockFileSystem = new MockFileSystem(); // nothing on disk
|
||||
var fakeImageCache = Substitute.For<IImageCache>();
|
||||
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Logo), Arg.Any<Option<int>>())
|
||||
.Returns(_ => LocalLogoCachePath);
|
||||
|
||||
var selector = new WatermarkSelector(
|
||||
mockFileSystem,
|
||||
fakeImageCache,
|
||||
Substitute.For<IDecoSelector>(),
|
||||
NullLogger<WatermarkSelector>.Instance);
|
||||
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(LocalLogoPath, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope guard for #502: with no logo artwork at all, the resolved path is the generated-initials
|
||||
/// URL from <see cref="Images.ChannelLogoGenerator.GenerateChannelLogoUrl" />, which hardcodes
|
||||
/// localhost (issue #1). That fallback stays disabled here — reviving it is deliberately deferred
|
||||
/// in docs/decisions.md and is not part of this fix.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ChannelWatermark_Should_Be_Ignored_When_Channel_Has_No_Logo_Artwork()
|
||||
{
|
||||
ChannelWatermark watermark = ChannelLogoWatermark(2, "Channel");
|
||||
Channel channel = ChannelWithLogo(null, watermark);
|
||||
|
||||
Option<WatermarkOptions> result = _selector.GetWatermarkOptions(
|
||||
channel,
|
||||
Option<ChannelWatermark>.None,
|
||||
Option<ChannelWatermark>.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using ErsatzTV.Core.Images;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Images;
|
||||
|
||||
[TestFixture]
|
||||
public class RemoteImageDecodeBudgetTests
|
||||
{
|
||||
private static readonly Uri Uri = new("https://example.com/logo.png");
|
||||
|
||||
// the product is the real bound: 2500x2500 x600 is affordable on each axis alone but not together
|
||||
[Test]
|
||||
public void Should_Reject_Dimensions_And_Frames_Affordable_Alone_But_Not_Together()
|
||||
{
|
||||
((long)2500 * 2500).ShouldBeLessThanOrEqualTo(RemoteImageDecodeBudget.MaxRemoteDecodedPixels);
|
||||
600.ShouldBeLessThanOrEqualTo(RemoteImageDecodeBudget.MaxRemoteFrames);
|
||||
|
||||
InvalidOperationException ex = Should.Throw<InvalidOperationException>(
|
||||
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(2500, 2500, 600, Uri));
|
||||
ex.Message.ShouldContain("pixel limit");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_Too_Many_Frames_Even_When_Each_Is_Tiny() =>
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(8, 8, RemoteImageDecodeBudget.MaxRemoteFrames + 1, Uri))
|
||||
.Message.ShouldContain("frame limit");
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_A_Single_Oversized_Frame() =>
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => RemoteImageDecodeBudget.EnsureDimensionsAffordable(30000, 30000, Uri))
|
||||
.Message.ShouldContain("pixel limit");
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_A_Single_Large_Still_Within_Budget() =>
|
||||
Should.NotThrow(() => RemoteImageDecodeBudget.EnsureDecodeAffordable(7680, 4320, 1, Uri));
|
||||
|
||||
[Test]
|
||||
public void Should_Charge_At_Least_One_Frame_When_Header_Reports_None() =>
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => RemoteImageDecodeBudget.EnsureDecodeAffordable(30000, 30000, 0, Uri));
|
||||
|
||||
[Test]
|
||||
public void Should_Afford_Fewer_Frames_As_Frames_Get_Larger()
|
||||
{
|
||||
RemoteImageDecodeBudget.AffordableFrames(8, 8).ShouldBe(RemoteImageDecodeBudget.MaxRemoteFrames);
|
||||
RemoteImageDecodeBudget.AffordableFrames(1000, 1000).ShouldBe(50);
|
||||
RemoteImageDecodeBudget.AffordableFrames(7000, 7000).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -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&<>"");
|
||||
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
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaybackOrderSupportTests
|
||||
{
|
||||
private static readonly PlaybackOrder[] AllOrders = Enum.GetValues<PlaybackOrder>();
|
||||
|
||||
// The tripwire (#403): every engine must classify every PlaybackOrder value as either supported or
|
||||
// explicitly unsupported. Adding a new order without classifying it here fails this test, which forces the
|
||||
// author to wire it into (or deliberately reject it from) each dispatch site instead of letting it degrade
|
||||
// silently.
|
||||
[Test]
|
||||
public void EveryOrder_IsClassified_ForEveryEngine()
|
||||
{
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
foreach (PlaybackOrder order in AllOrders)
|
||||
{
|
||||
PlaybackOrderSupport.IsClassified(engine, order).ShouldBeTrue(
|
||||
$"PlaybackOrder.{order} is not classified for {engine}. Add it to PlaybackOrderSupport " +
|
||||
"(supported or unsupported) AND wire it into that engine's dispatch switch (#403).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every SchedulingEngineKind must have a matrix entry, or Matrix[engine] throws KeyNotFoundException at
|
||||
// runtime instead of failing here. This is the engine-axis counterpart to the order tripwire.
|
||||
[Test]
|
||||
public void EveryEngineKind_HasAMatrixEntry()
|
||||
{
|
||||
var classified = PlaybackOrderSupport.Engines.ToHashSet();
|
||||
|
||||
foreach (SchedulingEngineKind engine in Enum.GetValues<SchedulingEngineKind>())
|
||||
{
|
||||
classified.ShouldContain(engine,
|
||||
$"SchedulingEngineKind.{engine} has no PlaybackOrderSupport matrix entry (#403).");
|
||||
}
|
||||
}
|
||||
|
||||
// The two sets must partition the enum: no order both supported and unsupported, and together they cover
|
||||
// exactly the enum (no stale entry for a removed value, no missing value).
|
||||
[Test]
|
||||
public void SupportedAndUnsupported_ArePartition_ForEveryEngine()
|
||||
{
|
||||
var all = AllOrders.ToHashSet();
|
||||
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
IReadOnlySet<PlaybackOrder> supported = PlaybackOrderSupport.SupportedBy(engine);
|
||||
IReadOnlySet<PlaybackOrder> unsupported = PlaybackOrderSupport.UnsupportedBy(engine);
|
||||
|
||||
supported.Intersect(unsupported).ShouldBeEmpty(
|
||||
$"{engine}: an order is listed as both supported and unsupported");
|
||||
|
||||
var union = supported.Concat(unsupported).ToHashSet();
|
||||
union.ShouldBe(all, ignoreOrder: true,
|
||||
$"{engine}: supported ∪ unsupported does not equal the PlaybackOrder enum");
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the specific fragility called out in #403: Random is in Block's allow-list, so it must be
|
||||
// supported by Block (it previously worked only via the switch's coincidental Random fallback).
|
||||
[Test]
|
||||
public void Block_Supports_Random()
|
||||
{
|
||||
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, PlaybackOrder.Random).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// WeightedShuffle (#70) is Classic-only; the other engines must classify it as unsupported so the
|
||||
// write-path guards and this matrix agree.
|
||||
[Test]
|
||||
public void WeightedShuffle_IsClassicOnly()
|
||||
{
|
||||
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, PlaybackOrder.WeightedShuffle)
|
||||
.ShouldBeTrue();
|
||||
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
if (engine == SchedulingEngineKind.Classic)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PlaybackOrderSupport.IsSupported(engine, PlaybackOrder.WeightedShuffle).ShouldBeFalse(
|
||||
$"{engine} must not support WeightedShuffle (#70 is Classic-only)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
@@ -358,143 +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]);
|
||||
}
|
||||
|
||||
// #403: an order the playlist engine doesn't handle must be dropped LOUDLY (a warning), not silently.
|
||||
[Test]
|
||||
public async Task Test_UnsupportedOrder_Drops_Item_And_Logs_Warning()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
var logger = new RecordingLogger();
|
||||
|
||||
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
|
||||
{
|
||||
{
|
||||
new PlaylistItem
|
||||
{
|
||||
Id = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
PlayAll = false,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 1
|
||||
},
|
||||
[FakeMovie(10), FakeMovie(11)]
|
||||
},
|
||||
{
|
||||
// WeightedShuffle (#70) is Classic-only; the playlist switch has no arm for it.
|
||||
new PlaylistItem
|
||||
{
|
||||
Id = 2,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
PlayAll = false,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 2
|
||||
},
|
||||
[FakeMovie(20), FakeMovie(21)]
|
||||
}
|
||||
};
|
||||
|
||||
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
|
||||
repo,
|
||||
playlistItemMap,
|
||||
new CollectionEnumeratorState(),
|
||||
shufflePlaylistItems: false,
|
||||
batchSize: Option<int>.None,
|
||||
CancellationToken.None,
|
||||
logger);
|
||||
|
||||
// the unsupported item (20, 21) is dropped; only the chronological item (10, 11) cycles
|
||||
var items = new List<int>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
items.AddRange(enumerator.Current.Map(mi => mi.Id));
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
items.ShouldContain(10);
|
||||
items.ShouldContain(11);
|
||||
items.ShouldNotContain(20);
|
||||
items.ShouldNotContain(21);
|
||||
|
||||
// and it said so, rather than dropping silently
|
||||
logger.Entries.ShouldContain(
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("not supported by playlist"));
|
||||
}
|
||||
|
||||
private static Movie FakeMovie(int id) => new()
|
||||
{
|
||||
Id = id,
|
||||
@@ -507,27 +369,4 @@ public class PlaylistEnumeratorTests
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception exception,
|
||||
Func<TState, Exception, string> formatter) =>
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,6 @@ public record ChannelGuideProgrammeResponseModel(
|
||||
public record ChannelGuideChannelResponseModel(
|
||||
string Number,
|
||||
string Name,
|
||||
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
|
||||
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
|
||||
string? Logo,
|
||||
List<ChannelGuideProgrammeResponseModel> Programmes);
|
||||
|
||||
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable enable
|
||||
#nullable enable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
@@ -15,8 +15,4 @@ public record ChannelResponseModel(
|
||||
string Language,
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg,
|
||||
int PlayoutCount,
|
||||
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
|
||||
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
|
||||
string? Logo);
|
||||
bool ShowInEpg);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user