Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76f8a939c |
@@ -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,136 +0,0 @@
|
||||
name: Build CI Toolchain Image
|
||||
|
||||
# Builds the shared CI toolchain image (.NET 10 SDK + Node 22 + prod-identical ffmpeg) and
|
||||
# pushes it to the Gitea container registry (ersatztv#390). The toolchain jobs in
|
||||
# docker-build.yml consume it via `container:`, pinned to an immutable :<sha>.
|
||||
#
|
||||
# push touching docker/ci/** -> :<short-sha> (+ :latest only from main)
|
||||
# workflow_dispatch -> manual rebuild
|
||||
# schedule (weekly) -> picks up base-image security updates
|
||||
#
|
||||
# Deliberately separate from docker-build.yml: this image changes rarely (a Dockerfile edit or
|
||||
# the weekly cron), while docker-build.yml runs on every push/PR. Coupling them would rebuild a
|
||||
# ~2GB toolchain image on every commit.
|
||||
#
|
||||
# ROLLOUT NOTE: the jobs pin an immutable :<sha>, never :latest — a broken toolchain image would
|
||||
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is therefore
|
||||
# a deliberate two-step: merge a docker/ci/Dockerfile change (this workflow publishes a new :<sha>),
|
||||
# then update the pin in docker-build.yml in a follow-up PR whose CI proves the new image works.
|
||||
# See docs/ci-cd.md -> "CI toolchain image".
|
||||
#
|
||||
# Like docker-build.yml: the Gitea registry is HTTP-only, so BuildKit needs the inline
|
||||
# `http = true` config (it does not inherit the host daemon's insecure-registries setting).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'docker/ci/**'
|
||||
- '.gitea/workflows/ci-image.yml'
|
||||
schedule:
|
||||
# Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main).
|
||||
#
|
||||
# What this cron does and does NOT do — it does **not** update any running job. The jobs in
|
||||
# docker-build.yml pin an immutable :<sha> (deliberately), so a rebuilt image is consumed only
|
||||
# when a human bumps that pin. Its actual value is twofold:
|
||||
# 1. a weekly CANARY — catches "the toolchain image no longer builds" (a NodeSource/apt/base
|
||||
# change) at a time of our choosing, rather than when you next need to bump the pin;
|
||||
# 2. it leaves a freshly-patched :latest so the next pin bump starts from a current base.
|
||||
# `no-cache` on this path is what makes both real: with the shared :buildcache, the
|
||||
# `apt-get update && apt-get install` layer would restore from cache and re-fetch nothing.
|
||||
- cron: '0 5 * * 1'
|
||||
|
||||
# Serialize per ref: concurrent builds would race on the shared :buildcache tag.
|
||||
# No cancel-in-progress — a half-pushed toolchain image is worse than a redundant build.
|
||||
concurrency:
|
||||
group: ersatztv-ci-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
CI_IMAGE: 192.168.1.95:3000/timothy/ersatztv-ci
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push CI image
|
||||
# `small` = the small-jobs runner lane. This is a docker-only job (no toolchain needed —
|
||||
# it *builds* the toolchain), same as docker-build.yml's `build` job.
|
||||
runs-on: small
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# only docker/ci/Dockerfile is needed; no git describe/log here
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
# Always publish the immutable :<sha> — that is what docker-build.yml pins.
|
||||
TAGS=("${CI_IMAGE}:${SHORT}")
|
||||
# :latest is a convenience/floating pointer for humans and the weekly rebuild; jobs must
|
||||
# never consume it. Only main may move it.
|
||||
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
|
||||
TAGS+=("${CI_IMAGE}:latest")
|
||||
fi
|
||||
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<__EOT__"
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
echo "__EOT__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
printf 'tag: %s\n' "${TAGS[@]}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."192.168.1.95:3000"]
|
||||
http = true
|
||||
|
||||
- name: Login to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/ci/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
# The scheduled rebuild must bypass the cache or it is pointless: `mode=max` buildcache
|
||||
# would restore the `apt-get update && apt-get install` layer verbatim and pull in none of
|
||||
# the base updates the cron exists to collect. Push-triggered builds keep the cache.
|
||||
no-cache: ${{ github.event_name == 'schedule' }}
|
||||
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache,mode=max,ignore-error=true
|
||||
|
||||
# The Dockerfile's own build-time smoke test (dotnet --info, node, ffmpeg, ...) already ran
|
||||
# inside the build. This re-checks the *pushed* artifact end-to-end: that the registry copy
|
||||
# pulls and its toolchain runs, which is exactly what `container:` will do on every job.
|
||||
- name: Verify the pushed image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="${CI_IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
docker run --rm --entrypoint /bin/bash "$IMG" -euxc '
|
||||
dotnet --version
|
||||
dotnet ef --version
|
||||
node --version
|
||||
ffmpeg -version | head -1
|
||||
git --version
|
||||
python3 --version
|
||||
# reportgenerator --version exits 1 ("No report files specified"); probe the shim.
|
||||
command -v reportgenerator
|
||||
'
|
||||
echo "CI image OK. Pin this in .gitea/workflows/docker-build.yml -> CI_IMAGE_REF:"
|
||||
echo " ${IMG}"
|
||||
@@ -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
|
||||
|
||||
@@ -12,30 +12,6 @@ name: Build ErsatzTV Image
|
||||
#
|
||||
# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins
|
||||
# `:prod`, never `:latest` (enforced in the prod compose — server-management#481).
|
||||
#
|
||||
# TOOLCHAIN IMAGE (ersatztv#390): the jobs that need a toolchain (`test`, `migrations`,
|
||||
# `functional-e2e`, `api-docs`, `format`) run inside our shared CI image via `container:`
|
||||
# instead of installing .NET/Node/ffmpeg per run. It ships the .NET 10 SDK, Node 22,
|
||||
# prod-identical ffmpeg, and the dotnet-ef/reportgenerator global tools — so those jobs carry
|
||||
# no setup-dotnet, no setup-node, no apt, no `dotnet tool install`. Built by ci-image.yml from
|
||||
# docker/ci/Dockerfile. Project deps (NuGet/npm) are NOT baked in and stay on actions/cache.
|
||||
#
|
||||
# The pin below is an IMMUTABLE :<sha>, never :latest — a bad toolchain push would otherwise
|
||||
# break every converted job at once. It is repeated per job because `jobs.<id>.container.image`
|
||||
# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md ->
|
||||
# "CI toolchain image" for the two-step procedure.
|
||||
#
|
||||
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
#
|
||||
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
|
||||
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
|
||||
# `scripts/ci-detect-docs-only.sh` as their first post-checkout step (id: detect) and gate every
|
||||
# real step on `steps.detect.outputs.docs_only != 'true'`. Crucially they STILL RUN and STILL
|
||||
# report `success` in seconds — the two REQUIRED contexts (`Build & test (.NET)`, `EF migration
|
||||
# integrity (SQLite + MySql)`) must keep reporting or a docs-only PR could never merge. We do NOT
|
||||
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
|
||||
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
|
||||
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -56,49 +32,14 @@ concurrency:
|
||||
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Inside a `container:`, act_runner does NOT default `run` steps to bash — it falls back to
|
||||
# `sh -e {0}` (dash), because it can't assume bash exists in an arbitrary image. Every multi-line
|
||||
# script here is bash (`set -o pipefail`, arrays, `shopt`, `mapfile`), so dash fails them
|
||||
# immediately: `set: Illegal option -o pipefail`. Declare the shell once for the whole workflow
|
||||
# rather than per step. Non-container jobs already defaulted to bash, so this changes nothing for
|
||||
# them. (ersatztv#390 — see docs/ci-cd.md -> "CI toolchain image".)
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
IMAGE: 192.168.1.95:3000/timothy/ersatztv
|
||||
|
||||
# --- CI build memory (ersatztv#406, server-management#604) ---
|
||||
# Roslyn's `VBCSCompiler` is a *persistent* compiler server: it outlives the `dotnet build` that
|
||||
# started it and keeps its managed heap warm for the next one. Locally that is a real speedup.
|
||||
# In CI it buys nothing — each job container is torn down at the end of the run, so there is
|
||||
# never a "next build" to warm — while costing a lot: 7.8 GB RSS was measured live on bumblebee,
|
||||
# the single largest consumer on a 25 GiB host that also runs prod media. Several of those, one
|
||||
# per concurrent job container, is what drove the host to load 340 with 21 GiB swapped.
|
||||
#
|
||||
# These are MSBuild properties/switches, set here as environment variables so they apply to every
|
||||
# dotnet invocation in every job (restore/build/test/format/api-docs) without touching each call
|
||||
# site. MSBuild surfaces environment variables as properties, and `UseSharedCompilation` is only
|
||||
# defaulted to true when empty, so setting it here wins.
|
||||
#
|
||||
# NOTE: this reaches the *runner-side* dotnet jobs only. The `build` job compiles inside
|
||||
# `docker build`, where these do not propagate — the same switches are set as ENV in the
|
||||
# Dockerfile's SDK stage (docker/Dockerfile) to cover it.
|
||||
UseSharedCompilation: "false" # no persistent VBCSCompiler; csc runs per-project and exits
|
||||
DOTNET_CLI_USE_MSBUILD_SERVER: "0" # no persistent MSBuild server process
|
||||
MSBUILDDISABLENODEREUSE: "1" # MSBuild worker nodes exit with the build instead of lingering
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -107,15 +48,12 @@ jobs:
|
||||
# are only needed by the `build` job's `git describe` (ersatztv#190)
|
||||
fetch-depth: 1
|
||||
|
||||
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
|
||||
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
|
||||
# context keeps reporting — see the workflow header and docs/ci-cd.md -> "Docs-only skip".
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -123,59 +61,46 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
|
||||
# the SPA's package downloads are project deps, so they stay cached per lockfile.
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
@@ -186,7 +111,6 @@ jobs:
|
||||
# floor later"), so this step is purely informational — continue-on-error keeps a missing
|
||||
# report or a transient tool-install failure from ever blocking a build.
|
||||
- name: Coverage summary
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -197,8 +121,10 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
echo "Found ${#reports[@]} coverage report(s)."
|
||||
# reportgenerator is baked into the CI toolchain image (docker/ci/Dockerfile) and already
|
||||
# on PATH — no per-run `dotnet tool` install. Bump its version there (ersatztv#390).
|
||||
# `update` is install-or-update (idempotent, unlike `install` which errors if the tool
|
||||
# is already present under set -e); pinned for reproducible summary output.
|
||||
dotnet tool update --global dotnet-reportgenerator-globaltool --version 5.5.10 >/dev/null
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
reportgenerator \
|
||||
"-reports:coverage/**/coverage.cobertura.xml" \
|
||||
"-targetdir:coverage/report" \
|
||||
@@ -210,80 +136,9 @@ jobs:
|
||||
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Memory of THIS job container, reported every run (ersatztv#406, server-management#604).
|
||||
# #604 sizes the runners' per-job caps on these numbers, and until now they were inherited
|
||||
# rather than measured: the 10g cap traces back to server-management#570 observing the image
|
||||
# build peg 5.999/6 GiB, which is a different job entirely.
|
||||
#
|
||||
# ⚠️ READ THE BREAKDOWN, NOT JUST THE PEAK. `memory.peak` is the high-water mark of
|
||||
# `memory.current`, which charges **page cache** to the cgroup as well as anonymous memory —
|
||||
# it is NOT "peak RSS", and for a build job (NuGet/npm/obj/bin/coverage I/O) the cache
|
||||
# dominates. Demonstrated on bumblebee: a container with anon=0 that merely reads an 800 MB
|
||||
# file reports memory.peak=826 MiB, of which file=800 MiB. This matters because the naive
|
||||
# reading inverts the decision: page cache is **reclaimed** under a tighter cap, not
|
||||
# OOM-killed, so a large peak that is mostly `file` is NOT evidence that the cap must stay
|
||||
# high. `anon` (+ a little kernel/sock) is the part that actually forces an OOM.
|
||||
#
|
||||
# The split below is read at end-of-job, so it is the *current* composition rather than the
|
||||
# composition at the peak instant — indicative, not exact. Sizing a cap off one run is still
|
||||
# wrong; take a few runs, and treat anon as the floor and peak as the (cache-inflated)
|
||||
# ceiling. Refining this into a true peak-anon sample is ersatztv#412.
|
||||
#
|
||||
# Runs LAST on purpose: memory.peak read at step N reports the peak only up to N, so this
|
||||
# sits after Coverage summary to include reportgenerator, the job's last real workload.
|
||||
# cgroup v2 first, v1 fallback.
|
||||
#
|
||||
# Skipped on docs-only runs (ersatztv#416): nothing ran, so there is nothing to measure.
|
||||
- name: Report peak container memory
|
||||
# `always()` controls whether this step RUNS, not whether its failure fails the job — and
|
||||
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
|
||||
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
|
||||
# the same guarantee the Coverage summary step above uses.
|
||||
if: ${{ always() && steps.detect.outputs.docs_only != 'true' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
|
||||
peak=""; src=""
|
||||
for f in /sys/fs/cgroup/memory.peak /sys/fs/cgroup/memory/memory.max_usage_in_bytes; do
|
||||
if [ -r "$f" ]; then peak=$(cat "$f" 2>/dev/null || echo ""); src="$f"; break; fi
|
||||
done
|
||||
if [ -z "$peak" ]; then
|
||||
echo "No cgroup peak-memory file readable in this container -- skipping."
|
||||
exit 0
|
||||
fi
|
||||
anon=""; file=""
|
||||
if [ -r /sys/fs/cgroup/memory.stat ]; then
|
||||
anon=$(awk '/^anon /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
|
||||
file=$(awk '/^file /{print $2}' /sys/fs/cgroup/memory.stat 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "::group::Container memory (ersatztv#406 / server-management#604)"
|
||||
printf 'peak (incl. page cache): %s MiB [%s bytes, %s]\n' "$(mib "$peak")" "$peak" "$src"
|
||||
if [ -n "$anon" ]; then
|
||||
printf 'end-of-job anon (the part that OOMs): %s MiB\n' "$(mib "$anon")"
|
||||
printf 'end-of-job file (page cache, reclaimable): %s MiB\n' "$(mib "${file:-0}")"
|
||||
echo 'NOTE: peak counts reclaimable page cache. Size caps on anon, not on peak.'
|
||||
else
|
||||
echo 'NOTE: no memory.stat breakdown available; peak includes reclaimable page cache.'
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{
|
||||
printf '**Container memory (test job):** peak %s MiB *(incl. reclaimable page cache)*' \
|
||||
"$(mib "$peak")"
|
||||
[ -n "$anon" ] && printf ' · end-of-job anon %s MiB · file %s MiB' \
|
||||
"$(mib "$anon")" "$(mib "${file:-0}")"
|
||||
printf '\n'
|
||||
} >> "$GITHUB_STEP_SUMMARY" || true
|
||||
fi
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
|
||||
# can't block image builds until it's proven reliable on the runner. Promote to a
|
||||
# required check / build dependency once green. (ersatztv#13)
|
||||
@@ -296,42 +151,7 @@ jobs:
|
||||
# No host-port binding: the job reaches this service as mysql:3306 on the shared
|
||||
# runner network. Publishing 3306 made concurrent runs collide ("port is already
|
||||
# allocated") whenever two migrations jobs overlapped.
|
||||
#
|
||||
# `--memory`/`--cpus` here because the runner's `container.options` (`--memory=10g`)
|
||||
# applies to the JOB container ONLY, not to `services:` — verified by inspecting a live
|
||||
# migrations job: the job container reported HostConfig.Memory=10737418240, its mysql
|
||||
# service reported `mem=0 nanocpus=0`, i.e. unbounded. So every migrations run was adding
|
||||
# an uncapped MySQL to an already-tight host (ersatztv#406, server-management#604).
|
||||
#
|
||||
# NOTE (ersatztv#416): a `services:` container starts whenever the JOB starts, regardless
|
||||
# of step `if:`. So a docs-only migrations run still spins this mysql (capped, seconds) even
|
||||
# though the DDL-replay steps below are skipped. Fully skipping the service would require an
|
||||
# `if:`-skipped job, which we deliberately do NOT do for a required context — the heavy cost
|
||||
# (the 787-migration replay) is what the step gating removes.
|
||||
#
|
||||
# `--memory-swap=2g` is NOT redundant with `--memory=2g` — it is the point. Docker defaults
|
||||
# an unset `--memory-swap` to *twice* `--memory`, so `--memory=2g` alone would grant 2g RAM
|
||||
# **plus 2g of swap** (verified on bumblebee: `--memory=2g` alone → memory.max=2147483648
|
||||
# AND memory.swap.max=2147483648; with `--memory-swap=2g` → memory.swap.max=0). Setting it
|
||||
# equal to --memory disables swap for this container. That matters more here than anywhere:
|
||||
# swap thrash on this host is the whole reason this cap exists, and a swapping mysqld mid-DDL
|
||||
# is precisely the pathology behind the known `Command Timeout expired` migrations flake. We
|
||||
# want a loud OOM over silent swapping — an OOM is a clear signal to raise the cap.
|
||||
#
|
||||
# 2g is sized on measurement rather than inheritance, but honestly: a mysql:8.4 container
|
||||
# with this exact env peaked at 543 MiB during init and settled at 481 MiB idle (probed on
|
||||
# bumblebee 2026-07-17). That is init+idle, NOT the 787-migration replay, which grows caches
|
||||
# idle never touches — so treat 2g as a measured floor with headroom, not a measured
|
||||
# ceiling. The migrations job going green is what validates it. If this OOM-kills the
|
||||
# service, raise it deliberately — do not remove the cap, and do not re-enable swap.
|
||||
#
|
||||
# `--cpus=2` is a ceiling, not a reservation, and is the one number here with no measurement
|
||||
# behind it: 787 sequential DDL statements on one connection are ~1-core-bound, so 2 is
|
||||
# judgement. Revisit if the apply step's tail latency grows.
|
||||
options: >-
|
||||
--memory=2g
|
||||
--memory-swap=2g
|
||||
--cpus=2
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
@@ -342,14 +162,12 @@ jobs:
|
||||
# default fetch-depth: 1 -- this job never runs git describe/log, only
|
||||
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
|
||||
|
||||
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
|
||||
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -357,21 +175,19 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
|
||||
# — no per-run `dotnet tool install`. Bump its version there (ersatztv#390).
|
||||
- name: Install dotnet-ef
|
||||
run: dotnet tool install --global dotnet-ef --version 9.0.12
|
||||
|
||||
# SQLite is the prod provider; both checks validated locally.
|
||||
- name: SQLite — model drift + apply all migrations to a fresh DB
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
@@ -385,7 +201,6 @@ jobs:
|
||||
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
|
||||
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
|
||||
- name: MySql — model drift + apply all migrations to a fresh DB
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
env:
|
||||
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
|
||||
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
|
||||
@@ -395,6 +210,7 @@ jobs:
|
||||
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;DefaultCommandTimeout=300;"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
echo "::group::MySql model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
@@ -423,32 +239,25 @@ jobs:
|
||||
name: Functional E2E (curl contracts)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
|
||||
# flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
|
||||
# If-Match/412, and since ersatztv#363 two lock-contention 409s) that sessions have been
|
||||
# re-running by hand. Deliberately NOT a `needs:` of `build` and not (yet) a required check, so a
|
||||
# functional-E2E flake can't block image builds or the unit-test gate — promote it to a required
|
||||
# check / build dependency once it's proven reliable (same rollout the `migrations` job used).
|
||||
# SQLite default provider -> no DB service. Runs on PRs and on main (regression net); skipped for
|
||||
# v* tag builds.
|
||||
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
|
||||
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
|
||||
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
|
||||
# the unit-test gate — promote it to a required check / build dependency once it's proven
|
||||
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
|
||||
# Runs on PRs and on main (regression net); skipped for v* tag builds.
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -456,40 +265,30 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
|
||||
|
||||
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
|
||||
# the toolchain image ships the same ffmpeg build prod runs, so the binary is already here.
|
||||
# That step also cost 110s of every run. The harness never *transcodes*, but since ersatztv#363
|
||||
# it does use ffmpeg to synthesize ~60 tiny testsrc clips to seed the scan-lock 409 flow (and
|
||||
# python3's stdlib sqlite3 to seed the DB rows the API can't create) — both already present in
|
||||
# the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent.
|
||||
- name: Ensure ffmpeg is available
|
||||
run: command -v ffmpeg >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y ffmpeg)
|
||||
|
||||
- name: Boot instance and run functional-E2E harness
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
@@ -518,16 +317,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ersatztv#416: a docs-only push to main has nothing to rebuild (docs are not in the image),
|
||||
# so skip the build/push/smoke steps — the job still reports success. Tag builds force
|
||||
# docs_only=false in the script, so a release is never skipped.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
|
||||
- name: Compute version and tags
|
||||
id: meta
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
@@ -550,7 +341,6 @@ jobs:
|
||||
printf 'tag: %s\n' "${TAGS[@]}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
@@ -558,7 +348,6 @@ jobs:
|
||||
http = true
|
||||
|
||||
- name: Login to Gitea registry
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -566,7 +355,6 @@ jobs:
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
@@ -582,16 +370,14 @@ jobs:
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
|
||||
|
||||
- name: Smoke + IPTV E2E (assert key endpoints)
|
||||
if: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && steps.detect.outputs.docs_only != 'true' }}
|
||||
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
NAME="etv-smoke-${{ github.run_id }}"
|
||||
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
# --memory-swap equal to --memory disables swap. Without it Docker defaults --memory-swap
|
||||
# to 2x --memory, so `--memory 2g` alone silently grants 2g RAM + 2g swap (ersatztv#406).
|
||||
docker run -d --name "$NAME" --memory 2g --memory-swap 2g \
|
||||
docker run -d --name "$NAME" --memory 2g \
|
||||
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
|
||||
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
|
||||
"$IMG"
|
||||
@@ -642,57 +428,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#390): the CI toolchain image pin in this file must name the image that
|
||||
# ci-image.yml actually last published — i.e. the short sha of the last commit to touch the image's
|
||||
# sources. Without this detector, a PR that edits docker/ci/** publishes a NEW image but runs its own
|
||||
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
|
||||
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
|
||||
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
|
||||
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
|
||||
#
|
||||
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): push the
|
||||
# Dockerfile change, let ci-image.yml publish `:<sha>`, then update the pin to that sha. Seconds-long
|
||||
# git+grep -> keep it off the build runners.
|
||||
ci-image-pin:
|
||||
name: CI image pin matches docker/ci
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# need real history: `git log -- <path>` on a shallow clone can't find the last
|
||||
# commit that touched the image sources
|
||||
fetch-depth: 0
|
||||
- name: Verify the pin matches the last-published image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# ci-image.yml tags the image `git rev-parse --short HEAD` of the push that built it, and it
|
||||
# only builds on pushes touching these paths — so the published image is named by the last
|
||||
# commit to touch them.
|
||||
#
|
||||
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
||||
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
|
||||
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
|
||||
expected="$(git log -1 --format=%H -- docker/ci .gitea/workflows/ci-image.yml)"
|
||||
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
|
||||
echo "Image sources last changed in: ${expected}"
|
||||
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
|
||||
if [ "${#pins[@]}" -ne 1 ]; then
|
||||
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
|
||||
exit 1
|
||||
fi
|
||||
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
|
||||
if [ -z "$pin_full" ]; then
|
||||
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$pin_full" != "$expected" ]; then
|
||||
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Let ci-image.yml publish the new :<sha>, then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
|
||||
exit 1
|
||||
fi
|
||||
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
|
||||
|
||||
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
|
||||
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
|
||||
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
|
||||
@@ -768,27 +503,7 @@ jobs:
|
||||
# API path changed, the expensive steps skip and the job passes trivially.
|
||||
api-docs:
|
||||
name: API docs in sync (OpenAPI + endpoint index)
|
||||
# `small` lane (ersatztv#390): this job is ~5s on the ~90% of PRs that touch no API path, but
|
||||
# it was queueing ~29 min behind the heavy jobs in the contended `ubuntu-latest` lane, which
|
||||
# only has bumblebee-runner (capacity 2) + ci-runner. `small` has capacity 4, the same base
|
||||
# image, and answers in ~5s. Moving it here (and `format`) also drops `ubuntu-latest` from 5
|
||||
# jobs to 3, which shortens the queue for `test`/`migrations`/`functional-e2e` too.
|
||||
# This is only possible because `container:` makes the job self-contained — it no longer needs
|
||||
# the runner image to supply .NET/Node.
|
||||
#
|
||||
# REVERTED to `ubuntu-latest` (server-management#604 / ersatztv#406). The caveat below the
|
||||
# original #390 rationale turned out to be the deciding factor: on an API-touching PR this
|
||||
# job does a full `dotnet build`, so it is NOT a small job, and "capacity 4 absorbs that" was
|
||||
# only true while nothing enforced the SUM of the lanes' memory caps. It didn't: 6 slots x 10g
|
||||
# on a 25 GiB host drove bumblebee to load 713 with 21 GiB swapped. The `small` lane is now
|
||||
# sized for genuinely-tiny jobs, and #604 grew the `ubuntu-latest` lane instead (ci-runner
|
||||
# 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -811,6 +526,12 @@ jobs:
|
||||
echo "No API-surface change -> skipping regeneration (job passes)."
|
||||
fi
|
||||
|
||||
- name: Setup .NET
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
uses: actions/cache@v4
|
||||
@@ -823,13 +544,13 @@ jobs:
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
- name: Setup Node
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.api_changed == 'true'
|
||||
@@ -864,16 +585,7 @@ jobs:
|
||||
# a status, so it is safe as a required check).
|
||||
format:
|
||||
name: Formatting (changed .cs conform to .editorconfig)
|
||||
# Was on the `small` lane (ersatztv#390) to dodge a ~29 min queue; reverted to `ubuntu-latest`
|
||||
# in ersatztv#406 — `dotnet format` needs the .NET SDK and real memory, so it does not belong
|
||||
# in a lane sized for seconds-long shell jobs. See the api-docs job above for the full
|
||||
# rationale; server-management#604 grew this lane so the queue it was dodging is gone.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -897,6 +609,12 @@ jobs:
|
||||
echo "No .cs change -> skipping format verify (job passes)."
|
||||
fi
|
||||
|
||||
- name: Setup .NET
|
||||
if: steps.detect.outputs.cs_changed == 'true'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.cs_changed == 'true'
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -15,12 +15,6 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
|
||||
./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
|
||||
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
|
||||
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
|
||||
# index/worktree trap: a review fix left in the working tree shipped without being committed).
|
||||
# Runs before the slow CI-parity checks so it fails fast. Fail-open; escape ETV_ALLOW_DIRTY_PUSH=1.
|
||||
./.claude/hooks/prepush-clean-worktree-check.sh || exit 1
|
||||
|
||||
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
|
||||
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
|
||||
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
|
||||
|
||||
@@ -38,58 +38,6 @@ public static class AutoTuneAxisMap
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// Per-source member query for a weighted auto-tune channel (#425). The discriminator identifies ONE
|
||||
// content source within the channel's axis:
|
||||
// * TV axes -> the show title. Episodes carry no parent-show id in the search index (only show_title
|
||||
// is denormalized onto them), so show_title is the only field that selects a show's episodes. It is
|
||||
// the same discriminator the TvShow axis already uses, so this introduces no new fragility class;
|
||||
// a post-create show rename empties the member (items fall through to the remainder) until re-tuned.
|
||||
// * MovieGenre -> the movie's media-item id (the stable, rename-proof `id` field; a movie IS the
|
||||
// played item, so its own id selects it exactly).
|
||||
// Deliberately discriminator-ONLY (no genre clause): membership is decided when the channel is tuned,
|
||||
// so a materialized show airs all its episodes and the remainder subtracts the whole source (below).
|
||||
public static string GenerateSourceQuery(AutoTuneAxis axis, string discriminator) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
|
||||
$"type:episode AND show_title:\"{EscapeLuceneValue(discriminator)}\"",
|
||||
AutoTuneAxis.MovieGenre => $"type:movie AND id:{discriminator}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// The bare clause used to subtract a materialized/excluded source from the remainder query (below).
|
||||
// Mirrors GenerateSourceQuery's discriminator field, minus the type prefix.
|
||||
public static string SourceDiscriminatorClause(AutoTuneAxis axis, string discriminator) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
|
||||
$"show_title:\"{EscapeLuceneValue(discriminator)}\"",
|
||||
AutoTuneAxis.MovieGenre => $"id:{discriminator}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// The catch-all remainder query: the base axis query minus every materialized/excluded source, so the
|
||||
// base set is partitioned across (member sources + remainder) with no item counted twice and none
|
||||
// dropped. Returns the plain base query when there is nothing to subtract. Emitted as valid classic
|
||||
// Lucene — `(base) AND NOT (d1 OR d2 ...)` — because a ParseException silently escapes the whole query
|
||||
// into a literal (SearchQueryParser.ParseQuery fallback).
|
||||
public static string GenerateRemainderQuery(
|
||||
AutoTuneAxis axis,
|
||||
string value,
|
||||
IReadOnlyCollection<string> subtractedDiscriminators)
|
||||
{
|
||||
string baseQuery = GenerateQuery(axis, value);
|
||||
if (subtractedDiscriminators is null || subtractedDiscriminators.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
string negated = string.Join(
|
||||
" OR ",
|
||||
subtractedDiscriminators.Select(d => SourceDiscriminatorClause(axis, d)));
|
||||
return $"({baseQuery}) AND NOT ({negated})";
|
||||
}
|
||||
|
||||
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
|
||||
public static string EscapeLuceneValue(string value) =>
|
||||
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
|
||||
@@ -42,16 +42,6 @@ public class BulkDeleteChannelsHandler(
|
||||
|
||||
dbContext.Channels.RemoveRange(channels);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Clean up the system-owned weighted-auto-tune artifacts these channels created (#425), inside the
|
||||
// same transaction — see DeleteChannelHandler for the cascade rationale.
|
||||
await dbContext.MultiCollections
|
||||
.Where(mc => mc.OwnedByChannelId != null && channelIds.Contains(mc.OwnedByChannelId.Value))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.SmartCollections
|
||||
.Where(sc => sc.OwnedByChannelId != null && channelIds.Contains(sc.OwnedByChannelId.Value))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
@@ -197,25 +197,13 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
bool multiItem = normalized.Count >= 2;
|
||||
|
||||
// MultiCollection entries only support Shuffle / ShuffleInOrder / WeightedShuffle
|
||||
// (mirrors PlayoutModeMustBeValid -- keep the two lists in step).
|
||||
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
|
||||
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
|
||||
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder
|
||||
or PlaybackOrder.WeightedShuffle))
|
||||
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
|
||||
{
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
|
||||
}
|
||||
|
||||
// A lineup of 2+ entries is persisted as a Playlist, and PlaylistEnumerator has no default arm: an
|
||||
// order it doesn't know leaves the enumerator null and the items are dropped from the playlist with
|
||||
// nothing reported. This is the second (and less obvious) persisting writer of
|
||||
// PlaylistItem.PlaybackOrder, alongside ReplacePlaylistItems (#70; the silent fallbacks are #403).
|
||||
if (multiItem && playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for a multi-item lineup; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
if (multiItem)
|
||||
{
|
||||
// The generated playlist cannot express rerun collections or nested playlists
|
||||
|
||||
@@ -57,22 +57,9 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
_fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
int channelId = channel.Id;
|
||||
dbContext.Channels.Remove(channel);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Clean up the system-owned weighted-auto-tune artifacts this channel created (#425): the
|
||||
// MultiCollection (its cascade removes the now-dangling flood schedule item) and its per-source
|
||||
// SmartCollections (cascade removes their join rows). Null OwnedByChannelId = a user collection, left
|
||||
// untouched. Non-weighted (#69 single-SmartCollection) auto-tune channels set no ownership, so their
|
||||
// pre-existing orphan-on-delete behavior is unchanged.
|
||||
await dbContext.MultiCollections
|
||||
.Where(mc => mc.OwnedByChannelId == channelId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.SmartCollections
|
||||
.Where(sc => sc.OwnedByChannelId == channelId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
@@ -176,13 +176,6 @@ public class UpdateChannelHandler(
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
|
||||
}
|
||||
|
||||
// Deliberately NOT Mapper.GetPlayoutsCount: this handler's query (see Handle) doesn't include
|
||||
// MirrorSourceChannel, so the shared helper would read that navigation as null and return the
|
||||
// same own-playouts-only count anyway — with a false air of Mirror-awareness. Harmless today
|
||||
// because ChannelController discards this view model and re-projects through
|
||||
// GetChannelByIdForApi, so this count never reaches the wire. If you ever return it directly,
|
||||
// fix the QUERY first (add the MirrorSourceChannel ThenInclude) — swapping in the helper alone
|
||||
// would report 0 playouts for a working mirror channel.
|
||||
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using MediatR;
|
||||
|
||||
@@ -9,27 +8,11 @@ public record CreateAutoTunedChannels(
|
||||
string Group,
|
||||
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
|
||||
|
||||
// The batch-level TemplateId is the default; any per-channel field set here overrides it for that one
|
||||
// channel. Advanced/Logo/TemplateId are all optional so the older positional {axis, value, name, number}
|
||||
// form (and every existing caller/test) keeps compiling and behaving identically.
|
||||
public record AutoTuneChannelSelection(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number,
|
||||
int? TemplateId = null,
|
||||
ArtworkContentTypeModel Logo = null,
|
||||
CreateChannelFromLineupAdvancedOptions Advanced = null,
|
||||
List<AutoTuneSourceWeight> Sources = null);
|
||||
|
||||
// Per-content-source rotation weight + query correction for a weighted auto-tune channel (#425).
|
||||
// SourceId is the show id (TV axes) or movie media-item id (movie axis) from the members list (#384).
|
||||
// Weight is the relative share of airtime (weighted round-robin; 1 = fair-share). Excluded drops the
|
||||
// source entirely. A SourceId that is not in the axis's base set is an "add-untagged" source — materialized
|
||||
// like any other. When every entry is Weight 1 and not excluded (and adds nothing), the channel keeps the
|
||||
// single-SmartCollection fair-share shape; otherwise it is built as a MultiCollection of per-source
|
||||
// SmartCollections carrying the weights.
|
||||
public record AutoTuneSourceWeight(int SourceId, int Weight = 1, bool Excluded = false);
|
||||
string Number);
|
||||
|
||||
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
|
||||
{
|
||||
|
||||
@@ -4,29 +4,17 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class CreateAutoTunedChannelsHandler(
|
||||
ISender mediator,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets,
|
||||
ISmartCollectionCache smartCollectionCache)
|
||||
public class CreateAutoTunedChannelsHandler(ISender mediator)
|
||||
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
|
||||
{
|
||||
private const string NumberTakenError = "Channel number must be unique";
|
||||
private const string DefaultGroup = "Auto-Tuned";
|
||||
|
||||
// The members enumeration caps its own search at 10k leaf items, so a channel's distinct source count is
|
||||
// already bounded (dozens/hundreds). One large page pulls them all.
|
||||
private const int MaxSources = 10_000;
|
||||
|
||||
public async Task<AutoTuneResult> Handle(
|
||||
CreateAutoTunedChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -54,53 +42,8 @@ public class CreateAutoTunedChannelsHandler(
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name");
|
||||
}
|
||||
|
||||
// Per-channel template override falls back to the batch template.
|
||||
int effectiveTemplateId = selection.TemplateId ?? templateId;
|
||||
|
||||
// Per-channel uploaded channel image; None = generate the on-the-fly fallback logo at serve time.
|
||||
ArtworkContentTypeModel logo = selection.Logo ?? ArtworkContentTypeModel.None;
|
||||
|
||||
// Per-source rotation weights / query corrections (#425) turn the channel from one fair-share
|
||||
// SmartCollection into a MultiCollection of per-source SmartCollections carrying the weights. Only
|
||||
// when the caller actually customized a source (a non-default weight, an exclusion, or an added
|
||||
// out-of-axis source) — otherwise the single-SmartCollection fair-share shape is kept (cheaper, and
|
||||
// identical output for TV since the fake-collection path already groups per show).
|
||||
WeightedPlan plan = await BuildWeightedPlan(selection, cancellationToken);
|
||||
if (plan is not null)
|
||||
{
|
||||
return await CreateWeightedChannel(
|
||||
effectiveTemplateId, group, name, logo, selection, plan, cancellationToken);
|
||||
}
|
||||
|
||||
return await CreateSingleSmartCollectionChannel(
|
||||
effectiveTemplateId,
|
||||
group,
|
||||
name,
|
||||
logo,
|
||||
selection,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AutoTuneChannelOutcome> CreateSingleSmartCollectionChannel(
|
||||
int effectiveTemplateId,
|
||||
string group,
|
||||
string name,
|
||||
ArtworkContentTypeModel logo,
|
||||
AutoTuneChannelSelection selection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
|
||||
|
||||
// The axis default (SeasonEpisode for a single show, Shuffle for a genre) is the playback order
|
||||
// unless the DetailPanel set an explicit per-channel override. Any other Advanced field the caller
|
||||
// set is layered on top of the template by CreateChannelFromLineup's `advanced.X ?? template.X`
|
||||
// stamp-at-create contract, so we only have to fill in the axis-derived PlaybackOrder default here.
|
||||
PlaybackOrder axisOrder = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
|
||||
CreateChannelFromLineupAdvancedOptions advanced =
|
||||
(selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with
|
||||
{
|
||||
PlaybackOrder = selection.Advanced?.PlaybackOrder ?? axisOrder
|
||||
};
|
||||
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
|
||||
|
||||
// 1. Create the smart collection that drives this channel.
|
||||
Either<BaseError, SmartCollectionViewModel> scResult =
|
||||
@@ -124,11 +67,11 @@ public class CreateAutoTunedChannelsHandler(
|
||||
selection.Number,
|
||||
group,
|
||||
string.Empty,
|
||||
logo,
|
||||
ArtworkContentTypeModel.None,
|
||||
IsEnabled: true,
|
||||
ShowInEpg: true,
|
||||
effectiveTemplateId,
|
||||
advanced,
|
||||
templateId,
|
||||
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
|
||||
[
|
||||
new CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType.SmartCollection,
|
||||
@@ -170,350 +113,4 @@ public class CreateAutoTunedChannelsHandler(
|
||||
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
|
||||
}
|
||||
|
||||
// A resolved weighting plan: the per-source member queries + their weights, and the catch-all remainder.
|
||||
// Null when the caller did not actually customize anything (fall back to the single-SmartCollection path).
|
||||
private sealed record WeightedPlan(List<WeightedMember> Members, WeightedMember Remainder);
|
||||
|
||||
private sealed record WeightedMember(string Query, int Weight);
|
||||
|
||||
// Resolve the caller's per-source overrides against the channel's live base source set. Returns null when
|
||||
// no source was customized (all weights 1, nothing excluded, nothing added) so the caller keeps the
|
||||
// single-SmartCollection fair-share shape.
|
||||
private async Task<WeightedPlan> BuildWeightedPlan(
|
||||
AutoTuneChannelSelection selection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<AutoTuneSourceWeight> sources = selection.Sources ?? [];
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enumerate the axis's distinct base sources (parent shows for TV, movies for the movie axis) exactly
|
||||
// as the DetailPanel members list does, so weight resolution matches what the user saw.
|
||||
PagedLibraryBrowseItemsResponseModel members = await mediator.Send(
|
||||
new GetAutoTuneChannelMembers(selection.Axis, selection.Value, 0, MaxSources),
|
||||
cancellationToken);
|
||||
|
||||
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
|
||||
|
||||
// Any override touching a non-default weight, an exclusion, or an id outside the base set means the
|
||||
// channel really is customized; otherwise the plan would be identical to fair-share.
|
||||
bool customized = sources.Any(s => s.Weight != 1 || s.Excluded || !baseIds.Contains(s.SourceId));
|
||||
if (!customized)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<int, AutoTuneSourceWeight> overridesById = sources
|
||||
.GroupBy(s => s.SourceId)
|
||||
.ToDictionary(g => g.Key, g => g.Last());
|
||||
|
||||
return selection.Axis switch
|
||||
{
|
||||
AutoTuneAxis.MovieGenre => BuildMoviePlan(selection, members, overridesById),
|
||||
_ => await BuildTvPlan(selection, members, overridesById, cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
// TV: every base show becomes its own weighted SmartCollection (discriminator-only `show_title`) so
|
||||
// un-weighted shows keep per-show fair-share — a single merged remainder would regress them to
|
||||
// item-proportional (a 200-episode show would swamp a 20-episode one). The remainder is the live
|
||||
// catch-all for shows/episodes added after tune-in, at weight 1.
|
||||
private async Task<WeightedPlan> BuildTvPlan(
|
||||
AutoTuneChannelSelection selection,
|
||||
PagedLibraryBrowseItemsResponseModel members,
|
||||
Dictionary<int, AutoTuneSourceWeight> overridesById,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var weightedMembers = new List<WeightedMember>();
|
||||
var subtracted = new List<string>();
|
||||
|
||||
// Base shows (title is the discriminator; the members list already carries it).
|
||||
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
|
||||
foreach (LibraryBrowseItemResponseModel item in members.Page)
|
||||
{
|
||||
AutoTuneSourceWeight ov = overridesById.GetValueOrDefault(item.Id);
|
||||
if (ov is { Excluded: true })
|
||||
{
|
||||
subtracted.Add(item.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
weightedMembers.Add(new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, item.Title),
|
||||
NormalizeWeight(ov?.Weight ?? 1)));
|
||||
subtracted.Add(item.Title);
|
||||
}
|
||||
|
||||
// Added (out-of-axis) shows: resolve the title from metadata since the members list won't include them.
|
||||
List<int> addedIds = overridesById.Keys.Where(id => !baseIds.Contains(id)).ToList();
|
||||
if (addedIds.Count > 0)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Dictionary<int, string> titles = (await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => addedIds.Contains(sm.ShowId))
|
||||
.Select(sm => new { sm.ShowId, sm.Title })
|
||||
.ToListAsync(cancellationToken))
|
||||
.GroupBy(x => x.ShowId)
|
||||
.ToDictionary(g => g.Key, g => g.First().Title);
|
||||
|
||||
foreach (int id in addedIds)
|
||||
{
|
||||
AutoTuneSourceWeight ov = overridesById[id];
|
||||
if (ov.Excluded || !titles.TryGetValue(id, out string title) || string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
weightedMembers.Add(new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, title),
|
||||
NormalizeWeight(ov.Weight)));
|
||||
subtracted.Add(title);
|
||||
}
|
||||
}
|
||||
|
||||
var remainder = new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted),
|
||||
1);
|
||||
|
||||
return new WeightedPlan(weightedMembers, remainder);
|
||||
}
|
||||
|
||||
// Movies: materialize only the touched movies (a non-default weight, or an added out-of-axis movie) as
|
||||
// individual `id:{n}` SmartCollections; every un-touched base movie stays in ONE remainder whose weight is
|
||||
// its member count. Because the fake-collection path already pools all movies uniformly, a count-weighted
|
||||
// remainder is exactly equivalent to materializing each movie individually — without hundreds of rows.
|
||||
private static WeightedPlan BuildMoviePlan(
|
||||
AutoTuneChannelSelection selection,
|
||||
PagedLibraryBrowseItemsResponseModel members,
|
||||
Dictionary<int, AutoTuneSourceWeight> overridesById)
|
||||
{
|
||||
var weightedMembers = new List<WeightedMember>();
|
||||
var subtracted = new List<string>();
|
||||
|
||||
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
|
||||
var subtractedBase = 0;
|
||||
|
||||
foreach ((int id, AutoTuneSourceWeight ov) in overridesById)
|
||||
{
|
||||
bool inBase = baseIds.Contains(id);
|
||||
string idClause = id.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
if (ov.Excluded)
|
||||
{
|
||||
subtracted.Add(idClause);
|
||||
if (inBase)
|
||||
{
|
||||
subtractedBase++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Materialize weighted base movies and every added (out-of-axis) movie; a base movie left at
|
||||
// weight 1 is cheaper to leave in the remainder (same airtime either way).
|
||||
if (ov.Weight != 1 || !inBase)
|
||||
{
|
||||
weightedMembers.Add(new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, idClause),
|
||||
NormalizeWeight(ov.Weight)));
|
||||
subtracted.Add(idClause);
|
||||
if (inBase)
|
||||
{
|
||||
subtractedBase++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remainder weight = the un-touched base movie count, so a weighted movie airs N× *each* remainder
|
||||
// movie (the fake path already pools movies uniformly, so this is equivalent to materializing each).
|
||||
// Clamped to MultiCollectionItemWeight.Maximum (1000): a genre with >1000 un-touched movies can't
|
||||
// express the exact ratio (the weighted movie then airs slightly more than intended) — the same
|
||||
// 1..1000 bound #70's weight column imposes everywhere. Realistic only at very large scale.
|
||||
int remainderCount = baseIds.Count - subtractedBase;
|
||||
var remainder = new WeightedMember(
|
||||
AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted),
|
||||
NormalizeWeight(remainderCount));
|
||||
|
||||
return new WeightedPlan(weightedMembers, remainder);
|
||||
}
|
||||
|
||||
private static int NormalizeWeight(int weight) =>
|
||||
Math.Clamp(weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum);
|
||||
|
||||
private async Task<AutoTuneChannelOutcome> CreateWeightedChannel(
|
||||
int effectiveTemplateId,
|
||||
string group,
|
||||
string name,
|
||||
ArtworkContentTypeModel logo,
|
||||
AutoTuneChannelSelection selection,
|
||||
WeightedPlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// WeightedShuffle is the whole point; it overrides any axis default / caller Advanced.PlaybackOrder.
|
||||
CreateChannelFromLineupAdvancedOptions advanced =
|
||||
(selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with
|
||||
{
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle
|
||||
};
|
||||
|
||||
// Short unique token: the channel id isn't known until CreateChannelFromLineup runs, and both
|
||||
// SmartCollection.Name and MultiCollection.Name are unique varchar(50).
|
||||
string token = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
int multiCollectionId;
|
||||
List<int> smartCollectionIds;
|
||||
await using (TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken))
|
||||
{
|
||||
var multiCollection = new MultiCollection
|
||||
{
|
||||
Name = $"at-mc:{token}",
|
||||
MultiCollectionItems = [],
|
||||
MultiCollectionSmartItems = []
|
||||
};
|
||||
|
||||
var index = 0;
|
||||
foreach (WeightedMember member in plan.Members.Append(plan.Remainder))
|
||||
{
|
||||
var smartCollection = new SmartCollection
|
||||
{
|
||||
Name = index == plan.Members.Count ? $"at:{token}:rem" : $"at:{token}:{index}",
|
||||
Query = member.Query
|
||||
};
|
||||
|
||||
dbContext.SmartCollections.Add(smartCollection);
|
||||
multiCollection.MultiCollectionSmartItems.Add(new MultiCollectionSmartItem
|
||||
{
|
||||
MultiCollection = multiCollection,
|
||||
SmartCollection = smartCollection,
|
||||
ScheduleAsGroup = false,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
Weight = member.Weight
|
||||
});
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
dbContext.MultiCollections.Add(multiCollection);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AutoTuneChannelOutcome(
|
||||
name, AutoTuneOutcomeStatus.Failed, null, $"Weighted collections: {ex.Message}");
|
||||
}
|
||||
|
||||
multiCollectionId = multiCollection.Id;
|
||||
smartCollectionIds = multiCollection.MultiCollectionSmartItems
|
||||
.Select(i => i.SmartCollectionId)
|
||||
.ToList();
|
||||
|
||||
// New smart collections became visible; refresh targets + cache like CreateSmartCollectionHandler
|
||||
// (post-commit, CancellationToken.None so a late cancel can't abort it after the commit landed).
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await smartCollectionCache.Refresh(CancellationToken.None);
|
||||
}
|
||||
|
||||
var command = new CreateChannelFromLineup(
|
||||
name,
|
||||
selection.Number,
|
||||
group,
|
||||
string.Empty,
|
||||
logo,
|
||||
IsEnabled: true,
|
||||
ShowInEpg: true,
|
||||
effectiveTemplateId,
|
||||
advanced,
|
||||
[
|
||||
new CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType.MultiCollection,
|
||||
CollectionType.MultiCollection,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: multiCollectionId,
|
||||
SmartCollectionId: null,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null)
|
||||
]);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
foreach (BaseError error in channelResult.LeftToSeq())
|
||||
{
|
||||
// Roll back the multi collection + its member smart collections so a retry doesn't collide on
|
||||
// name uniqueness. Best-effort; the outcome below stands regardless of the cleanup result.
|
||||
await TryDeleteOwnedArtifacts(multiCollectionId, smartCollectionIds, cancellationToken);
|
||||
|
||||
AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal)
|
||||
? AutoTuneOutcomeStatus.Skipped
|
||||
: AutoTuneOutcomeStatus.Failed;
|
||||
return new AutoTuneChannelOutcome(name, status, null, error.Value);
|
||||
}
|
||||
|
||||
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
|
||||
|
||||
// Stamp ownership so the artifacts are hidden from user collection lists and cleaned up on channel
|
||||
// delete. Best-effort: an unstamped artifact is a cosmetic/cleanup issue, never a failed channel.
|
||||
await TryStampOwnership(multiCollectionId, smartCollectionIds, channelId);
|
||||
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
|
||||
}
|
||||
|
||||
private async Task TryStampOwnership(
|
||||
int multiCollectionId,
|
||||
List<int> smartCollectionIds,
|
||||
int channelId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Post-commit side effect: runs on CancellationToken.None so a late request cancellation can't
|
||||
// abort it after the channel-create commit landed (#254) — an un-stamped artifact would be a
|
||||
// permanent orphan (never cleaned on delete, and visible in the user collection lists). The MC +
|
||||
// its member smart collections are stamped in one transaction so a mid-way failure can't leave the
|
||||
// MC owned while the smart collections stay orphaned.
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(CancellationToken.None);
|
||||
await dbContext.MultiCollections
|
||||
.Where(mc => mc.Id == multiCollectionId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(mc => mc.OwnedByChannelId, channelId), CancellationToken.None);
|
||||
await dbContext.SmartCollections
|
||||
.Where(sc => smartCollectionIds.Contains(sc.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(sc => sc.OwnedByChannelId, channelId), CancellationToken.None);
|
||||
await transaction.CommitAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// intentionally ignored; see call site
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryDeleteOwnedArtifacts(
|
||||
int multiCollectionId,
|
||||
List<int> smartCollectionIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await dbContext.MultiCollections
|
||||
.Where(mc => mc.Id == multiCollectionId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
await dbContext.SmartCollections
|
||||
.Where(sc => smartCollectionIds.Contains(sc.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await smartCollectionCache.Refresh(CancellationToken.None);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// intentionally ignored; see call site
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
// Read-only enumeration of the distinct content-source members a proposed auto-tune channel's
|
||||
// server-generated SmartCollection query resolves to (issue #384). The client passes axis+value; the
|
||||
// server owns query generation (AutoTuneAxisMap.GenerateQuery) — the client never sends Lucene.
|
||||
public record GetAutoTuneChannelMembers(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
int PageNum,
|
||||
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
|
||||
@@ -1,168 +0,0 @@
|
||||
using ErsatzTV.Application.LibraryBrowse;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
// Runs the server-owned SmartCollection query for an axis value through the same search index the
|
||||
// built channel's playout uses, then rolls the matching leaf items up to their distinct content
|
||||
// sources: parent shows for the episode axes, movies for the movie-genre axis. Feeds the Auto-Tune
|
||||
// DetailPanel's read-only-by-default source list (#383/#384).
|
||||
public class GetAutoTuneChannelMembersHandler(
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAutoTuneChannelMembers, PagedLibraryBrowseItemsResponseModel>
|
||||
{
|
||||
// Mirrors MediaCollectionRepository.GetSmartCollectionItems: the index dislikes a zero limit, so
|
||||
// pull up to 10k matching leaf items and group in memory. A source whose matches fall entirely
|
||||
// beyond this cap would be under-counted (the same staleness bound the smart-collection path
|
||||
// already accepts) — realistic axis values resolve to far fewer than 10k items.
|
||||
private const int SearchLimit = 10_000;
|
||||
|
||||
public async Task<PagedLibraryBrowseItemsResponseModel> Handle(
|
||||
GetAutoTuneChannelMembers request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// An out-of-range numeric axis binds successfully (ModelState stays valid, so [ApiController]'s
|
||||
// auto-400 does not fire); treat it as no results rather than letting GenerateQuery's
|
||||
// ArgumentOutOfRangeException surface as a 500 — matching #69's EnumerateAxis `_ => []`.
|
||||
if (string.IsNullOrWhiteSpace(request.Value) || !Enum.IsDefined(request.Axis))
|
||||
{
|
||||
return new PagedLibraryBrowseItemsResponseModel(0, []);
|
||||
}
|
||||
|
||||
string query = AutoTuneAxisMap.GenerateQuery(request.Axis, request.Value);
|
||||
SearchResult searchResults = await searchIndex.Search(
|
||||
query,
|
||||
string.Empty,
|
||||
0,
|
||||
SearchLimit,
|
||||
cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
return request.Axis switch
|
||||
{
|
||||
AutoTuneAxis.MovieGenre => await MovieMembers(dbContext, searchResults, request, cancellationToken),
|
||||
_ => await ShowMembers(dbContext, searchResults, request, cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
// Episode axes (TvShow / TvGenre): roll matching episodes up to their distinct parent shows.
|
||||
private static async Task<PagedLibraryBrowseItemsResponseModel> ShowMembers(
|
||||
TvContext dbContext,
|
||||
SearchResult searchResults,
|
||||
GetAutoTuneChannelMembers request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> episodeIds = searchResults.Items
|
||||
.Where(i => i.Type == LuceneSearchIndex.EpisodeType)
|
||||
.Select(i => i.Id)
|
||||
.ToList();
|
||||
|
||||
if (episodeIds.Count == 0)
|
||||
{
|
||||
return new PagedLibraryBrowseItemsResponseModel(0, []);
|
||||
}
|
||||
|
||||
// Per-show count is the number of episodes THIS channel's query contributes, not the show's
|
||||
// total episode count (Episode -> Season -> ShowId; proven query style from LibraryBrowseItemMapper).
|
||||
Dictionary<int, int> matchCountByShow = (await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Where(e => episodeIds.Contains(e.Id))
|
||||
.Select(e => new { e.Id, e.Season.ShowId })
|
||||
.ToListAsync(cancellationToken))
|
||||
.GroupBy(x => x.ShowId)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
List<int> showIds = matchCountByShow.Keys.ToList();
|
||||
|
||||
// Order the distinct shows by title, then page (the show set is bounded — dozens, not thousands).
|
||||
List<int> orderedShowIds = (await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => showIds.Contains(sm.ShowId))
|
||||
.Select(sm => new { sm.ShowId, sm.Title })
|
||||
.ToListAsync(cancellationToken))
|
||||
.GroupBy(x => x.ShowId)
|
||||
.Select(g => new { ShowId = g.Key, Title = g.OrderBy(x => x.Title).Select(x => x.Title).FirstOrDefault() })
|
||||
.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(x => x.ShowId)
|
||||
.Select(x => x.ShowId)
|
||||
.ToList();
|
||||
|
||||
int total = orderedShowIds.Count;
|
||||
List<int> pageIds = orderedShowIds
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
List<LibraryBrowseItemResponseModel> hydrated =
|
||||
await LibraryBrowseItemMapper.GetShows(dbContext, pageIds, cancellationToken);
|
||||
Dictionary<int, LibraryBrowseItemResponseModel> byId = hydrated.ToDictionary(s => s.Id);
|
||||
|
||||
// GetShows groups by show id, so restore the requested title order and override its total-episode
|
||||
// ItemCount with the query-matching count.
|
||||
List<LibraryBrowseItemResponseModel> ordered = pageIds
|
||||
.Where(byId.ContainsKey)
|
||||
.Select(id => byId[id] with
|
||||
{
|
||||
ItemCount = matchCountByShow.TryGetValue(id, out int count) ? count : byId[id].ItemCount
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
|
||||
}
|
||||
|
||||
// Movie-genre axis: the matching movies are themselves the distinct content sources.
|
||||
private static async Task<PagedLibraryBrowseItemsResponseModel> MovieMembers(
|
||||
TvContext dbContext,
|
||||
SearchResult searchResults,
|
||||
GetAutoTuneChannelMembers request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> movieIds = searchResults.Items
|
||||
.Where(i => i.Type == LuceneSearchIndex.MovieType)
|
||||
.Select(i => i.Id)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (movieIds.Count == 0)
|
||||
{
|
||||
return new PagedLibraryBrowseItemsResponseModel(0, []);
|
||||
}
|
||||
|
||||
List<int> orderedMovieIds = (await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Where(mm => movieIds.Contains(mm.MovieId))
|
||||
.Select(mm => new { mm.MovieId, mm.Title })
|
||||
.ToListAsync(cancellationToken))
|
||||
.GroupBy(x => x.MovieId)
|
||||
.Select(g => new { MovieId = g.Key, Title = g.OrderBy(x => x.Title).Select(x => x.Title).FirstOrDefault() })
|
||||
.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(x => x.MovieId)
|
||||
.Select(x => x.MovieId)
|
||||
.ToList();
|
||||
|
||||
int total = orderedMovieIds.Count;
|
||||
List<int> pageIds = orderedMovieIds
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
List<LibraryBrowseItemResponseModel> hydrated =
|
||||
await LibraryBrowseItemMapper.GetMovies(dbContext, pageIds, cancellationToken);
|
||||
Dictionary<int, LibraryBrowseItemResponseModel> byId = hydrated.ToDictionary(m => m.Id);
|
||||
|
||||
List<LibraryBrowseItemResponseModel> ordered = pageIds
|
||||
.Where(byId.ContainsKey)
|
||||
.Select(id => byId[id])
|
||||
.ToList();
|
||||
|
||||
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -6,29 +6,6 @@ namespace ErsatzTV.Application.Channels;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
/// <summary>
|
||||
/// A mirror channel has no playouts of its own; it relays the playouts of its mirror source, so both must be
|
||||
/// counted for the total to answer "can this channel play anything?". Requires <see cref="Channel.Playouts" />
|
||||
/// and, for mirrors, <see cref="Channel.MirrorSourceChannel" />.<see cref="Channel.Playouts" /> to be included
|
||||
/// by the query — the repository reads are AsNoTracking, so an un-included navigation silently counts zero.
|
||||
/// </summary>
|
||||
internal static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
var result = 0;
|
||||
|
||||
if (channel.Playouts != null)
|
||||
{
|
||||
result += channel.Playouts.Count;
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
||||
{
|
||||
result += channel.MirrorSourceChannel.Playouts.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
@@ -96,7 +73,7 @@ internal static class Mapper
|
||||
channel.ShowInEpg);
|
||||
}
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) =>
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
@@ -108,8 +85,7 @@ internal static class Mapper
|
||||
channel.PreferredAudioLanguageCode,
|
||||
GetStreamingMode(channel),
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg,
|
||||
playoutCount);
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
@@ -13,6 +13,6 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
|
||||
return channels.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
@@ -11,4 +11,21 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository)
|
||||
await channelRepository.GetAll(cancellationToken)
|
||||
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
|
||||
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
|
||||
|
||||
private static int GetPlayoutsCount(Channel channel)
|
||||
{
|
||||
var result = 0;
|
||||
|
||||
if (channel.Playouts != null)
|
||||
{
|
||||
result += channel.Playouts.Count;
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
||||
{
|
||||
result += channel.MirrorSourceChannel.Playouts.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
|
||||
GetChannelByIdForApi request,
|
||||
CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel)));
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0));
|
||||
}
|
||||
|
||||
@@ -60,10 +60,7 @@ public partial class GetChannelGuideHandler(
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
// The token value is HTTP-request-derived and interpolated raw into the pre-built XMLTV
|
||||
// cache fragments, so it must be XML-escaped like {RequestBase} above — a token containing
|
||||
// '&', '<', '>', or '"' would otherwise malform the whole guide. Opaque tokens are a no-op.
|
||||
accessTokenUri += $"&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>
|
||||
|
||||
@@ -10,11 +10,7 @@ internal static class Mapper
|
||||
result.Title,
|
||||
GetStatus(result.Status),
|
||||
result.Message,
|
||||
string.IsNullOrWhiteSpace(result.BriefMessage) ? null : result.BriefMessage,
|
||||
result.Link.MatchUnsafe(l => l.Target, () => (string)null),
|
||||
result.Link.MatchUnsafe(
|
||||
l => new HealthCheckRemediationResponseModel(GetLinkKind(l.Kind), l.Target),
|
||||
() => (HealthCheckRemediationResponseModel)null));
|
||||
result.Link.MatchUnsafe(l => l.Link, () => null));
|
||||
|
||||
private static string GetStatus(HealthCheckStatus status) =>
|
||||
status switch
|
||||
@@ -23,17 +19,6 @@ internal static class Mapper
|
||||
HealthCheckStatus.Fail => "fail",
|
||||
HealthCheckStatus.Warning => "warn",
|
||||
HealthCheckStatus.Info => "info",
|
||||
// NotApplicable is filtered out before mapping today; map it defensively rather
|
||||
// than throwing, so a future caller that skips the filter can't 500 the endpoint.
|
||||
HealthCheckStatus.NotApplicable => "notApplicable",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
||||
};
|
||||
|
||||
private static string GetLinkKind(HealthCheckLinkKind kind) =>
|
||||
kind switch
|
||||
{
|
||||
HealthCheckLinkKind.ExternalDoc => "ExternalDoc",
|
||||
HealthCheckLinkKind.AppRoute => "AppRoute",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,13 +55,7 @@ public class AddArtistToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Artist.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -57,13 +57,7 @@ public class AddEpisodeToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Episode.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -54,13 +54,7 @@ public class AddImageToCollectionHandler : IRequestHandler<AddImageToCollection,
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Image.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -38,52 +38,23 @@ public class AddItemsToCollectionHandler :
|
||||
_searchChannel = searchChannel;
|
||||
}
|
||||
|
||||
// A duplicate-key race can roll back the whole batch (#308); recompute membership from a fresh
|
||||
// context and retry with only the still-missing items. Bounded to avoid a livelock — the common
|
||||
// no-collision path runs the loop body exactly once.
|
||||
private const int MaxDuplicateRetries = 5;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddItemsToCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
|
||||
// true = terminal (nothing to add, or our batch committed); false = a duplicate-key race
|
||||
// rolled the batch back, recompute membership and retry.
|
||||
Either<BaseError, bool> attemptResult = await maybeCollection.Match(
|
||||
Some: async collection =>
|
||||
{
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, bool>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
|
||||
if (attemptResult.IsLeft)
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: async collection =>
|
||||
{
|
||||
return attemptResult.Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
bool committed = attemptResult.Match(Left: _ => false, Right: done => done);
|
||||
if (committed)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// A concurrent add inserted one+ of our items first; recompute against fresh membership.
|
||||
if (attempt >= MaxDuplicateRetries)
|
||||
{
|
||||
return BaseError.New(
|
||||
"Concurrent modification while adding items to the collection; please retry.");
|
||||
}
|
||||
}
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyAddItemsRequest(
|
||||
private async Task<Unit> ApplyAddItemsRequest(
|
||||
TvContext dbContext,
|
||||
Collection collection,
|
||||
AddItemsToCollection request,
|
||||
@@ -104,10 +75,10 @@ public class AddItemsToCollectionHandler :
|
||||
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
|
||||
|
||||
// No-op when every requested item is already a member: don't rotate the ETag or fan out
|
||||
// rebuilds for an idempotent re-add — #269. Terminal success (no retry).
|
||||
// rebuilds for an idempotent re-add — #269.
|
||||
if (toAddIds.Count == 0)
|
||||
{
|
||||
return true;
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
List<MediaItem> toAdd = await dbContext.MediaItems
|
||||
@@ -120,15 +91,7 @@ public class AddItemsToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a).
|
||||
collection.Version++;
|
||||
|
||||
// A concurrent add of an overlapping item won the composite-PK race and rolled back this whole
|
||||
// batch. Unlike the single-item handlers (idempotent no-op), a bulk add must NOT drop the items
|
||||
// that did NOT collide — signal the caller to recompute membership and retry the still-missing
|
||||
// ones. #308
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(cancellationToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(cancellationToken);
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
@@ -141,7 +104,7 @@ public class AddItemsToCollectionHandler :
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
|
||||
return true;
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Collection>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,13 +55,7 @@ public class AddMediaItemToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MediaItem.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,13 +55,7 @@ public class AddMovieToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Movie.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -57,13 +57,7 @@ public class AddMusicVideoToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.MusicVideo.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -57,13 +57,7 @@ public class AddOtherVideoToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.OtherVideo.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,13 +55,7 @@ public class AddSeasonToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Season.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,13 +55,7 @@ public class AddShowToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Show.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
@@ -55,13 +55,7 @@ public class AddSongToCollectionHandler :
|
||||
// force-write past a concurrent replace-all Version bump — this add takes no If-Match, so a
|
||||
// benign race must not 500 (#253/#269 §7a). Post-commit enqueues run on CancellationToken.None.
|
||||
parameters.Collection.Version++;
|
||||
if (!await dbContext.TrySaveChangesForcingVersion(CancellationToken.None))
|
||||
{
|
||||
// A concurrent add of this same item won the composite-PK race and already inserted the row,
|
||||
// rotated the collection ETag, and fanned out the rebuild — so this is now an idempotent
|
||||
// no-op. Skip our reindex/rebuild fan-out (the winner already did it). #308
|
||||
return Unit.Default;
|
||||
}
|
||||
await dbContext.SaveChangesForcingVersion(CancellationToken.None);
|
||||
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems([parameters.Song.Id]), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
@@ -7,8 +7,7 @@ public record CreateMultiCollectionItem(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record CreateMultiCollection(string Name, List<CreateMultiCollectionItem> Items)
|
||||
: IRequest<Either<BaseError, MultiCollectionViewModel>>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -51,56 +51,42 @@ public class CreateMultiCollectionHandler :
|
||||
private static Task<Validation<BaseError, MultiCollection>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateMultiCollection request) =>
|
||||
ValidateName(dbContext, request)
|
||||
.BindT(name => ValidateWeights(request).Map(_ => name))
|
||||
.MapT(name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Bind(i =>
|
||||
ValidateName(dbContext, request).MapT(name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Bind(i =>
|
||||
{
|
||||
if (i.CollectionId.HasValue)
|
||||
{
|
||||
if (i.CollectionId.HasValue)
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = i.CollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
});
|
||||
}
|
||||
return Some(
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = i.CollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
});
|
||||
}
|
||||
|
||||
return Option<MultiCollectionItem>.None;
|
||||
})
|
||||
return Option<MultiCollectionItem>.None;
|
||||
})
|
||||
.ToList(),
|
||||
MultiCollectionSmartItems = request.Items.Bind(i =>
|
||||
MultiCollectionSmartItems = request.Items.Bind(i =>
|
||||
{
|
||||
if (i.SmartCollectionId.HasValue)
|
||||
{
|
||||
if (i.SmartCollectionId.HasValue)
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionSmartItem
|
||||
{
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
});
|
||||
}
|
||||
return Some(
|
||||
new MultiCollectionSmartItem
|
||||
{
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
});
|
||||
}
|
||||
|
||||
return Option<MultiCollectionSmartItem>.None;
|
||||
})
|
||||
return Option<MultiCollectionSmartItem>.None;
|
||||
})
|
||||
.ToList()
|
||||
});
|
||||
|
||||
// Bounds are shared with the update path so the two cannot drift -- they silently disagreed before #402:
|
||||
// EF's HasDefaultValue substitutes 1 for a 0 on INSERT (0 reads as "not set") while an UPDATE writes the 0
|
||||
// through, so the same input landed differently depending on the verb. The enumerator clamps out-of-range
|
||||
// weights, so neither a 0 nor a huge value can reach the rotation; this gate refuses input that has no
|
||||
// meaning on a share-of-airtime scale, and keeps create and update honest with each other. See #70.
|
||||
private static Validation<BaseError, Unit> ValidateWeights(CreateMultiCollection request) =>
|
||||
request.Items.All(i => MultiCollectionItemWeight.IsValid(i.Weight))
|
||||
? Unit.Default
|
||||
: BaseError.New(MultiCollectionItemWeight.ValidationMessage);
|
||||
});
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -73,35 +73,7 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
ReplacePlaylistItems request,
|
||||
CancellationToken cancellationToken) =>
|
||||
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
|
||||
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
|
||||
.BindT(playlist => ValidateName(request).Map(_ => playlist));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
|
||||
request.NotEmpty(x => x.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||
|
||||
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
|
||||
ReplacePlaylistItems request,
|
||||
Playlist playlist) =>
|
||||
request.Items
|
||||
.Map(item => PlaybackOrderMustBeSupported(item.PlaybackOrder))
|
||||
.Sequence()
|
||||
.Map(_ => playlist);
|
||||
|
||||
private static Validation<BaseError, Unit> PlaybackOrderMustBeSupported(PlaybackOrder playbackOrder)
|
||||
{
|
||||
// WeightedShuffle (#70) is implemented for classic schedule items only. PlaylistEnumerator has no
|
||||
// default arm, so an order it doesn't know leaves the enumerator null and the item is dropped from the
|
||||
// playlist silently -- refuse it at the write path instead of scheduling nothing at build time.
|
||||
if (playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for playlist items; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist));
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -7,8 +7,7 @@ public record UpdateMultiCollectionItem(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record UpdateMultiCollection(
|
||||
int MultiCollectionId,
|
||||
|
||||
@@ -76,8 +76,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
CollectionId = i.CollectionId.Value,
|
||||
MultiCollectionId = c.Id,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
})
|
||||
.ToList();
|
||||
var toRemove = c.MultiCollectionItems
|
||||
@@ -95,7 +94,6 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
{
|
||||
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
|
||||
item.PlaybackOrder = incoming.PlaybackOrder;
|
||||
item.Weight = incoming.Weight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +110,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
MultiCollectionId = c.Id,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
})
|
||||
.ToList();
|
||||
var toRemoveSmart = c.MultiCollectionSmartItems
|
||||
@@ -131,7 +128,6 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
{
|
||||
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
|
||||
item.PlaybackOrder = incoming.PlaybackOrder;
|
||||
item.Weight = incoming.Weight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,20 +156,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
TvContext dbContext,
|
||||
UpdateMultiCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await MultiCollectionMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
ValidateWeights(request))
|
||||
.Apply((collectionToUpdate, _, _) => collectionToUpdate);
|
||||
|
||||
// Bounds are shared with the create path so the two cannot drift -- they silently disagreed before #402:
|
||||
// EF's HasDefaultValue substitutes 1 for a 0 on INSERT (0 reads as "not set"), but an UPDATE writes the 0
|
||||
// through, so the same input landed differently depending on the verb. The enumerator clamps out-of-range
|
||||
// weights, so a 0 no longer removes the source; this gate is about refusing input that has no meaning on a
|
||||
// share-of-airtime scale, and about keeping create and update honest with each other. See #70.
|
||||
private static Validation<BaseError, Unit> ValidateWeights(UpdateMultiCollection request) =>
|
||||
request.Items.All(i => MultiCollectionItemWeight.IsValid(i.Weight))
|
||||
? Unit.Default
|
||||
: BaseError.New(MultiCollectionItemWeight.ValidationMessage);
|
||||
(await MultiCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -70,8 +70,7 @@ internal static class Mapper
|
||||
multiCollectionItem.MultiCollectionId,
|
||||
ProjectToViewModel(multiCollectionItem.Collection),
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Weight);
|
||||
multiCollectionItem.PlaybackOrder);
|
||||
|
||||
private static MultiCollectionSmartItemViewModel ProjectToViewModel(
|
||||
MultiCollectionSmartItem multiCollectionSmartItem) =>
|
||||
@@ -79,8 +78,7 @@ internal static class Mapper
|
||||
multiCollectionSmartItem.MultiCollectionId,
|
||||
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
|
||||
multiCollectionSmartItem.ScheduleAsGroup,
|
||||
multiCollectionSmartItem.PlaybackOrder,
|
||||
multiCollectionSmartItem.Weight);
|
||||
multiCollectionSmartItem.PlaybackOrder);
|
||||
|
||||
internal static TreeViewModel ProjectToViewModel(List<PlaylistGroup> playlistGroups) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
@@ -6,5 +6,4 @@ public record MultiCollectionItemViewModel(
|
||||
int MultiCollectionId,
|
||||
MediaCollectionViewModel Collection,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
@@ -6,5 +6,4 @@ public record MultiCollectionSmartItemViewModel(
|
||||
int MultiCollectionId,
|
||||
SmartCollectionViewModel SmartCollection,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
@@ -17,7 +17,6 @@ public class GetAllMultiCollectionsHandler : IRequestHandler<GetAllMultiCollecti
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.MultiCollections
|
||||
.Where(mc => mc.OwnedByChannelId == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
+1
-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))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -63,9 +63,6 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
||||
case PlaybackOrder.Shuffle:
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
// WeightedShuffle (#70) distributes across a multi collection's sources, so this is its
|
||||
// intended home. Listed explicitly rather than falling through the switch by omission.
|
||||
case PlaybackOrder.WeightedShuffle:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,30 +112,7 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
BlockMustExist(dbContext, request.BlockId, cancellationToken)
|
||||
.BindT(block => MinutesMustBeValid(request, block))
|
||||
.BindT(block => BlockNameMustBeValid(dbContext, block, request))
|
||||
.BindT(block => CollectionTypesMustBeValid(request, block))
|
||||
.BindT(block => PlaybackOrdersMustBeSupported(request, block));
|
||||
|
||||
private static Validation<BaseError, Block> PlaybackOrdersMustBeSupported(
|
||||
ReplaceBlockItems request,
|
||||
Block block) =>
|
||||
request.Items
|
||||
.Map(item => PlaybackOrderMustBeSupported(item.PlaybackOrder))
|
||||
.Sequence()
|
||||
.Map(_ => block);
|
||||
|
||||
private static Validation<BaseError, Unit> PlaybackOrderMustBeSupported(PlaybackOrder playbackOrder)
|
||||
{
|
||||
// WeightedShuffle (#70) is implemented for classic schedule items only. BlockPlayoutBuilder filters
|
||||
// block items against an allow-list of orders and silently `continue`s past anything else, so an
|
||||
// unsupported order here means the block item never airs and nothing reports why.
|
||||
if (playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for block items; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
.BindT(block => CollectionTypesMustBeValid(request, block));
|
||||
|
||||
private static Task<Validation<BaseError, Block>> BlockMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries;
|
||||
|
||||
public record GetSearchFieldCatalog : IRequest<List<SearchFieldResponseModel>>;
|
||||
@@ -1,11 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries;
|
||||
|
||||
public class GetSearchFieldCatalogHandler : IRequestHandler<GetSearchFieldCatalog, List<SearchFieldResponseModel>>
|
||||
{
|
||||
public Task<List<SearchFieldResponseModel>> Handle(
|
||||
GetSearchFieldCatalog request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(SearchFieldCatalog.Fields);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record QuerySearchIndexAllItems(string Query, int PageNum, int PageSize)
|
||||
: IRequest<SearchResultAllItemsViewModel>;
|
||||
public record QuerySearchIndexAllItems(string Query) : IRequest<SearchResultAllItemsViewModel>;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
@@ -9,69 +8,21 @@ public class QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex)
|
||||
{
|
||||
public async Task<SearchResultAllItemsViewModel> Handle(
|
||||
QuerySearchIndexAllItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int skip = request.PageNum * request.PageSize;
|
||||
int limit = request.PageSize;
|
||||
CancellationToken cancellationToken) =>
|
||||
new(
|
||||
await GetIds(LuceneSearchIndex.MovieType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.ShowType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.SeasonType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.EpisodeType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.ArtistType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.OtherVideoType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.SongType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.ImageType, request.Query, cancellationToken),
|
||||
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, cancellationToken));
|
||||
|
||||
(List<int> Ids, int Total) movies =
|
||||
await GetIds(LuceneSearchIndex.MovieType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) shows =
|
||||
await GetIds(LuceneSearchIndex.ShowType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) seasons =
|
||||
await GetIds(LuceneSearchIndex.SeasonType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) episodes =
|
||||
await GetIds(LuceneSearchIndex.EpisodeType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) artists =
|
||||
await GetIds(LuceneSearchIndex.ArtistType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) musicVideos =
|
||||
await GetIds(LuceneSearchIndex.MusicVideoType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) otherVideos =
|
||||
await GetIds(LuceneSearchIndex.OtherVideoType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) songs =
|
||||
await GetIds(LuceneSearchIndex.SongType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) images =
|
||||
await GetIds(LuceneSearchIndex.ImageType, request.Query, skip, limit, cancellationToken);
|
||||
(List<int> Ids, int Total) remoteStreams =
|
||||
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, skip, limit, cancellationToken);
|
||||
|
||||
return new SearchResultAllItemsViewModel(
|
||||
movies.Ids,
|
||||
shows.Ids,
|
||||
seasons.Ids,
|
||||
episodes.Ids,
|
||||
artists.Ids,
|
||||
musicVideos.Ids,
|
||||
otherVideos.Ids,
|
||||
songs.Ids,
|
||||
images.Ids,
|
||||
remoteStreams.Ids,
|
||||
new SearchResultAllItemsTotals(
|
||||
movies.Total,
|
||||
shows.Total,
|
||||
seasons.Total,
|
||||
episodes.Total,
|
||||
artists.Total,
|
||||
musicVideos.Total,
|
||||
otherVideos.Total,
|
||||
songs.Total,
|
||||
images.Total,
|
||||
remoteStreams.Total));
|
||||
}
|
||||
|
||||
private async Task<(List<int> Ids, int Total)> GetIds(
|
||||
string type,
|
||||
string query,
|
||||
int skip,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult result = await searchIndex.Search(
|
||||
$"type:{type} AND ({query})",
|
||||
string.Empty,
|
||||
skip,
|
||||
limit,
|
||||
cancellationToken);
|
||||
return (result.Items.Map(i => i.Id).ToList(), result.TotalCount);
|
||||
}
|
||||
private async Task<List<int>> GetIds(string type, string query, CancellationToken cancellationToken) =>
|
||||
(await searchIndex.Search($"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@ public class SearchMultiCollectionsHandler(IDbContextFactory<TvContext> dbContex
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.MultiCollections
|
||||
.AsNoTracking()
|
||||
// Hide system-owned auto-tune weighted artifacts (#425) from the scheduling picker: selecting one
|
||||
// into a user schedule would let a later channel delete cascade away that schedule item.
|
||||
.Where(mc => mc.OwnedByChannelId == null)
|
||||
.Where(mc => EF.Functions.Like(mc.Name, $"%{request.Query}%"))
|
||||
.OrderBy(mc => mc.Name)
|
||||
.Take(10)
|
||||
|
||||
@@ -15,9 +15,6 @@ public class SearchSmartCollectionsHandler(IDbContextFactory<TvContext> dbContex
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.SmartCollections
|
||||
.AsNoTracking()
|
||||
// Hide system-owned auto-tune weighted artifacts (#425) from the scheduling picker: selecting one
|
||||
// into a user schedule would let a later channel delete cascade away that schedule item.
|
||||
.Where(sc => sc.OwnedByChannelId == null)
|
||||
.Where(sc => EF.Functions.Like(sc.Name, $"%{request.Query}%"))
|
||||
.OrderBy(sc => sc.Name)
|
||||
.Take(10)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public static class SearchFieldCatalog
|
||||
{
|
||||
private static readonly string[] None = [];
|
||||
|
||||
// Allowed values for the `type` enum — mirrors the lowercase tokens the Lucene index stores for
|
||||
// the `type` field (see LuceneSearchIndex.TypeField and its *Type constants).
|
||||
private static readonly string[] ItemTypes =
|
||||
[
|
||||
"movie", "show", "season", "episode", "artist", "music_video", "other_video", "song", "image",
|
||||
"remote_stream"
|
||||
];
|
||||
|
||||
public static readonly List<SearchFieldResponseModel> Fields =
|
||||
[
|
||||
// General
|
||||
new("title", "Title", "text", "General", None),
|
||||
new("genre", "Genre", "text", "General", None),
|
||||
new("tag", "Tag", "text", "General", None),
|
||||
new("plot", "Plot", "fulltext", "General", None),
|
||||
new("content_rating", "Content rating", "text", "General", None),
|
||||
new("studio", "Studio", "text", "General", None),
|
||||
new("collection", "Collection", "text", "General", None),
|
||||
new("state", "State", "text", "General", None),
|
||||
new("type", "Item type", "enum", "General", ItemTypes),
|
||||
|
||||
// TV
|
||||
new("network", "Network", "text", "TV", None),
|
||||
new("show_title", "Show title", "text", "TV", None),
|
||||
new("show_genre", "Show genre", "text", "TV", None),
|
||||
new("season_number", "Season number", "number", "TV", None),
|
||||
new("episode_number", "Episode number", "number", "TV", None),
|
||||
|
||||
// Movie / People
|
||||
new("director", "Director", "text", "Movie", None),
|
||||
new("writer", "Writer", "text", "Movie", None),
|
||||
new("actor", "Actor", "text", "Movie", None),
|
||||
|
||||
// Music
|
||||
new("artist", "Artist", "text", "Music", None),
|
||||
new("album", "Album", "text", "Music", None),
|
||||
new("album_artist", "Album artist", "text", "Music", None),
|
||||
|
||||
// Technical
|
||||
new("minutes", "Duration (min)", "number", "Technical", None),
|
||||
new("height", "Height (px)", "number", "Technical", None),
|
||||
new("width", "Width (px)", "number", "Technical", None),
|
||||
new("video_codec", "Video codec", "text", "Technical", None),
|
||||
new("video_dynamic_range", "Dynamic range", "text", "Technical", None),
|
||||
|
||||
// Dates
|
||||
new("added_date", "Date added", "date", "Dates", None),
|
||||
new("release_date", "Release date", "date", "Dates", None)
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record SearchResultAllItemsViewModel(
|
||||
List<int> MovieIds,
|
||||
@@ -10,17 +10,4 @@ public record SearchResultAllItemsViewModel(
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds,
|
||||
List<int> RemoteStreamIds,
|
||||
SearchResultAllItemsTotals Totals);
|
||||
|
||||
public record SearchResultAllItemsTotals(
|
||||
int MovieCount,
|
||||
int ShowCount,
|
||||
int SeasonCount,
|
||||
int EpisodeCount,
|
||||
int ArtistCount,
|
||||
int MusicVideoCount,
|
||||
int OtherVideoCount,
|
||||
int SongCount,
|
||||
int ImageCount,
|
||||
int RemoteStreamCount);
|
||||
List<int> RemoteStreamIds);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Graphics;
|
||||
@@ -76,10 +75,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
|
||||
private async Task<Unit> StartProcess(StartFFmpegSession request, CancellationToken cancellationToken)
|
||||
{
|
||||
// measures the full client-visible cold-start: this handler only runs when the session
|
||||
// is not already active, so its whole duration is the tune-in delay the client waits on
|
||||
var coldStartStopwatch = Stopwatch.StartNew();
|
||||
|
||||
Option<TimeSpan> idleTimeout = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken)
|
||||
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
|
||||
@@ -119,34 +114,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken)
|
||||
.Map(maybeCount => maybeCount.Match(identity, () => 1));
|
||||
|
||||
PlaylistSegmentsResult segments = await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
|
||||
coldStartStopwatch.Stop();
|
||||
|
||||
// #350 cold-start instrumentation: one self-describing sample per tune-in so the real
|
||||
// driver split (process startup vs segment fill, and which heavy features were active)
|
||||
// can be measured on prod before any transcode-pipeline optimization.
|
||||
// "setup" is the pre-wait handler overhead (config reads + framerate/channel/graphics
|
||||
// mediator sends + worker spawn) so total = setup + startup + fill accounts for every ms.
|
||||
long totalMs = (long)coldStartStopwatch.Elapsed.TotalMilliseconds;
|
||||
long startupMs = (long)segments.ProcessStartup.TotalMilliseconds;
|
||||
long fillMs = (long)segments.SegmentFill.TotalMilliseconds;
|
||||
long setupMs = Math.Max(0, totalMs - startupMs - fillMs);
|
||||
_logger.LogInformation(
|
||||
"HLS cold-start channel {Channel} mode {Mode}: total {TotalMs}ms " +
|
||||
"(setup {SetupMs}ms + startup {ProcessStartupMs}ms + fill {SegmentFillMs}ms), " +
|
||||
"segments {SegmentsReached}/{InitialSegmentCount}, " +
|
||||
"deadlineExpired {DeadlineExpired}, subtitleBurnIn {SubtitleBurnIn}, hwaccel {HwAccel}",
|
||||
request.ChannelNumber,
|
||||
request.Mode,
|
||||
totalMs,
|
||||
setupMs,
|
||||
startupMs,
|
||||
fillMs,
|
||||
segments.SegmentsReached,
|
||||
segments.InitialSegmentCount,
|
||||
segments.DeadlineExpired,
|
||||
segments.Features.SubtitleBurnIn,
|
||||
segments.Features.HardwareAcceleration);
|
||||
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
@@ -55,12 +55,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private string _workingDirectory;
|
||||
private Option<double> _slugSeconds;
|
||||
|
||||
// The arguments of the first ffmpeg process launched for this session, captured for
|
||||
// cold-start telemetry (#350). Written once on the sequential Run loop, read on the
|
||||
// handler thread from WaitForPlaylistSegments after segments exist (a happens-before:
|
||||
// segments cannot exist until this process ran) — volatile for cross-thread visibility.
|
||||
private volatile string _coldStartFFmpegArguments;
|
||||
|
||||
public HlsSessionWorker(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
@@ -306,20 +300,17 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlaylistSegmentsResult> WaitForPlaylistSegments(
|
||||
public async Task WaitForPlaylistSegments(
|
||||
int initialSegmentCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Waiting for playlist segments...");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var processStartup = TimeSpan.Zero;
|
||||
var segmentCount = 0;
|
||||
try
|
||||
{
|
||||
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
|
||||
|
||||
// Phase A: ffmpeg process spawn -> playlist file exists (startup + probe + init + first GOP)
|
||||
_logger.LogDebug("Waiting for playlist to exist");
|
||||
while (!_fileSystem.File.Exists(playlistFileName))
|
||||
{
|
||||
@@ -327,13 +318,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
|
||||
_logger.LogDebug("Playlist exists");
|
||||
processStartup = sw.Elapsed;
|
||||
|
||||
// start the segment-wait deadline only after the playlist file appears,
|
||||
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
|
||||
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
|
||||
|
||||
// Phase B: playlist exists -> the requested number of segments are present (or deadline)
|
||||
var segmentCount = 0;
|
||||
int lastSegmentCount = -1;
|
||||
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
|
||||
{
|
||||
@@ -355,14 +345,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
segmentCount = result.SegmentCount;
|
||||
}
|
||||
}
|
||||
|
||||
return new PlaylistSegmentsResult(
|
||||
processStartup,
|
||||
sw.Elapsed - processStartup,
|
||||
segmentCount,
|
||||
initialSegmentCount,
|
||||
segmentCount < initialSegmentCount,
|
||||
ColdStartFeatures.FromFFmpegArguments(_coldStartFFmpegArguments));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -551,9 +533,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
Command process = processModel.Process;
|
||||
|
||||
// capture the first process's arguments once for cold-start telemetry (#350)
|
||||
_coldStartFFmpegArguments ??= process.Arguments;
|
||||
|
||||
_logger.LogDebug("ffmpeg hls arguments {FFmpegArguments}", process.Arguments);
|
||||
|
||||
try
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Channels;
|
||||
|
||||
// Issue #77 (XMLTV half): the clock-boundary Pad machinery snaps content to :00/:15/:30/:45 by emitting
|
||||
// trailing PostRoll filler up to the boundary (proven at the builder level by
|
||||
// PlayoutBuildGoldenTests.Classic_clock_padded). This test locks the OTHER half: that the guide
|
||||
// projection — the single source of truth shared by the XMLTV cache builder and the JSON guide query —
|
||||
// coalesces that trailing filler into the programme window, so each guide programme STOPS on the padded
|
||||
// clock boundary. Together the two tests cover the full chain: schedule -> PlayoutItems -> guide.
|
||||
[TestFixture]
|
||||
public class ChannelGuideProjectorClockPadTests
|
||||
{
|
||||
// Deterministic UTC instants mirroring the Classic_clock_padded golden shape: off-boundary content
|
||||
// (22/37/37-min) each followed by a single PostRoll filler run that ends exactly on the next :15 mark.
|
||||
private static readonly DateTime Anchor = new(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Test]
|
||||
public void Padded_programmes_stop_on_quarter_hour_boundary()
|
||||
{
|
||||
var items = new List<PlayoutItem>
|
||||
{
|
||||
// Programme 1: content 00:00-00:22, padded to 00:30 with PostRoll filler.
|
||||
Content(guideGroup: 1, start: T(0, 0), finish: T(0, 22), title: "Movie 01"),
|
||||
Pad(guideGroup: 1, start: T(0, 22), finish: T(0, 30)),
|
||||
|
||||
// Programme 2: content 00:30-01:07, padded to 01:15.
|
||||
Content(guideGroup: 2, start: T(0, 30), finish: T(1, 7), title: "Movie 02"),
|
||||
Pad(guideGroup: 2, start: T(1, 7), finish: T(1, 15)),
|
||||
|
||||
// Programme 3: content 01:15-01:52, padded to 02:00.
|
||||
Content(guideGroup: 3, start: T(1, 15), finish: T(1, 52), title: "Movie 03"),
|
||||
Pad(guideGroup: 3, start: T(1, 52), finish: T(2, 0))
|
||||
};
|
||||
|
||||
List<ChannelGuideEntry> entries = ChannelGuideProjector
|
||||
.Project(PlayoutScheduleKind.Classic, items, XmltvTimeZone.Utc, XmltvBlockBehavior.UseActualTimes)
|
||||
.ToList();
|
||||
|
||||
// One programme per content item; trailing filler is coalesced in, not surfaced as its own entry.
|
||||
entries.Count.ShouldBe(3);
|
||||
entries.ShouldAllBe(e => e.DisplayItem.FillerKind == FillerKind.None);
|
||||
|
||||
// The #77 invariant: every programme STOPS on a :15 clock boundary (the padded target), and the
|
||||
// 2nd/3rd programmes also START on one (the previous programme padded up to it).
|
||||
foreach (ChannelGuideEntry entry in entries)
|
||||
{
|
||||
(entry.Stop.Minute % 15).ShouldBe(0, $"programme '{entry.DisplayItem.CustomTitle}' stops off-boundary at {entry.Stop:HH:mm:ss}");
|
||||
entry.Stop.Second.ShouldBe(0);
|
||||
}
|
||||
|
||||
entries[0].Stop.ShouldBe(new DateTimeOffset(T(0, 30), TimeSpan.Zero));
|
||||
entries[1].Start.ShouldBe(new DateTimeOffset(T(0, 30), TimeSpan.Zero));
|
||||
entries[1].Stop.ShouldBe(new DateTimeOffset(T(1, 15), TimeSpan.Zero));
|
||||
entries[2].Start.ShouldBe(new DateTimeOffset(T(1, 15), TimeSpan.Zero));
|
||||
entries[2].Stop.ShouldBe(new DateTimeOffset(T(2, 0), TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private static DateTime T(int hour, int minute) => Anchor.AddHours(hour).AddMinutes(minute);
|
||||
|
||||
private static PlayoutItem Content(int guideGroup, DateTime start, DateTime finish, string title) =>
|
||||
new()
|
||||
{
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
FillerKind = FillerKind.None,
|
||||
GuideGroup = guideGroup,
|
||||
CustomTitle = title
|
||||
};
|
||||
|
||||
private static PlayoutItem Pad(int guideGroup, DateTime start, DateTime finish) =>
|
||||
new()
|
||||
{
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
GuideGroup = guideGroup
|
||||
};
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class ColdStartFeaturesTests
|
||||
{
|
||||
[Test]
|
||||
public void Should_Detect_Subtitle_BurnIn_From_Subtitles_Filter()
|
||||
{
|
||||
const string Args =
|
||||
"-i ep.mkv -filter_complex \"[0:0]subtitles=http://localhost:8409/media/subtitle/1.ass[v]\" " +
|
||||
"-c:v h264_vaapi -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Flag_Subtitle_BurnIn_For_Copy_Or_Overlay()
|
||||
{
|
||||
// subtitle stream copy (-c:s) and a watermark/image-subtitle overlay must NOT be read as burn-in
|
||||
const string Args = "-i ep.mkv -filter_complex \"[v][wm]overlay=10:10:format=0[vwm]\" -c:s copy -c:v libx264 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestCase("-c:v h264_vaapi -f hls", "vaapi")]
|
||||
[TestCase("-c:v hevc_vaapi -f hls", "vaapi")]
|
||||
[TestCase("-c:v h264_nvenc -f hls", "nvenc")]
|
||||
[TestCase("-c:v h264_qsv -f hls", "qsv")]
|
||||
[TestCase("-c:v h264_videotoolbox -f hls", "videotoolbox")]
|
||||
[TestCase("-c:v h264_amf -f hls", "amf")]
|
||||
public void Should_Detect_Hardware_Family_From_Encoder(string args, string expected)
|
||||
{
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Detect_Hardware_Family_From_Vaapi_Filter_Even_Without_Encoder_Token()
|
||||
{
|
||||
// a vaapi-accelerated pipeline surfaces the family via filters (e.g. scale_vaapi) too
|
||||
const string Args = "-hwaccel vaapi -i ep.mkv -vf scale_vaapi=1920:1080 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe("vaapi");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fall_Back_To_Hwaccel_Flag_When_No_Hardware_Encoder_Or_Filter()
|
||||
{
|
||||
// hardware decode, software encode: attribute the decode accel
|
||||
const string Args = "-hwaccel cuda -i ep.mkv -c:v libx264 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe("cuda");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Report_Software_When_No_Acceleration()
|
||||
{
|
||||
const string Args = "-i ep.mkv -c:v libx264 -c:a aac -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
features.HardwareAcceleration.ShouldBe("software");
|
||||
}
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
[TestCase(null)]
|
||||
public void Should_Report_Unknown_For_Empty_Arguments(string args)
|
||||
{
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
features.HardwareAcceleration.ShouldBe("unknown");
|
||||
}
|
||||
}
|
||||
@@ -187,47 +187,6 @@ public class ChannelGuideGoldenTests
|
||||
xml.ShouldNotContain("a&b");
|
||||
}
|
||||
|
||||
// The access-token value is HTTP-request-derived (?access_token=) and interpolated raw into the
|
||||
// {AccessTokenUri} placeholder, so a token containing XML-special chars must be escaped too —
|
||||
// otherwise it malforms the whole guide, exactly like the {RequestBase} case above. (Finding #376.)
|
||||
[Test]
|
||||
public async Task Guide_xml_escapes_access_token()
|
||||
{
|
||||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
localFileSystem
|
||||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||||
.Returns(new[]
|
||||
{
|
||||
FragmentPath(fileSystem, "channels.xml"),
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.None);
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: "tok&<>\""),
|
||||
CancellationToken.None);
|
||||
|
||||
string xml = result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// Every XML-special char in the token must be escaped; the raw token must never reach the output.
|
||||
xml.ShouldContain("access_token=tok&<>"");
|
||||
xml.ShouldNotContain("access_token=tok&<");
|
||||
}
|
||||
|
||||
// --- harness ---
|
||||
|
||||
private async Task Verify(string goldenName, GetChannelGuide request)
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Padded Movie 01
|
||||
001 | 2026-01-15 00:22:00 - 2026-01-15 00:23:00 | PostRoll | Filler Clip
|
||||
002 | 2026-01-15 00:23:00 - 2026-01-15 00:24:00 | PostRoll | Filler Clip
|
||||
003 | 2026-01-15 00:24:00 - 2026-01-15 00:25:00 | PostRoll | Filler Clip
|
||||
004 | 2026-01-15 00:25:00 - 2026-01-15 00:26:00 | PostRoll | Filler Clip
|
||||
005 | 2026-01-15 00:26:00 - 2026-01-15 00:27:00 | PostRoll | Filler Clip
|
||||
006 | 2026-01-15 00:27:00 - 2026-01-15 00:28:00 | PostRoll | Filler Clip
|
||||
007 | 2026-01-15 00:28:00 - 2026-01-15 00:29:00 | PostRoll | Filler Clip
|
||||
008 | 2026-01-15 00:29:00 - 2026-01-15 00:30:00 | PostRoll | Filler Clip
|
||||
009 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Padded Movie 02
|
||||
010 | 2026-01-15 01:07:00 - 2026-01-15 01:08:00 | PostRoll | Filler Clip
|
||||
011 | 2026-01-15 01:08:00 - 2026-01-15 01:09:00 | PostRoll | Filler Clip
|
||||
012 | 2026-01-15 01:09:00 - 2026-01-15 01:10:00 | PostRoll | Filler Clip
|
||||
013 | 2026-01-15 01:10:00 - 2026-01-15 01:11:00 | PostRoll | Filler Clip
|
||||
014 | 2026-01-15 01:11:00 - 2026-01-15 01:12:00 | PostRoll | Filler Clip
|
||||
015 | 2026-01-15 01:12:00 - 2026-01-15 01:13:00 | PostRoll | Filler Clip
|
||||
016 | 2026-01-15 01:13:00 - 2026-01-15 01:14:00 | PostRoll | Filler Clip
|
||||
017 | 2026-01-15 01:14:00 - 2026-01-15 01:15:00 | PostRoll | Filler Clip
|
||||
018 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Padded Movie 03
|
||||
019 | 2026-01-15 02:07:00 - 2026-01-15 02:08:00 | PostRoll | Filler Clip
|
||||
020 | 2026-01-15 02:08:00 - 2026-01-15 02:09:00 | PostRoll | Filler Clip
|
||||
021 | 2026-01-15 02:09:00 - 2026-01-15 02:10:00 | PostRoll | Filler Clip
|
||||
022 | 2026-01-15 02:10:00 - 2026-01-15 02:11:00 | PostRoll | Filler Clip
|
||||
023 | 2026-01-15 02:11:00 - 2026-01-15 02:12:00 | PostRoll | Filler Clip
|
||||
024 | 2026-01-15 02:12:00 - 2026-01-15 02:13:00 | PostRoll | Filler Clip
|
||||
025 | 2026-01-15 02:13:00 - 2026-01-15 02:14:00 | PostRoll | Filler Clip
|
||||
026 | 2026-01-15 02:14:00 - 2026-01-15 02:15:00 | PostRoll | Filler Clip
|
||||
027 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Padded Movie 01
|
||||
028 | 2026-01-15 02:37:00 - 2026-01-15 02:38:00 | PostRoll | Filler Clip
|
||||
029 | 2026-01-15 02:38:00 - 2026-01-15 02:39:00 | PostRoll | Filler Clip
|
||||
030 | 2026-01-15 02:39:00 - 2026-01-15 02:40:00 | PostRoll | Filler Clip
|
||||
031 | 2026-01-15 02:40:00 - 2026-01-15 02:41:00 | PostRoll | Filler Clip
|
||||
032 | 2026-01-15 02:41:00 - 2026-01-15 02:42:00 | PostRoll | Filler Clip
|
||||
033 | 2026-01-15 02:42:00 - 2026-01-15 02:43:00 | PostRoll | Filler Clip
|
||||
034 | 2026-01-15 02:43:00 - 2026-01-15 02:44:00 | PostRoll | Filler Clip
|
||||
035 | 2026-01-15 02:44:00 - 2026-01-15 02:45:00 | PostRoll | Filler Clip
|
||||
036 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Padded Movie 02
|
||||
037 | 2026-01-15 03:22:00 - 2026-01-15 03:23:00 | PostRoll | Filler Clip
|
||||
038 | 2026-01-15 03:23:00 - 2026-01-15 03:24:00 | PostRoll | Filler Clip
|
||||
039 | 2026-01-15 03:24:00 - 2026-01-15 03:25:00 | PostRoll | Filler Clip
|
||||
040 | 2026-01-15 03:25:00 - 2026-01-15 03:26:00 | PostRoll | Filler Clip
|
||||
041 | 2026-01-15 03:26:00 - 2026-01-15 03:27:00 | PostRoll | Filler Clip
|
||||
042 | 2026-01-15 03:27:00 - 2026-01-15 03:28:00 | PostRoll | Filler Clip
|
||||
043 | 2026-01-15 03:28:00 - 2026-01-15 03:29:00 | PostRoll | Filler Clip
|
||||
044 | 2026-01-15 03:29:00 - 2026-01-15 03:30:00 | PostRoll | Filler Clip
|
||||
045 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Padded Movie 03
|
||||
046 | 2026-01-15 04:22:00 - 2026-01-15 04:23:00 | PostRoll | Filler Clip
|
||||
047 | 2026-01-15 04:23:00 - 2026-01-15 04:24:00 | PostRoll | Filler Clip
|
||||
048 | 2026-01-15 04:24:00 - 2026-01-15 04:25:00 | PostRoll | Filler Clip
|
||||
049 | 2026-01-15 04:25:00 - 2026-01-15 04:26:00 | PostRoll | Filler Clip
|
||||
050 | 2026-01-15 04:26:00 - 2026-01-15 04:27:00 | PostRoll | Filler Clip
|
||||
051 | 2026-01-15 04:27:00 - 2026-01-15 04:28:00 | PostRoll | Filler Clip
|
||||
052 | 2026-01-15 04:28:00 - 2026-01-15 04:29:00 | PostRoll | Filler Clip
|
||||
053 | 2026-01-15 04:29:00 - 2026-01-15 04:30:00 | PostRoll | Filler Clip
|
||||
054 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Padded Movie 01
|
||||
055 | 2026-01-15 04:52:00 - 2026-01-15 04:53:00 | PostRoll | Filler Clip
|
||||
056 | 2026-01-15 04:53:00 - 2026-01-15 04:54:00 | PostRoll | Filler Clip
|
||||
057 | 2026-01-15 04:54:00 - 2026-01-15 04:55:00 | PostRoll | Filler Clip
|
||||
058 | 2026-01-15 04:55:00 - 2026-01-15 04:56:00 | PostRoll | Filler Clip
|
||||
059 | 2026-01-15 04:56:00 - 2026-01-15 04:57:00 | PostRoll | Filler Clip
|
||||
060 | 2026-01-15 04:57:00 - 2026-01-15 04:58:00 | PostRoll | Filler Clip
|
||||
061 | 2026-01-15 04:58:00 - 2026-01-15 04:59:00 | PostRoll | Filler Clip
|
||||
062 | 2026-01-15 04:59:00 - 2026-01-15 05:00:00 | PostRoll | Filler Clip
|
||||
063 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Padded Movie 02
|
||||
064 | 2026-01-15 05:37:00 - 2026-01-15 05:38:00 | PostRoll | Filler Clip
|
||||
065 | 2026-01-15 05:38:00 - 2026-01-15 05:39:00 | PostRoll | Filler Clip
|
||||
066 | 2026-01-15 05:39:00 - 2026-01-15 05:40:00 | PostRoll | Filler Clip
|
||||
067 | 2026-01-15 05:40:00 - 2026-01-15 05:41:00 | PostRoll | Filler Clip
|
||||
068 | 2026-01-15 05:41:00 - 2026-01-15 05:42:00 | PostRoll | Filler Clip
|
||||
069 | 2026-01-15 05:42:00 - 2026-01-15 05:43:00 | PostRoll | Filler Clip
|
||||
070 | 2026-01-15 05:43:00 - 2026-01-15 05:44:00 | PostRoll | Filler Clip
|
||||
071 | 2026-01-15 05:44:00 - 2026-01-15 05:45:00 | PostRoll | Filler Clip
|
||||
072 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Padded Movie 03
|
||||
073 | 2026-01-15 06:37:00 - 2026-01-15 06:38:00 | PostRoll | Filler Clip
|
||||
074 | 2026-01-15 06:38:00 - 2026-01-15 06:39:00 | PostRoll | Filler Clip
|
||||
075 | 2026-01-15 06:39:00 - 2026-01-15 06:40:00 | PostRoll | Filler Clip
|
||||
076 | 2026-01-15 06:40:00 - 2026-01-15 06:41:00 | PostRoll | Filler Clip
|
||||
077 | 2026-01-15 06:41:00 - 2026-01-15 06:42:00 | PostRoll | Filler Clip
|
||||
078 | 2026-01-15 06:42:00 - 2026-01-15 06:43:00 | PostRoll | Filler Clip
|
||||
079 | 2026-01-15 06:43:00 - 2026-01-15 06:44:00 | PostRoll | Filler Clip
|
||||
080 | 2026-01-15 06:44:00 - 2026-01-15 06:45:00 | PostRoll | Filler Clip
|
||||
081 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Padded Movie 01
|
||||
082 | 2026-01-15 07:07:00 - 2026-01-15 07:08:00 | PostRoll | Filler Clip
|
||||
083 | 2026-01-15 07:08:00 - 2026-01-15 07:09:00 | PostRoll | Filler Clip
|
||||
084 | 2026-01-15 07:09:00 - 2026-01-15 07:10:00 | PostRoll | Filler Clip
|
||||
085 | 2026-01-15 07:10:00 - 2026-01-15 07:11:00 | PostRoll | Filler Clip
|
||||
086 | 2026-01-15 07:11:00 - 2026-01-15 07:12:00 | PostRoll | Filler Clip
|
||||
087 | 2026-01-15 07:12:00 - 2026-01-15 07:13:00 | PostRoll | Filler Clip
|
||||
088 | 2026-01-15 07:13:00 - 2026-01-15 07:14:00 | PostRoll | Filler Clip
|
||||
089 | 2026-01-15 07:14:00 - 2026-01-15 07:15:00 | PostRoll | Filler Clip
|
||||
090 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Padded Movie 02
|
||||
091 | 2026-01-15 07:52:00 - 2026-01-15 07:53:00 | PostRoll | Filler Clip
|
||||
092 | 2026-01-15 07:53:00 - 2026-01-15 07:54:00 | PostRoll | Filler Clip
|
||||
093 | 2026-01-15 07:54:00 - 2026-01-15 07:55:00 | PostRoll | Filler Clip
|
||||
094 | 2026-01-15 07:55:00 - 2026-01-15 07:56:00 | PostRoll | Filler Clip
|
||||
095 | 2026-01-15 07:56:00 - 2026-01-15 07:57:00 | PostRoll | Filler Clip
|
||||
096 | 2026-01-15 07:57:00 - 2026-01-15 07:58:00 | PostRoll | Filler Clip
|
||||
097 | 2026-01-15 07:58:00 - 2026-01-15 07:59:00 | PostRoll | Filler Clip
|
||||
098 | 2026-01-15 07:59:00 - 2026-01-15 08:00:00 | PostRoll | Filler Clip
|
||||
099 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Padded Movie 03
|
||||
100 | 2026-01-15 08:52:00 - 2026-01-15 08:53:00 | PostRoll | Filler Clip
|
||||
101 | 2026-01-15 08:53:00 - 2026-01-15 08:54:00 | PostRoll | Filler Clip
|
||||
102 | 2026-01-15 08:54:00 - 2026-01-15 08:55:00 | PostRoll | Filler Clip
|
||||
103 | 2026-01-15 08:55:00 - 2026-01-15 08:56:00 | PostRoll | Filler Clip
|
||||
104 | 2026-01-15 08:56:00 - 2026-01-15 08:57:00 | PostRoll | Filler Clip
|
||||
105 | 2026-01-15 08:57:00 - 2026-01-15 08:58:00 | PostRoll | Filler Clip
|
||||
106 | 2026-01-15 08:58:00 - 2026-01-15 08:59:00 | PostRoll | Filler Clip
|
||||
107 | 2026-01-15 08:59:00 - 2026-01-15 09:00:00 | PostRoll | Filler Clip
|
||||
108 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Padded Movie 01
|
||||
109 | 2026-01-15 09:22:00 - 2026-01-15 09:23:00 | PostRoll | Filler Clip
|
||||
110 | 2026-01-15 09:23:00 - 2026-01-15 09:24:00 | PostRoll | Filler Clip
|
||||
111 | 2026-01-15 09:24:00 - 2026-01-15 09:25:00 | PostRoll | Filler Clip
|
||||
112 | 2026-01-15 09:25:00 - 2026-01-15 09:26:00 | PostRoll | Filler Clip
|
||||
113 | 2026-01-15 09:26:00 - 2026-01-15 09:27:00 | PostRoll | Filler Clip
|
||||
114 | 2026-01-15 09:27:00 - 2026-01-15 09:28:00 | PostRoll | Filler Clip
|
||||
115 | 2026-01-15 09:28:00 - 2026-01-15 09:29:00 | PostRoll | Filler Clip
|
||||
116 | 2026-01-15 09:29:00 - 2026-01-15 09:30:00 | PostRoll | Filler Clip
|
||||
117 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Padded Movie 02
|
||||
118 | 2026-01-15 10:07:00 - 2026-01-15 10:08:00 | PostRoll | Filler Clip
|
||||
119 | 2026-01-15 10:08:00 - 2026-01-15 10:09:00 | PostRoll | Filler Clip
|
||||
120 | 2026-01-15 10:09:00 - 2026-01-15 10:10:00 | PostRoll | Filler Clip
|
||||
121 | 2026-01-15 10:10:00 - 2026-01-15 10:11:00 | PostRoll | Filler Clip
|
||||
122 | 2026-01-15 10:11:00 - 2026-01-15 10:12:00 | PostRoll | Filler Clip
|
||||
123 | 2026-01-15 10:12:00 - 2026-01-15 10:13:00 | PostRoll | Filler Clip
|
||||
124 | 2026-01-15 10:13:00 - 2026-01-15 10:14:00 | PostRoll | Filler Clip
|
||||
125 | 2026-01-15 10:14:00 - 2026-01-15 10:15:00 | PostRoll | Filler Clip
|
||||
126 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Padded Movie 03
|
||||
127 | 2026-01-15 11:07:00 - 2026-01-15 11:08:00 | PostRoll | Filler Clip
|
||||
128 | 2026-01-15 11:08:00 - 2026-01-15 11:09:00 | PostRoll | Filler Clip
|
||||
129 | 2026-01-15 11:09:00 - 2026-01-15 11:10:00 | PostRoll | Filler Clip
|
||||
130 | 2026-01-15 11:10:00 - 2026-01-15 11:11:00 | PostRoll | Filler Clip
|
||||
131 | 2026-01-15 11:11:00 - 2026-01-15 11:12:00 | PostRoll | Filler Clip
|
||||
132 | 2026-01-15 11:12:00 - 2026-01-15 11:13:00 | PostRoll | Filler Clip
|
||||
133 | 2026-01-15 11:13:00 - 2026-01-15 11:14:00 | PostRoll | Filler Clip
|
||||
134 | 2026-01-15 11:14:00 - 2026-01-15 11:15:00 | PostRoll | Filler Clip
|
||||
135 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Padded Movie 01
|
||||
136 | 2026-01-15 11:37:00 - 2026-01-15 11:38:00 | PostRoll | Filler Clip
|
||||
137 | 2026-01-15 11:38:00 - 2026-01-15 11:39:00 | PostRoll | Filler Clip
|
||||
138 | 2026-01-15 11:39:00 - 2026-01-15 11:40:00 | PostRoll | Filler Clip
|
||||
139 | 2026-01-15 11:40:00 - 2026-01-15 11:41:00 | PostRoll | Filler Clip
|
||||
140 | 2026-01-15 11:41:00 - 2026-01-15 11:42:00 | PostRoll | Filler Clip
|
||||
141 | 2026-01-15 11:42:00 - 2026-01-15 11:43:00 | PostRoll | Filler Clip
|
||||
142 | 2026-01-15 11:43:00 - 2026-01-15 11:44:00 | PostRoll | Filler Clip
|
||||
143 | 2026-01-15 11:44:00 - 2026-01-15 11:45:00 | PostRoll | Filler Clip
|
||||
144 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Padded Movie 02
|
||||
145 | 2026-01-15 12:22:00 - 2026-01-15 12:23:00 | PostRoll | Filler Clip
|
||||
146 | 2026-01-15 12:23:00 - 2026-01-15 12:24:00 | PostRoll | Filler Clip
|
||||
147 | 2026-01-15 12:24:00 - 2026-01-15 12:25:00 | PostRoll | Filler Clip
|
||||
148 | 2026-01-15 12:25:00 - 2026-01-15 12:26:00 | PostRoll | Filler Clip
|
||||
149 | 2026-01-15 12:26:00 - 2026-01-15 12:27:00 | PostRoll | Filler Clip
|
||||
150 | 2026-01-15 12:27:00 - 2026-01-15 12:28:00 | PostRoll | Filler Clip
|
||||
151 | 2026-01-15 12:28:00 - 2026-01-15 12:29:00 | PostRoll | Filler Clip
|
||||
152 | 2026-01-15 12:29:00 - 2026-01-15 12:30:00 | PostRoll | Filler Clip
|
||||
153 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Padded Movie 03
|
||||
154 | 2026-01-15 13:22:00 - 2026-01-15 13:23:00 | PostRoll | Filler Clip
|
||||
155 | 2026-01-15 13:23:00 - 2026-01-15 13:24:00 | PostRoll | Filler Clip
|
||||
156 | 2026-01-15 13:24:00 - 2026-01-15 13:25:00 | PostRoll | Filler Clip
|
||||
157 | 2026-01-15 13:25:00 - 2026-01-15 13:26:00 | PostRoll | Filler Clip
|
||||
158 | 2026-01-15 13:26:00 - 2026-01-15 13:27:00 | PostRoll | Filler Clip
|
||||
159 | 2026-01-15 13:27:00 - 2026-01-15 13:28:00 | PostRoll | Filler Clip
|
||||
160 | 2026-01-15 13:28:00 - 2026-01-15 13:29:00 | PostRoll | Filler Clip
|
||||
161 | 2026-01-15 13:29:00 - 2026-01-15 13:30:00 | PostRoll | Filler Clip
|
||||
162 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Padded Movie 01
|
||||
163 | 2026-01-15 13:52:00 - 2026-01-15 13:53:00 | PostRoll | Filler Clip
|
||||
164 | 2026-01-15 13:53:00 - 2026-01-15 13:54:00 | PostRoll | Filler Clip
|
||||
165 | 2026-01-15 13:54:00 - 2026-01-15 13:55:00 | PostRoll | Filler Clip
|
||||
166 | 2026-01-15 13:55:00 - 2026-01-15 13:56:00 | PostRoll | Filler Clip
|
||||
167 | 2026-01-15 13:56:00 - 2026-01-15 13:57:00 | PostRoll | Filler Clip
|
||||
168 | 2026-01-15 13:57:00 - 2026-01-15 13:58:00 | PostRoll | Filler Clip
|
||||
169 | 2026-01-15 13:58:00 - 2026-01-15 13:59:00 | PostRoll | Filler Clip
|
||||
170 | 2026-01-15 13:59:00 - 2026-01-15 14:00:00 | PostRoll | Filler Clip
|
||||
171 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Padded Movie 02
|
||||
172 | 2026-01-15 14:37:00 - 2026-01-15 14:38:00 | PostRoll | Filler Clip
|
||||
173 | 2026-01-15 14:38:00 - 2026-01-15 14:39:00 | PostRoll | Filler Clip
|
||||
174 | 2026-01-15 14:39:00 - 2026-01-15 14:40:00 | PostRoll | Filler Clip
|
||||
175 | 2026-01-15 14:40:00 - 2026-01-15 14:41:00 | PostRoll | Filler Clip
|
||||
176 | 2026-01-15 14:41:00 - 2026-01-15 14:42:00 | PostRoll | Filler Clip
|
||||
177 | 2026-01-15 14:42:00 - 2026-01-15 14:43:00 | PostRoll | Filler Clip
|
||||
178 | 2026-01-15 14:43:00 - 2026-01-15 14:44:00 | PostRoll | Filler Clip
|
||||
179 | 2026-01-15 14:44:00 - 2026-01-15 14:45:00 | PostRoll | Filler Clip
|
||||
180 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Padded Movie 03
|
||||
181 | 2026-01-15 15:37:00 - 2026-01-15 15:38:00 | PostRoll | Filler Clip
|
||||
182 | 2026-01-15 15:38:00 - 2026-01-15 15:39:00 | PostRoll | Filler Clip
|
||||
183 | 2026-01-15 15:39:00 - 2026-01-15 15:40:00 | PostRoll | Filler Clip
|
||||
184 | 2026-01-15 15:40:00 - 2026-01-15 15:41:00 | PostRoll | Filler Clip
|
||||
185 | 2026-01-15 15:41:00 - 2026-01-15 15:42:00 | PostRoll | Filler Clip
|
||||
186 | 2026-01-15 15:42:00 - 2026-01-15 15:43:00 | PostRoll | Filler Clip
|
||||
187 | 2026-01-15 15:43:00 - 2026-01-15 15:44:00 | PostRoll | Filler Clip
|
||||
188 | 2026-01-15 15:44:00 - 2026-01-15 15:45:00 | PostRoll | Filler Clip
|
||||
189 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Padded Movie 01
|
||||
190 | 2026-01-15 16:07:00 - 2026-01-15 16:08:00 | PostRoll | Filler Clip
|
||||
191 | 2026-01-15 16:08:00 - 2026-01-15 16:09:00 | PostRoll | Filler Clip
|
||||
192 | 2026-01-15 16:09:00 - 2026-01-15 16:10:00 | PostRoll | Filler Clip
|
||||
193 | 2026-01-15 16:10:00 - 2026-01-15 16:11:00 | PostRoll | Filler Clip
|
||||
194 | 2026-01-15 16:11:00 - 2026-01-15 16:12:00 | PostRoll | Filler Clip
|
||||
195 | 2026-01-15 16:12:00 - 2026-01-15 16:13:00 | PostRoll | Filler Clip
|
||||
196 | 2026-01-15 16:13:00 - 2026-01-15 16:14:00 | PostRoll | Filler Clip
|
||||
197 | 2026-01-15 16:14:00 - 2026-01-15 16:15:00 | PostRoll | Filler Clip
|
||||
198 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Padded Movie 02
|
||||
199 | 2026-01-15 16:52:00 - 2026-01-15 16:53:00 | PostRoll | Filler Clip
|
||||
200 | 2026-01-15 16:53:00 - 2026-01-15 16:54:00 | PostRoll | Filler Clip
|
||||
201 | 2026-01-15 16:54:00 - 2026-01-15 16:55:00 | PostRoll | Filler Clip
|
||||
202 | 2026-01-15 16:55:00 - 2026-01-15 16:56:00 | PostRoll | Filler Clip
|
||||
203 | 2026-01-15 16:56:00 - 2026-01-15 16:57:00 | PostRoll | Filler Clip
|
||||
204 | 2026-01-15 16:57:00 - 2026-01-15 16:58:00 | PostRoll | Filler Clip
|
||||
205 | 2026-01-15 16:58:00 - 2026-01-15 16:59:00 | PostRoll | Filler Clip
|
||||
206 | 2026-01-15 16:59:00 - 2026-01-15 17:00:00 | PostRoll | Filler Clip
|
||||
207 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Padded Movie 03
|
||||
208 | 2026-01-15 17:52:00 - 2026-01-15 17:53:00 | PostRoll | Filler Clip
|
||||
209 | 2026-01-15 17:53:00 - 2026-01-15 17:54:00 | PostRoll | Filler Clip
|
||||
210 | 2026-01-15 17:54:00 - 2026-01-15 17:55:00 | PostRoll | Filler Clip
|
||||
211 | 2026-01-15 17:55:00 - 2026-01-15 17:56:00 | PostRoll | Filler Clip
|
||||
212 | 2026-01-15 17:56:00 - 2026-01-15 17:57:00 | PostRoll | Filler Clip
|
||||
213 | 2026-01-15 17:57:00 - 2026-01-15 17:58:00 | PostRoll | Filler Clip
|
||||
214 | 2026-01-15 17:58:00 - 2026-01-15 17:59:00 | PostRoll | Filler Clip
|
||||
215 | 2026-01-15 17:59:00 - 2026-01-15 18:00:00 | PostRoll | Filler Clip
|
||||
216 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Padded Movie 01
|
||||
217 | 2026-01-15 18:22:00 - 2026-01-15 18:23:00 | PostRoll | Filler Clip
|
||||
218 | 2026-01-15 18:23:00 - 2026-01-15 18:24:00 | PostRoll | Filler Clip
|
||||
219 | 2026-01-15 18:24:00 - 2026-01-15 18:25:00 | PostRoll | Filler Clip
|
||||
220 | 2026-01-15 18:25:00 - 2026-01-15 18:26:00 | PostRoll | Filler Clip
|
||||
221 | 2026-01-15 18:26:00 - 2026-01-15 18:27:00 | PostRoll | Filler Clip
|
||||
222 | 2026-01-15 18:27:00 - 2026-01-15 18:28:00 | PostRoll | Filler Clip
|
||||
223 | 2026-01-15 18:28:00 - 2026-01-15 18:29:00 | PostRoll | Filler Clip
|
||||
224 | 2026-01-15 18:29:00 - 2026-01-15 18:30:00 | PostRoll | Filler Clip
|
||||
225 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Padded Movie 02
|
||||
226 | 2026-01-15 19:07:00 - 2026-01-15 19:08:00 | PostRoll | Filler Clip
|
||||
227 | 2026-01-15 19:08:00 - 2026-01-15 19:09:00 | PostRoll | Filler Clip
|
||||
228 | 2026-01-15 19:09:00 - 2026-01-15 19:10:00 | PostRoll | Filler Clip
|
||||
229 | 2026-01-15 19:10:00 - 2026-01-15 19:11:00 | PostRoll | Filler Clip
|
||||
230 | 2026-01-15 19:11:00 - 2026-01-15 19:12:00 | PostRoll | Filler Clip
|
||||
231 | 2026-01-15 19:12:00 - 2026-01-15 19:13:00 | PostRoll | Filler Clip
|
||||
232 | 2026-01-15 19:13:00 - 2026-01-15 19:14:00 | PostRoll | Filler Clip
|
||||
233 | 2026-01-15 19:14:00 - 2026-01-15 19:15:00 | PostRoll | Filler Clip
|
||||
234 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Padded Movie 03
|
||||
235 | 2026-01-15 20:07:00 - 2026-01-15 20:08:00 | PostRoll | Filler Clip
|
||||
236 | 2026-01-15 20:08:00 - 2026-01-15 20:09:00 | PostRoll | Filler Clip
|
||||
237 | 2026-01-15 20:09:00 - 2026-01-15 20:10:00 | PostRoll | Filler Clip
|
||||
238 | 2026-01-15 20:10:00 - 2026-01-15 20:11:00 | PostRoll | Filler Clip
|
||||
239 | 2026-01-15 20:11:00 - 2026-01-15 20:12:00 | PostRoll | Filler Clip
|
||||
240 | 2026-01-15 20:12:00 - 2026-01-15 20:13:00 | PostRoll | Filler Clip
|
||||
241 | 2026-01-15 20:13:00 - 2026-01-15 20:14:00 | PostRoll | Filler Clip
|
||||
242 | 2026-01-15 20:14:00 - 2026-01-15 20:15:00 | PostRoll | Filler Clip
|
||||
243 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Padded Movie 01
|
||||
244 | 2026-01-15 20:37:00 - 2026-01-15 20:38:00 | PostRoll | Filler Clip
|
||||
245 | 2026-01-15 20:38:00 - 2026-01-15 20:39:00 | PostRoll | Filler Clip
|
||||
246 | 2026-01-15 20:39:00 - 2026-01-15 20:40:00 | PostRoll | Filler Clip
|
||||
247 | 2026-01-15 20:40:00 - 2026-01-15 20:41:00 | PostRoll | Filler Clip
|
||||
248 | 2026-01-15 20:41:00 - 2026-01-15 20:42:00 | PostRoll | Filler Clip
|
||||
249 | 2026-01-15 20:42:00 - 2026-01-15 20:43:00 | PostRoll | Filler Clip
|
||||
250 | 2026-01-15 20:43:00 - 2026-01-15 20:44:00 | PostRoll | Filler Clip
|
||||
251 | 2026-01-15 20:44:00 - 2026-01-15 20:45:00 | PostRoll | Filler Clip
|
||||
252 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Padded Movie 02
|
||||
253 | 2026-01-15 21:22:00 - 2026-01-15 21:23:00 | PostRoll | Filler Clip
|
||||
254 | 2026-01-15 21:23:00 - 2026-01-15 21:24:00 | PostRoll | Filler Clip
|
||||
255 | 2026-01-15 21:24:00 - 2026-01-15 21:25:00 | PostRoll | Filler Clip
|
||||
256 | 2026-01-15 21:25:00 - 2026-01-15 21:26:00 | PostRoll | Filler Clip
|
||||
257 | 2026-01-15 21:26:00 - 2026-01-15 21:27:00 | PostRoll | Filler Clip
|
||||
258 | 2026-01-15 21:27:00 - 2026-01-15 21:28:00 | PostRoll | Filler Clip
|
||||
259 | 2026-01-15 21:28:00 - 2026-01-15 21:29:00 | PostRoll | Filler Clip
|
||||
260 | 2026-01-15 21:29:00 - 2026-01-15 21:30:00 | PostRoll | Filler Clip
|
||||
261 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Padded Movie 03
|
||||
262 | 2026-01-15 22:22:00 - 2026-01-15 22:23:00 | PostRoll | Filler Clip
|
||||
263 | 2026-01-15 22:23:00 - 2026-01-15 22:24:00 | PostRoll | Filler Clip
|
||||
264 | 2026-01-15 22:24:00 - 2026-01-15 22:25:00 | PostRoll | Filler Clip
|
||||
265 | 2026-01-15 22:25:00 - 2026-01-15 22:26:00 | PostRoll | Filler Clip
|
||||
266 | 2026-01-15 22:26:00 - 2026-01-15 22:27:00 | PostRoll | Filler Clip
|
||||
267 | 2026-01-15 22:27:00 - 2026-01-15 22:28:00 | PostRoll | Filler Clip
|
||||
268 | 2026-01-15 22:28:00 - 2026-01-15 22:29:00 | PostRoll | Filler Clip
|
||||
269 | 2026-01-15 22:29:00 - 2026-01-15 22:30:00 | PostRoll | Filler Clip
|
||||
270 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Padded Movie 01
|
||||
271 | 2026-01-15 22:52:00 - 2026-01-15 22:53:00 | PostRoll | Filler Clip
|
||||
272 | 2026-01-15 22:53:00 - 2026-01-15 22:54:00 | PostRoll | Filler Clip
|
||||
273 | 2026-01-15 22:54:00 - 2026-01-15 22:55:00 | PostRoll | Filler Clip
|
||||
274 | 2026-01-15 22:55:00 - 2026-01-15 22:56:00 | PostRoll | Filler Clip
|
||||
275 | 2026-01-15 22:56:00 - 2026-01-15 22:57:00 | PostRoll | Filler Clip
|
||||
276 | 2026-01-15 22:57:00 - 2026-01-15 22:58:00 | PostRoll | Filler Clip
|
||||
277 | 2026-01-15 22:58:00 - 2026-01-15 22:59:00 | PostRoll | Filler Clip
|
||||
278 | 2026-01-15 22:59:00 - 2026-01-15 23:00:00 | PostRoll | Filler Clip
|
||||
279 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Padded Movie 02
|
||||
280 | 2026-01-15 23:37:00 - 2026-01-15 23:38:00 | PostRoll | Filler Clip
|
||||
281 | 2026-01-15 23:38:00 - 2026-01-15 23:39:00 | PostRoll | Filler Clip
|
||||
282 | 2026-01-15 23:39:00 - 2026-01-15 23:40:00 | PostRoll | Filler Clip
|
||||
283 | 2026-01-15 23:40:00 - 2026-01-15 23:41:00 | PostRoll | Filler Clip
|
||||
284 | 2026-01-15 23:41:00 - 2026-01-15 23:42:00 | PostRoll | Filler Clip
|
||||
285 | 2026-01-15 23:42:00 - 2026-01-15 23:43:00 | PostRoll | Filler Clip
|
||||
286 | 2026-01-15 23:43:00 - 2026-01-15 23:44:00 | PostRoll | Filler Clip
|
||||
287 | 2026-01-15 23:44:00 - 2026-01-15 23:45:00 | PostRoll | Filler Clip
|
||||
288 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Padded Movie 03
|
||||
289 | 2026-01-16 00:37:00 - 2026-01-16 00:38:00 | PostRoll | Filler Clip
|
||||
290 | 2026-01-16 00:38:00 - 2026-01-16 00:39:00 | PostRoll | Filler Clip
|
||||
291 | 2026-01-16 00:39:00 - 2026-01-16 00:40:00 | PostRoll | Filler Clip
|
||||
292 | 2026-01-16 00:40:00 - 2026-01-16 00:41:00 | PostRoll | Filler Clip
|
||||
293 | 2026-01-16 00:41:00 - 2026-01-16 00:42:00 | PostRoll | Filler Clip
|
||||
294 | 2026-01-16 00:42:00 - 2026-01-16 00:43:00 | PostRoll | Filler Clip
|
||||
295 | 2026-01-16 00:43:00 - 2026-01-16 00:44:00 | PostRoll | Filler Clip
|
||||
296 | 2026-01-16 00:44:00 - 2026-01-16 00:45:00 | PostRoll | Filler Clip
|
||||
297 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Padded Movie 01
|
||||
298 | 2026-01-16 01:07:00 - 2026-01-16 01:08:00 | PostRoll | Filler Clip
|
||||
299 | 2026-01-16 01:08:00 - 2026-01-16 01:09:00 | PostRoll | Filler Clip
|
||||
300 | 2026-01-16 01:09:00 - 2026-01-16 01:10:00 | PostRoll | Filler Clip
|
||||
301 | 2026-01-16 01:10:00 - 2026-01-16 01:11:00 | PostRoll | Filler Clip
|
||||
302 | 2026-01-16 01:11:00 - 2026-01-16 01:12:00 | PostRoll | Filler Clip
|
||||
303 | 2026-01-16 01:12:00 - 2026-01-16 01:13:00 | PostRoll | Filler Clip
|
||||
304 | 2026-01-16 01:13:00 - 2026-01-16 01:14:00 | PostRoll | Filler Clip
|
||||
305 | 2026-01-16 01:14:00 - 2026-01-16 01:15:00 | PostRoll | Filler Clip
|
||||
306 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Padded Movie 02
|
||||
307 | 2026-01-16 01:52:00 - 2026-01-16 01:53:00 | PostRoll | Filler Clip
|
||||
308 | 2026-01-16 01:53:00 - 2026-01-16 01:54:00 | PostRoll | Filler Clip
|
||||
309 | 2026-01-16 01:54:00 - 2026-01-16 01:55:00 | PostRoll | Filler Clip
|
||||
310 | 2026-01-16 01:55:00 - 2026-01-16 01:56:00 | PostRoll | Filler Clip
|
||||
311 | 2026-01-16 01:56:00 - 2026-01-16 01:57:00 | PostRoll | Filler Clip
|
||||
312 | 2026-01-16 01:57:00 - 2026-01-16 01:58:00 | PostRoll | Filler Clip
|
||||
313 | 2026-01-16 01:58:00 - 2026-01-16 01:59:00 | PostRoll | Filler Clip
|
||||
314 | 2026-01-16 01:59:00 - 2026-01-16 02:00:00 | PostRoll | Filler Clip
|
||||
315 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Padded Movie 03
|
||||
316 | 2026-01-16 02:52:00 - 2026-01-16 02:53:00 | PostRoll | Filler Clip
|
||||
317 | 2026-01-16 02:53:00 - 2026-01-16 02:54:00 | PostRoll | Filler Clip
|
||||
318 | 2026-01-16 02:54:00 - 2026-01-16 02:55:00 | PostRoll | Filler Clip
|
||||
319 | 2026-01-16 02:55:00 - 2026-01-16 02:56:00 | PostRoll | Filler Clip
|
||||
320 | 2026-01-16 02:56:00 - 2026-01-16 02:57:00 | PostRoll | Filler Clip
|
||||
321 | 2026-01-16 02:57:00 - 2026-01-16 02:58:00 | PostRoll | Filler Clip
|
||||
322 | 2026-01-16 02:58:00 - 2026-01-16 02:59:00 | PostRoll | Filler Clip
|
||||
323 | 2026-01-16 02:59:00 - 2026-01-16 03:00:00 | PostRoll | Filler Clip
|
||||
324 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Padded Movie 01
|
||||
325 | 2026-01-16 03:22:00 - 2026-01-16 03:23:00 | PostRoll | Filler Clip
|
||||
326 | 2026-01-16 03:23:00 - 2026-01-16 03:24:00 | PostRoll | Filler Clip
|
||||
327 | 2026-01-16 03:24:00 - 2026-01-16 03:25:00 | PostRoll | Filler Clip
|
||||
328 | 2026-01-16 03:25:00 - 2026-01-16 03:26:00 | PostRoll | Filler Clip
|
||||
329 | 2026-01-16 03:26:00 - 2026-01-16 03:27:00 | PostRoll | Filler Clip
|
||||
330 | 2026-01-16 03:27:00 - 2026-01-16 03:28:00 | PostRoll | Filler Clip
|
||||
331 | 2026-01-16 03:28:00 - 2026-01-16 03:29:00 | PostRoll | Filler Clip
|
||||
332 | 2026-01-16 03:29:00 - 2026-01-16 03:30:00 | PostRoll | Filler Clip
|
||||
333 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Padded Movie 02
|
||||
334 | 2026-01-16 04:07:00 - 2026-01-16 04:08:00 | PostRoll | Filler Clip
|
||||
335 | 2026-01-16 04:08:00 - 2026-01-16 04:09:00 | PostRoll | Filler Clip
|
||||
336 | 2026-01-16 04:09:00 - 2026-01-16 04:10:00 | PostRoll | Filler Clip
|
||||
337 | 2026-01-16 04:10:00 - 2026-01-16 04:11:00 | PostRoll | Filler Clip
|
||||
338 | 2026-01-16 04:11:00 - 2026-01-16 04:12:00 | PostRoll | Filler Clip
|
||||
339 | 2026-01-16 04:12:00 - 2026-01-16 04:13:00 | PostRoll | Filler Clip
|
||||
340 | 2026-01-16 04:13:00 - 2026-01-16 04:14:00 | PostRoll | Filler Clip
|
||||
341 | 2026-01-16 04:14:00 - 2026-01-16 04:15:00 | PostRoll | Filler Clip
|
||||
342 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Padded Movie 03
|
||||
343 | 2026-01-16 05:07:00 - 2026-01-16 05:08:00 | PostRoll | Filler Clip
|
||||
344 | 2026-01-16 05:08:00 - 2026-01-16 05:09:00 | PostRoll | Filler Clip
|
||||
345 | 2026-01-16 05:09:00 - 2026-01-16 05:10:00 | PostRoll | Filler Clip
|
||||
346 | 2026-01-16 05:10:00 - 2026-01-16 05:11:00 | PostRoll | Filler Clip
|
||||
347 | 2026-01-16 05:11:00 - 2026-01-16 05:12:00 | PostRoll | Filler Clip
|
||||
348 | 2026-01-16 05:12:00 - 2026-01-16 05:13:00 | PostRoll | Filler Clip
|
||||
349 | 2026-01-16 05:13:00 - 2026-01-16 05:14:00 | PostRoll | Filler Clip
|
||||
350 | 2026-01-16 05:14:00 - 2026-01-16 05:15:00 | PostRoll | Filler Clip
|
||||
351 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Padded Movie 01
|
||||
352 | 2026-01-16 05:37:00 - 2026-01-16 05:38:00 | PostRoll | Filler Clip
|
||||
353 | 2026-01-16 05:38:00 - 2026-01-16 05:39:00 | PostRoll | Filler Clip
|
||||
354 | 2026-01-16 05:39:00 - 2026-01-16 05:40:00 | PostRoll | Filler Clip
|
||||
355 | 2026-01-16 05:40:00 - 2026-01-16 05:41:00 | PostRoll | Filler Clip
|
||||
356 | 2026-01-16 05:41:00 - 2026-01-16 05:42:00 | PostRoll | Filler Clip
|
||||
357 | 2026-01-16 05:42:00 - 2026-01-16 05:43:00 | PostRoll | Filler Clip
|
||||
358 | 2026-01-16 05:43:00 - 2026-01-16 05:44:00 | PostRoll | Filler Clip
|
||||
359 | 2026-01-16 05:44:00 - 2026-01-16 05:45:00 | PostRoll | Filler Clip
|
||||
360 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Padded Movie 02
|
||||
361 | 2026-01-16 06:22:00 - 2026-01-16 06:23:00 | PostRoll | Filler Clip
|
||||
362 | 2026-01-16 06:23:00 - 2026-01-16 06:24:00 | PostRoll | Filler Clip
|
||||
363 | 2026-01-16 06:24:00 - 2026-01-16 06:25:00 | PostRoll | Filler Clip
|
||||
364 | 2026-01-16 06:25:00 - 2026-01-16 06:26:00 | PostRoll | Filler Clip
|
||||
365 | 2026-01-16 06:26:00 - 2026-01-16 06:27:00 | PostRoll | Filler Clip
|
||||
366 | 2026-01-16 06:27:00 - 2026-01-16 06:28:00 | PostRoll | Filler Clip
|
||||
367 | 2026-01-16 06:28:00 - 2026-01-16 06:29:00 | PostRoll | Filler Clip
|
||||
368 | 2026-01-16 06:29:00 - 2026-01-16 06:30:00 | PostRoll | Filler Clip
|
||||
369 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Padded Movie 03
|
||||
370 | 2026-01-16 07:22:00 - 2026-01-16 07:23:00 | PostRoll | Filler Clip
|
||||
371 | 2026-01-16 07:23:00 - 2026-01-16 07:24:00 | PostRoll | Filler Clip
|
||||
372 | 2026-01-16 07:24:00 - 2026-01-16 07:25:00 | PostRoll | Filler Clip
|
||||
373 | 2026-01-16 07:25:00 - 2026-01-16 07:26:00 | PostRoll | Filler Clip
|
||||
374 | 2026-01-16 07:26:00 - 2026-01-16 07:27:00 | PostRoll | Filler Clip
|
||||
375 | 2026-01-16 07:27:00 - 2026-01-16 07:28:00 | PostRoll | Filler Clip
|
||||
376 | 2026-01-16 07:28:00 - 2026-01-16 07:29:00 | PostRoll | Filler Clip
|
||||
377 | 2026-01-16 07:29:00 - 2026-01-16 07:30:00 | PostRoll | Filler Clip
|
||||
378 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Padded Movie 01
|
||||
379 | 2026-01-16 07:52:00 - 2026-01-16 07:53:00 | PostRoll | Filler Clip
|
||||
380 | 2026-01-16 07:53:00 - 2026-01-16 07:54:00 | PostRoll | Filler Clip
|
||||
381 | 2026-01-16 07:54:00 - 2026-01-16 07:55:00 | PostRoll | Filler Clip
|
||||
382 | 2026-01-16 07:55:00 - 2026-01-16 07:56:00 | PostRoll | Filler Clip
|
||||
383 | 2026-01-16 07:56:00 - 2026-01-16 07:57:00 | PostRoll | Filler Clip
|
||||
384 | 2026-01-16 07:57:00 - 2026-01-16 07:58:00 | PostRoll | Filler Clip
|
||||
385 | 2026-01-16 07:58:00 - 2026-01-16 07:59:00 | PostRoll | Filler Clip
|
||||
386 | 2026-01-16 07:59:00 - 2026-01-16 08:00:00 | PostRoll | Filler Clip
|
||||
387 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Padded Movie 02
|
||||
388 | 2026-01-16 08:37:00 - 2026-01-16 08:38:00 | PostRoll | Filler Clip
|
||||
389 | 2026-01-16 08:38:00 - 2026-01-16 08:39:00 | PostRoll | Filler Clip
|
||||
390 | 2026-01-16 08:39:00 - 2026-01-16 08:40:00 | PostRoll | Filler Clip
|
||||
391 | 2026-01-16 08:40:00 - 2026-01-16 08:41:00 | PostRoll | Filler Clip
|
||||
392 | 2026-01-16 08:41:00 - 2026-01-16 08:42:00 | PostRoll | Filler Clip
|
||||
393 | 2026-01-16 08:42:00 - 2026-01-16 08:43:00 | PostRoll | Filler Clip
|
||||
394 | 2026-01-16 08:43:00 - 2026-01-16 08:44:00 | PostRoll | Filler Clip
|
||||
395 | 2026-01-16 08:44:00 - 2026-01-16 08:45:00 | PostRoll | Filler Clip
|
||||
396 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Padded Movie 03
|
||||
397 | 2026-01-16 09:37:00 - 2026-01-16 09:38:00 | PostRoll | Filler Clip
|
||||
398 | 2026-01-16 09:38:00 - 2026-01-16 09:39:00 | PostRoll | Filler Clip
|
||||
399 | 2026-01-16 09:39:00 - 2026-01-16 09:40:00 | PostRoll | Filler Clip
|
||||
400 | 2026-01-16 09:40:00 - 2026-01-16 09:41:00 | PostRoll | Filler Clip
|
||||
401 | 2026-01-16 09:41:00 - 2026-01-16 09:42:00 | PostRoll | Filler Clip
|
||||
402 | 2026-01-16 09:42:00 - 2026-01-16 09:43:00 | PostRoll | Filler Clip
|
||||
403 | 2026-01-16 09:43:00 - 2026-01-16 09:44:00 | PostRoll | Filler Clip
|
||||
404 | 2026-01-16 09:44:00 - 2026-01-16 09:45:00 | PostRoll | Filler Clip
|
||||
405 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Padded Movie 01
|
||||
406 | 2026-01-16 10:07:00 - 2026-01-16 10:08:00 | PostRoll | Filler Clip
|
||||
407 | 2026-01-16 10:08:00 - 2026-01-16 10:09:00 | PostRoll | Filler Clip
|
||||
408 | 2026-01-16 10:09:00 - 2026-01-16 10:10:00 | PostRoll | Filler Clip
|
||||
409 | 2026-01-16 10:10:00 - 2026-01-16 10:11:00 | PostRoll | Filler Clip
|
||||
410 | 2026-01-16 10:11:00 - 2026-01-16 10:12:00 | PostRoll | Filler Clip
|
||||
411 | 2026-01-16 10:12:00 - 2026-01-16 10:13:00 | PostRoll | Filler Clip
|
||||
412 | 2026-01-16 10:13:00 - 2026-01-16 10:14:00 | PostRoll | Filler Clip
|
||||
413 | 2026-01-16 10:14:00 - 2026-01-16 10:15:00 | PostRoll | Filler Clip
|
||||
414 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Padded Movie 02
|
||||
415 | 2026-01-16 10:52:00 - 2026-01-16 10:53:00 | PostRoll | Filler Clip
|
||||
416 | 2026-01-16 10:53:00 - 2026-01-16 10:54:00 | PostRoll | Filler Clip
|
||||
417 | 2026-01-16 10:54:00 - 2026-01-16 10:55:00 | PostRoll | Filler Clip
|
||||
418 | 2026-01-16 10:55:00 - 2026-01-16 10:56:00 | PostRoll | Filler Clip
|
||||
419 | 2026-01-16 10:56:00 - 2026-01-16 10:57:00 | PostRoll | Filler Clip
|
||||
420 | 2026-01-16 10:57:00 - 2026-01-16 10:58:00 | PostRoll | Filler Clip
|
||||
421 | 2026-01-16 10:58:00 - 2026-01-16 10:59:00 | PostRoll | Filler Clip
|
||||
422 | 2026-01-16 10:59:00 - 2026-01-16 11:00:00 | PostRoll | Filler Clip
|
||||
423 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Padded Movie 03
|
||||
424 | 2026-01-16 11:52:00 - 2026-01-16 11:53:00 | PostRoll | Filler Clip
|
||||
425 | 2026-01-16 11:53:00 - 2026-01-16 11:54:00 | PostRoll | Filler Clip
|
||||
426 | 2026-01-16 11:54:00 - 2026-01-16 11:55:00 | PostRoll | Filler Clip
|
||||
427 | 2026-01-16 11:55:00 - 2026-01-16 11:56:00 | PostRoll | Filler Clip
|
||||
428 | 2026-01-16 11:56:00 - 2026-01-16 11:57:00 | PostRoll | Filler Clip
|
||||
429 | 2026-01-16 11:57:00 - 2026-01-16 11:58:00 | PostRoll | Filler Clip
|
||||
430 | 2026-01-16 11:58:00 - 2026-01-16 11:59:00 | PostRoll | Filler Clip
|
||||
431 | 2026-01-16 11:59:00 - 2026-01-16 12:00:00 | PostRoll | Filler Clip
|
||||
432 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Padded Movie 01
|
||||
433 | 2026-01-16 12:22:00 - 2026-01-16 12:23:00 | PostRoll | Filler Clip
|
||||
434 | 2026-01-16 12:23:00 - 2026-01-16 12:24:00 | PostRoll | Filler Clip
|
||||
435 | 2026-01-16 12:24:00 - 2026-01-16 12:25:00 | PostRoll | Filler Clip
|
||||
436 | 2026-01-16 12:25:00 - 2026-01-16 12:26:00 | PostRoll | Filler Clip
|
||||
437 | 2026-01-16 12:26:00 - 2026-01-16 12:27:00 | PostRoll | Filler Clip
|
||||
438 | 2026-01-16 12:27:00 - 2026-01-16 12:28:00 | PostRoll | Filler Clip
|
||||
439 | 2026-01-16 12:28:00 - 2026-01-16 12:29:00 | PostRoll | Filler Clip
|
||||
440 | 2026-01-16 12:29:00 - 2026-01-16 12:30:00 | PostRoll | Filler Clip
|
||||
441 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Padded Movie 02
|
||||
442 | 2026-01-16 13:07:00 - 2026-01-16 13:08:00 | PostRoll | Filler Clip
|
||||
443 | 2026-01-16 13:08:00 - 2026-01-16 13:09:00 | PostRoll | Filler Clip
|
||||
444 | 2026-01-16 13:09:00 - 2026-01-16 13:10:00 | PostRoll | Filler Clip
|
||||
445 | 2026-01-16 13:10:00 - 2026-01-16 13:11:00 | PostRoll | Filler Clip
|
||||
446 | 2026-01-16 13:11:00 - 2026-01-16 13:12:00 | PostRoll | Filler Clip
|
||||
447 | 2026-01-16 13:12:00 - 2026-01-16 13:13:00 | PostRoll | Filler Clip
|
||||
448 | 2026-01-16 13:13:00 - 2026-01-16 13:14:00 | PostRoll | Filler Clip
|
||||
449 | 2026-01-16 13:14:00 - 2026-01-16 13:15:00 | PostRoll | Filler Clip
|
||||
450 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Padded Movie 03
|
||||
451 | 2026-01-16 14:07:00 - 2026-01-16 14:08:00 | PostRoll | Filler Clip
|
||||
452 | 2026-01-16 14:08:00 - 2026-01-16 14:09:00 | PostRoll | Filler Clip
|
||||
453 | 2026-01-16 14:09:00 - 2026-01-16 14:10:00 | PostRoll | Filler Clip
|
||||
454 | 2026-01-16 14:10:00 - 2026-01-16 14:11:00 | PostRoll | Filler Clip
|
||||
455 | 2026-01-16 14:11:00 - 2026-01-16 14:12:00 | PostRoll | Filler Clip
|
||||
456 | 2026-01-16 14:12:00 - 2026-01-16 14:13:00 | PostRoll | Filler Clip
|
||||
457 | 2026-01-16 14:13:00 - 2026-01-16 14:14:00 | PostRoll | Filler Clip
|
||||
458 | 2026-01-16 14:14:00 - 2026-01-16 14:15:00 | PostRoll | Filler Clip
|
||||
459 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Padded Movie 01
|
||||
460 | 2026-01-16 14:37:00 - 2026-01-16 14:38:00 | PostRoll | Filler Clip
|
||||
461 | 2026-01-16 14:38:00 - 2026-01-16 14:39:00 | PostRoll | Filler Clip
|
||||
462 | 2026-01-16 14:39:00 - 2026-01-16 14:40:00 | PostRoll | Filler Clip
|
||||
463 | 2026-01-16 14:40:00 - 2026-01-16 14:41:00 | PostRoll | Filler Clip
|
||||
464 | 2026-01-16 14:41:00 - 2026-01-16 14:42:00 | PostRoll | Filler Clip
|
||||
465 | 2026-01-16 14:42:00 - 2026-01-16 14:43:00 | PostRoll | Filler Clip
|
||||
466 | 2026-01-16 14:43:00 - 2026-01-16 14:44:00 | PostRoll | Filler Clip
|
||||
467 | 2026-01-16 14:44:00 - 2026-01-16 14:45:00 | PostRoll | Filler Clip
|
||||
468 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Padded Movie 02
|
||||
469 | 2026-01-16 15:22:00 - 2026-01-16 15:23:00 | PostRoll | Filler Clip
|
||||
470 | 2026-01-16 15:23:00 - 2026-01-16 15:24:00 | PostRoll | Filler Clip
|
||||
471 | 2026-01-16 15:24:00 - 2026-01-16 15:25:00 | PostRoll | Filler Clip
|
||||
472 | 2026-01-16 15:25:00 - 2026-01-16 15:26:00 | PostRoll | Filler Clip
|
||||
473 | 2026-01-16 15:26:00 - 2026-01-16 15:27:00 | PostRoll | Filler Clip
|
||||
474 | 2026-01-16 15:27:00 - 2026-01-16 15:28:00 | PostRoll | Filler Clip
|
||||
475 | 2026-01-16 15:28:00 - 2026-01-16 15:29:00 | PostRoll | Filler Clip
|
||||
476 | 2026-01-16 15:29:00 - 2026-01-16 15:30:00 | PostRoll | Filler Clip
|
||||
477 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Padded Movie 03
|
||||
478 | 2026-01-16 16:22:00 - 2026-01-16 16:23:00 | PostRoll | Filler Clip
|
||||
479 | 2026-01-16 16:23:00 - 2026-01-16 16:24:00 | PostRoll | Filler Clip
|
||||
480 | 2026-01-16 16:24:00 - 2026-01-16 16:25:00 | PostRoll | Filler Clip
|
||||
481 | 2026-01-16 16:25:00 - 2026-01-16 16:26:00 | PostRoll | Filler Clip
|
||||
482 | 2026-01-16 16:26:00 - 2026-01-16 16:27:00 | PostRoll | Filler Clip
|
||||
483 | 2026-01-16 16:27:00 - 2026-01-16 16:28:00 | PostRoll | Filler Clip
|
||||
484 | 2026-01-16 16:28:00 - 2026-01-16 16:29:00 | PostRoll | Filler Clip
|
||||
485 | 2026-01-16 16:29:00 - 2026-01-16 16:30:00 | PostRoll | Filler Clip
|
||||
486 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Padded Movie 01
|
||||
487 | 2026-01-16 16:52:00 - 2026-01-16 16:53:00 | PostRoll | Filler Clip
|
||||
488 | 2026-01-16 16:53:00 - 2026-01-16 16:54:00 | PostRoll | Filler Clip
|
||||
489 | 2026-01-16 16:54:00 - 2026-01-16 16:55:00 | PostRoll | Filler Clip
|
||||
490 | 2026-01-16 16:55:00 - 2026-01-16 16:56:00 | PostRoll | Filler Clip
|
||||
491 | 2026-01-16 16:56:00 - 2026-01-16 16:57:00 | PostRoll | Filler Clip
|
||||
492 | 2026-01-16 16:57:00 - 2026-01-16 16:58:00 | PostRoll | Filler Clip
|
||||
493 | 2026-01-16 16:58:00 - 2026-01-16 16:59:00 | PostRoll | Filler Clip
|
||||
494 | 2026-01-16 16:59:00 - 2026-01-16 17:00:00 | PostRoll | Filler Clip
|
||||
495 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Padded Movie 02
|
||||
496 | 2026-01-16 17:37:00 - 2026-01-16 17:38:00 | PostRoll | Filler Clip
|
||||
497 | 2026-01-16 17:38:00 - 2026-01-16 17:39:00 | PostRoll | Filler Clip
|
||||
498 | 2026-01-16 17:39:00 - 2026-01-16 17:40:00 | PostRoll | Filler Clip
|
||||
499 | 2026-01-16 17:40:00 - 2026-01-16 17:41:00 | PostRoll | Filler Clip
|
||||
500 | 2026-01-16 17:41:00 - 2026-01-16 17:42:00 | PostRoll | Filler Clip
|
||||
501 | 2026-01-16 17:42:00 - 2026-01-16 17:43:00 | PostRoll | Filler Clip
|
||||
502 | 2026-01-16 17:43:00 - 2026-01-16 17:44:00 | PostRoll | Filler Clip
|
||||
503 | 2026-01-16 17:44:00 - 2026-01-16 17:45:00 | PostRoll | Filler Clip
|
||||
504 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Padded Movie 03
|
||||
505 | 2026-01-16 18:37:00 - 2026-01-16 18:38:00 | PostRoll | Filler Clip
|
||||
506 | 2026-01-16 18:38:00 - 2026-01-16 18:39:00 | PostRoll | Filler Clip
|
||||
507 | 2026-01-16 18:39:00 - 2026-01-16 18:40:00 | PostRoll | Filler Clip
|
||||
508 | 2026-01-16 18:40:00 - 2026-01-16 18:41:00 | PostRoll | Filler Clip
|
||||
509 | 2026-01-16 18:41:00 - 2026-01-16 18:42:00 | PostRoll | Filler Clip
|
||||
510 | 2026-01-16 18:42:00 - 2026-01-16 18:43:00 | PostRoll | Filler Clip
|
||||
511 | 2026-01-16 18:43:00 - 2026-01-16 18:44:00 | PostRoll | Filler Clip
|
||||
512 | 2026-01-16 18:44:00 - 2026-01-16 18:45:00 | PostRoll | Filler Clip
|
||||
513 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Padded Movie 01
|
||||
514 | 2026-01-16 19:07:00 - 2026-01-16 19:08:00 | PostRoll | Filler Clip
|
||||
515 | 2026-01-16 19:08:00 - 2026-01-16 19:09:00 | PostRoll | Filler Clip
|
||||
516 | 2026-01-16 19:09:00 - 2026-01-16 19:10:00 | PostRoll | Filler Clip
|
||||
517 | 2026-01-16 19:10:00 - 2026-01-16 19:11:00 | PostRoll | Filler Clip
|
||||
518 | 2026-01-16 19:11:00 - 2026-01-16 19:12:00 | PostRoll | Filler Clip
|
||||
519 | 2026-01-16 19:12:00 - 2026-01-16 19:13:00 | PostRoll | Filler Clip
|
||||
520 | 2026-01-16 19:13:00 - 2026-01-16 19:14:00 | PostRoll | Filler Clip
|
||||
521 | 2026-01-16 19:14:00 - 2026-01-16 19:15:00 | PostRoll | Filler Clip
|
||||
522 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Padded Movie 02
|
||||
523 | 2026-01-16 19:52:00 - 2026-01-16 19:53:00 | PostRoll | Filler Clip
|
||||
524 | 2026-01-16 19:53:00 - 2026-01-16 19:54:00 | PostRoll | Filler Clip
|
||||
525 | 2026-01-16 19:54:00 - 2026-01-16 19:55:00 | PostRoll | Filler Clip
|
||||
526 | 2026-01-16 19:55:00 - 2026-01-16 19:56:00 | PostRoll | Filler Clip
|
||||
527 | 2026-01-16 19:56:00 - 2026-01-16 19:57:00 | PostRoll | Filler Clip
|
||||
528 | 2026-01-16 19:57:00 - 2026-01-16 19:58:00 | PostRoll | Filler Clip
|
||||
529 | 2026-01-16 19:58:00 - 2026-01-16 19:59:00 | PostRoll | Filler Clip
|
||||
530 | 2026-01-16 19:59:00 - 2026-01-16 20:00:00 | PostRoll | Filler Clip
|
||||
531 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Padded Movie 03
|
||||
532 | 2026-01-16 20:52:00 - 2026-01-16 20:53:00 | PostRoll | Filler Clip
|
||||
533 | 2026-01-16 20:53:00 - 2026-01-16 20:54:00 | PostRoll | Filler Clip
|
||||
534 | 2026-01-16 20:54:00 - 2026-01-16 20:55:00 | PostRoll | Filler Clip
|
||||
535 | 2026-01-16 20:55:00 - 2026-01-16 20:56:00 | PostRoll | Filler Clip
|
||||
536 | 2026-01-16 20:56:00 - 2026-01-16 20:57:00 | PostRoll | Filler Clip
|
||||
537 | 2026-01-16 20:57:00 - 2026-01-16 20:58:00 | PostRoll | Filler Clip
|
||||
538 | 2026-01-16 20:58:00 - 2026-01-16 20:59:00 | PostRoll | Filler Clip
|
||||
539 | 2026-01-16 20:59:00 - 2026-01-16 21:00:00 | PostRoll | Filler Clip
|
||||
540 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Padded Movie 01
|
||||
541 | 2026-01-16 21:22:00 - 2026-01-16 21:23:00 | PostRoll | Filler Clip
|
||||
542 | 2026-01-16 21:23:00 - 2026-01-16 21:24:00 | PostRoll | Filler Clip
|
||||
543 | 2026-01-16 21:24:00 - 2026-01-16 21:25:00 | PostRoll | Filler Clip
|
||||
544 | 2026-01-16 21:25:00 - 2026-01-16 21:26:00 | PostRoll | Filler Clip
|
||||
545 | 2026-01-16 21:26:00 - 2026-01-16 21:27:00 | PostRoll | Filler Clip
|
||||
546 | 2026-01-16 21:27:00 - 2026-01-16 21:28:00 | PostRoll | Filler Clip
|
||||
547 | 2026-01-16 21:28:00 - 2026-01-16 21:29:00 | PostRoll | Filler Clip
|
||||
548 | 2026-01-16 21:29:00 - 2026-01-16 21:30:00 | PostRoll | Filler Clip
|
||||
549 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Padded Movie 02
|
||||
550 | 2026-01-16 22:07:00 - 2026-01-16 22:08:00 | PostRoll | Filler Clip
|
||||
551 | 2026-01-16 22:08:00 - 2026-01-16 22:09:00 | PostRoll | Filler Clip
|
||||
552 | 2026-01-16 22:09:00 - 2026-01-16 22:10:00 | PostRoll | Filler Clip
|
||||
553 | 2026-01-16 22:10:00 - 2026-01-16 22:11:00 | PostRoll | Filler Clip
|
||||
554 | 2026-01-16 22:11:00 - 2026-01-16 22:12:00 | PostRoll | Filler Clip
|
||||
555 | 2026-01-16 22:12:00 - 2026-01-16 22:13:00 | PostRoll | Filler Clip
|
||||
556 | 2026-01-16 22:13:00 - 2026-01-16 22:14:00 | PostRoll | Filler Clip
|
||||
557 | 2026-01-16 22:14:00 - 2026-01-16 22:15:00 | PostRoll | Filler Clip
|
||||
558 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Padded Movie 03
|
||||
559 | 2026-01-16 23:07:00 - 2026-01-16 23:08:00 | PostRoll | Filler Clip
|
||||
560 | 2026-01-16 23:08:00 - 2026-01-16 23:09:00 | PostRoll | Filler Clip
|
||||
561 | 2026-01-16 23:09:00 - 2026-01-16 23:10:00 | PostRoll | Filler Clip
|
||||
562 | 2026-01-16 23:10:00 - 2026-01-16 23:11:00 | PostRoll | Filler Clip
|
||||
563 | 2026-01-16 23:11:00 - 2026-01-16 23:12:00 | PostRoll | Filler Clip
|
||||
564 | 2026-01-16 23:12:00 - 2026-01-16 23:13:00 | PostRoll | Filler Clip
|
||||
565 | 2026-01-16 23:13:00 - 2026-01-16 23:14:00 | PostRoll | Filler Clip
|
||||
566 | 2026-01-16 23:14:00 - 2026-01-16 23:15:00 | PostRoll | Filler Clip
|
||||
567 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Padded Movie 01
|
||||
568 | 2026-01-16 23:37:00 - 2026-01-16 23:38:00 | PostRoll | Filler Clip
|
||||
569 | 2026-01-16 23:38:00 - 2026-01-16 23:39:00 | PostRoll | Filler Clip
|
||||
570 | 2026-01-16 23:39:00 - 2026-01-16 23:40:00 | PostRoll | Filler Clip
|
||||
571 | 2026-01-16 23:40:00 - 2026-01-16 23:41:00 | PostRoll | Filler Clip
|
||||
572 | 2026-01-16 23:41:00 - 2026-01-16 23:42:00 | PostRoll | Filler Clip
|
||||
573 | 2026-01-16 23:42:00 - 2026-01-16 23:43:00 | PostRoll | Filler Clip
|
||||
574 | 2026-01-16 23:43:00 - 2026-01-16 23:44:00 | PostRoll | Filler Clip
|
||||
575 | 2026-01-16 23:44:00 - 2026-01-16 23:45:00 | PostRoll | Filler Clip
|
||||
576 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Padded Movie 02
|
||||
577 | 2026-01-17 00:22:00 - 2026-01-17 00:23:00 | PostRoll | Filler Clip
|
||||
578 | 2026-01-17 00:23:00 - 2026-01-17 00:24:00 | PostRoll | Filler Clip
|
||||
579 | 2026-01-17 00:24:00 - 2026-01-17 00:25:00 | PostRoll | Filler Clip
|
||||
580 | 2026-01-17 00:25:00 - 2026-01-17 00:26:00 | PostRoll | Filler Clip
|
||||
581 | 2026-01-17 00:26:00 - 2026-01-17 00:27:00 | PostRoll | Filler Clip
|
||||
582 | 2026-01-17 00:27:00 - 2026-01-17 00:28:00 | PostRoll | Filler Clip
|
||||
583 | 2026-01-17 00:28:00 - 2026-01-17 00:29:00 | PostRoll | Filler Clip
|
||||
584 | 2026-01-17 00:29:00 - 2026-01-17 00:30:00 | PostRoll | Filler Clip
|
||||
585 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Padded Movie 03
|
||||
586 | 2026-01-17 01:22:00 - 2026-01-17 01:23:00 | PostRoll | Filler Clip
|
||||
587 | 2026-01-17 01:23:00 - 2026-01-17 01:24:00 | PostRoll | Filler Clip
|
||||
588 | 2026-01-17 01:24:00 - 2026-01-17 01:25:00 | PostRoll | Filler Clip
|
||||
589 | 2026-01-17 01:25:00 - 2026-01-17 01:26:00 | PostRoll | Filler Clip
|
||||
590 | 2026-01-17 01:26:00 - 2026-01-17 01:27:00 | PostRoll | Filler Clip
|
||||
591 | 2026-01-17 01:27:00 - 2026-01-17 01:28:00 | PostRoll | Filler Clip
|
||||
592 | 2026-01-17 01:28:00 - 2026-01-17 01:29:00 | PostRoll | Filler Clip
|
||||
593 | 2026-01-17 01:29:00 - 2026-01-17 01:30:00 | PostRoll | Filler Clip
|
||||
594 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Padded Movie 01
|
||||
595 | 2026-01-17 01:52:00 - 2026-01-17 01:53:00 | PostRoll | Filler Clip
|
||||
596 | 2026-01-17 01:53:00 - 2026-01-17 01:54:00 | PostRoll | Filler Clip
|
||||
597 | 2026-01-17 01:54:00 - 2026-01-17 01:55:00 | PostRoll | Filler Clip
|
||||
598 | 2026-01-17 01:55:00 - 2026-01-17 01:56:00 | PostRoll | Filler Clip
|
||||
599 | 2026-01-17 01:56:00 - 2026-01-17 01:57:00 | PostRoll | Filler Clip
|
||||
600 | 2026-01-17 01:57:00 - 2026-01-17 01:58:00 | PostRoll | Filler Clip
|
||||
601 | 2026-01-17 01:58:00 - 2026-01-17 01:59:00 | PostRoll | Filler Clip
|
||||
602 | 2026-01-17 01:59:00 - 2026-01-17 02:00:00 | PostRoll | Filler Clip
|
||||
603 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Padded Movie 02
|
||||
604 | 2026-01-17 02:37:00 - 2026-01-17 02:38:00 | PostRoll | Filler Clip
|
||||
605 | 2026-01-17 02:38:00 - 2026-01-17 02:39:00 | PostRoll | Filler Clip
|
||||
606 | 2026-01-17 02:39:00 - 2026-01-17 02:40:00 | PostRoll | Filler Clip
|
||||
607 | 2026-01-17 02:40:00 - 2026-01-17 02:41:00 | PostRoll | Filler Clip
|
||||
608 | 2026-01-17 02:41:00 - 2026-01-17 02:42:00 | PostRoll | Filler Clip
|
||||
609 | 2026-01-17 02:42:00 - 2026-01-17 02:43:00 | PostRoll | Filler Clip
|
||||
610 | 2026-01-17 02:43:00 - 2026-01-17 02:44:00 | PostRoll | Filler Clip
|
||||
611 | 2026-01-17 02:44:00 - 2026-01-17 02:45:00 | PostRoll | Filler Clip
|
||||
612 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Padded Movie 03
|
||||
613 | 2026-01-17 03:37:00 - 2026-01-17 03:38:00 | PostRoll | Filler Clip
|
||||
614 | 2026-01-17 03:38:00 - 2026-01-17 03:39:00 | PostRoll | Filler Clip
|
||||
615 | 2026-01-17 03:39:00 - 2026-01-17 03:40:00 | PostRoll | Filler Clip
|
||||
616 | 2026-01-17 03:40:00 - 2026-01-17 03:41:00 | PostRoll | Filler Clip
|
||||
617 | 2026-01-17 03:41:00 - 2026-01-17 03:42:00 | PostRoll | Filler Clip
|
||||
618 | 2026-01-17 03:42:00 - 2026-01-17 03:43:00 | PostRoll | Filler Clip
|
||||
619 | 2026-01-17 03:43:00 - 2026-01-17 03:44:00 | PostRoll | Filler Clip
|
||||
620 | 2026-01-17 03:44:00 - 2026-01-17 03:45:00 | PostRoll | Filler Clip
|
||||
621 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Padded Movie 01
|
||||
622 | 2026-01-17 04:07:00 - 2026-01-17 04:08:00 | PostRoll | Filler Clip
|
||||
623 | 2026-01-17 04:08:00 - 2026-01-17 04:09:00 | PostRoll | Filler Clip
|
||||
624 | 2026-01-17 04:09:00 - 2026-01-17 04:10:00 | PostRoll | Filler Clip
|
||||
625 | 2026-01-17 04:10:00 - 2026-01-17 04:11:00 | PostRoll | Filler Clip
|
||||
626 | 2026-01-17 04:11:00 - 2026-01-17 04:12:00 | PostRoll | Filler Clip
|
||||
627 | 2026-01-17 04:12:00 - 2026-01-17 04:13:00 | PostRoll | Filler Clip
|
||||
628 | 2026-01-17 04:13:00 - 2026-01-17 04:14:00 | PostRoll | Filler Clip
|
||||
629 | 2026-01-17 04:14:00 - 2026-01-17 04:15:00 | PostRoll | Filler Clip
|
||||
630 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Padded Movie 02
|
||||
631 | 2026-01-17 04:52:00 - 2026-01-17 04:53:00 | PostRoll | Filler Clip
|
||||
632 | 2026-01-17 04:53:00 - 2026-01-17 04:54:00 | PostRoll | Filler Clip
|
||||
633 | 2026-01-17 04:54:00 - 2026-01-17 04:55:00 | PostRoll | Filler Clip
|
||||
634 | 2026-01-17 04:55:00 - 2026-01-17 04:56:00 | PostRoll | Filler Clip
|
||||
635 | 2026-01-17 04:56:00 - 2026-01-17 04:57:00 | PostRoll | Filler Clip
|
||||
636 | 2026-01-17 04:57:00 - 2026-01-17 04:58:00 | PostRoll | Filler Clip
|
||||
637 | 2026-01-17 04:58:00 - 2026-01-17 04:59:00 | PostRoll | Filler Clip
|
||||
638 | 2026-01-17 04:59:00 - 2026-01-17 05:00:00 | PostRoll | Filler Clip
|
||||
639 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Padded Movie 03
|
||||
640 | 2026-01-17 05:52:00 - 2026-01-17 05:53:00 | PostRoll | Filler Clip
|
||||
641 | 2026-01-17 05:53:00 - 2026-01-17 05:54:00 | PostRoll | Filler Clip
|
||||
642 | 2026-01-17 05:54:00 - 2026-01-17 05:55:00 | PostRoll | Filler Clip
|
||||
643 | 2026-01-17 05:55:00 - 2026-01-17 05:56:00 | PostRoll | Filler Clip
|
||||
644 | 2026-01-17 05:56:00 - 2026-01-17 05:57:00 | PostRoll | Filler Clip
|
||||
645 | 2026-01-17 05:57:00 - 2026-01-17 05:58:00 | PostRoll | Filler Clip
|
||||
646 | 2026-01-17 05:58:00 - 2026-01-17 05:59:00 | PostRoll | Filler Clip
|
||||
647 | 2026-01-17 05:59:00 - 2026-01-17 06:00:00 | PostRoll | Filler Clip
|
||||
@@ -1,72 +0,0 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Shuffle Movie 04
|
||||
001 | 2026-01-15 00:30:00 - 2026-01-15 01:30:00 | None | Shuffle Movie 06
|
||||
002 | 2026-01-15 01:30:00 - 2026-01-15 02:30:00 | None | Shuffle Movie 03
|
||||
003 | 2026-01-15 02:30:00 - 2026-01-15 03:00:00 | None | Shuffle Movie 01
|
||||
004 | 2026-01-15 03:00:00 - 2026-01-15 03:45:00 | None | Shuffle Movie 02
|
||||
005 | 2026-01-15 03:45:00 - 2026-01-15 04:30:00 | None | Shuffle Movie 05
|
||||
006 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Shuffle Movie 01
|
||||
007 | 2026-01-15 05:00:00 - 2026-01-15 05:45:00 | None | Shuffle Movie 05
|
||||
008 | 2026-01-15 05:45:00 - 2026-01-15 06:45:00 | None | Shuffle Movie 06
|
||||
009 | 2026-01-15 06:45:00 - 2026-01-15 07:15:00 | None | Shuffle Movie 04
|
||||
010 | 2026-01-15 07:15:00 - 2026-01-15 08:00:00 | None | Shuffle Movie 02
|
||||
011 | 2026-01-15 08:00:00 - 2026-01-15 09:00:00 | None | Shuffle Movie 03
|
||||
012 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Shuffle Movie 01
|
||||
013 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Shuffle Movie 02
|
||||
014 | 2026-01-15 10:15:00 - 2026-01-15 11:15:00 | None | Shuffle Movie 06
|
||||
015 | 2026-01-15 11:15:00 - 2026-01-15 11:45:00 | None | Shuffle Movie 04
|
||||
016 | 2026-01-15 11:45:00 - 2026-01-15 12:30:00 | None | Shuffle Movie 05
|
||||
017 | 2026-01-15 12:30:00 - 2026-01-15 13:30:00 | None | Shuffle Movie 03
|
||||
018 | 2026-01-15 13:30:00 - 2026-01-15 14:15:00 | None | Shuffle Movie 05
|
||||
019 | 2026-01-15 14:15:00 - 2026-01-15 14:45:00 | None | Shuffle Movie 01
|
||||
020 | 2026-01-15 14:45:00 - 2026-01-15 15:45:00 | None | Shuffle Movie 06
|
||||
021 | 2026-01-15 15:45:00 - 2026-01-15 16:15:00 | None | Shuffle Movie 04
|
||||
022 | 2026-01-15 16:15:00 - 2026-01-15 17:00:00 | None | Shuffle Movie 02
|
||||
023 | 2026-01-15 17:00:00 - 2026-01-15 18:00:00 | None | Shuffle Movie 03
|
||||
024 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Shuffle Movie 01
|
||||
025 | 2026-01-15 18:30:00 - 2026-01-15 19:00:00 | None | Shuffle Movie 04
|
||||
026 | 2026-01-15 19:00:00 - 2026-01-15 19:45:00 | None | Shuffle Movie 05
|
||||
027 | 2026-01-15 19:45:00 - 2026-01-15 20:30:00 | None | Shuffle Movie 02
|
||||
028 | 2026-01-15 20:30:00 - 2026-01-15 21:30:00 | None | Shuffle Movie 03
|
||||
029 | 2026-01-15 21:30:00 - 2026-01-15 22:30:00 | None | Shuffle Movie 06
|
||||
030 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Shuffle Movie 04
|
||||
031 | 2026-01-15 23:00:00 - 2026-01-15 23:30:00 | None | Shuffle Movie 01
|
||||
032 | 2026-01-15 23:30:00 - 2026-01-16 00:30:00 | None | Shuffle Movie 06
|
||||
033 | 2026-01-16 00:30:00 - 2026-01-16 01:15:00 | None | Shuffle Movie 02
|
||||
034 | 2026-01-16 01:15:00 - 2026-01-16 02:15:00 | None | Shuffle Movie 03
|
||||
035 | 2026-01-16 02:15:00 - 2026-01-16 03:00:00 | None | Shuffle Movie 05
|
||||
036 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Shuffle Movie 01
|
||||
037 | 2026-01-16 03:30:00 - 2026-01-16 04:00:00 | None | Shuffle Movie 04
|
||||
038 | 2026-01-16 04:00:00 - 2026-01-16 04:45:00 | None | Shuffle Movie 02
|
||||
039 | 2026-01-16 04:45:00 - 2026-01-16 05:45:00 | None | Shuffle Movie 06
|
||||
040 | 2026-01-16 05:45:00 - 2026-01-16 06:45:00 | None | Shuffle Movie 03
|
||||
041 | 2026-01-16 06:45:00 - 2026-01-16 07:30:00 | None | Shuffle Movie 05
|
||||
042 | 2026-01-16 07:30:00 - 2026-01-16 08:30:00 | None | Shuffle Movie 06
|
||||
043 | 2026-01-16 08:30:00 - 2026-01-16 09:00:00 | None | Shuffle Movie 01
|
||||
044 | 2026-01-16 09:00:00 - 2026-01-16 10:00:00 | None | Shuffle Movie 03
|
||||
045 | 2026-01-16 10:00:00 - 2026-01-16 10:45:00 | None | Shuffle Movie 05
|
||||
046 | 2026-01-16 10:45:00 - 2026-01-16 11:15:00 | None | Shuffle Movie 04
|
||||
047 | 2026-01-16 11:15:00 - 2026-01-16 12:00:00 | None | Shuffle Movie 02
|
||||
048 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Shuffle Movie 04
|
||||
049 | 2026-01-16 12:30:00 - 2026-01-16 13:15:00 | None | Shuffle Movie 02
|
||||
050 | 2026-01-16 13:15:00 - 2026-01-16 13:45:00 | None | Shuffle Movie 01
|
||||
051 | 2026-01-16 13:45:00 - 2026-01-16 14:45:00 | None | Shuffle Movie 03
|
||||
052 | 2026-01-16 14:45:00 - 2026-01-16 15:30:00 | None | Shuffle Movie 05
|
||||
053 | 2026-01-16 15:30:00 - 2026-01-16 16:30:00 | None | Shuffle Movie 06
|
||||
054 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Shuffle Movie 01
|
||||
055 | 2026-01-16 17:00:00 - 2026-01-16 18:00:00 | None | Shuffle Movie 06
|
||||
056 | 2026-01-16 18:00:00 - 2026-01-16 18:45:00 | None | Shuffle Movie 02
|
||||
057 | 2026-01-16 18:45:00 - 2026-01-16 19:45:00 | None | Shuffle Movie 03
|
||||
058 | 2026-01-16 19:45:00 - 2026-01-16 20:15:00 | None | Shuffle Movie 04
|
||||
059 | 2026-01-16 20:15:00 - 2026-01-16 21:00:00 | None | Shuffle Movie 05
|
||||
060 | 2026-01-16 21:00:00 - 2026-01-16 21:45:00 | None | Shuffle Movie 02
|
||||
061 | 2026-01-16 21:45:00 - 2026-01-16 22:45:00 | None | Shuffle Movie 03
|
||||
062 | 2026-01-16 22:45:00 - 2026-01-16 23:15:00 | None | Shuffle Movie 01
|
||||
063 | 2026-01-16 23:15:00 - 2026-01-16 23:45:00 | None | Shuffle Movie 04
|
||||
064 | 2026-01-16 23:45:00 - 2026-01-17 00:45:00 | None | Shuffle Movie 06
|
||||
065 | 2026-01-17 00:45:00 - 2026-01-17 01:30:00 | None | Shuffle Movie 05
|
||||
066 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Shuffle Movie 01
|
||||
067 | 2026-01-17 02:00:00 - 2026-01-17 02:45:00 | None | Shuffle Movie 02
|
||||
068 | 2026-01-17 02:45:00 - 2026-01-17 03:30:00 | None | Shuffle Movie 05
|
||||
069 | 2026-01-17 03:30:00 - 2026-01-17 04:30:00 | None | Shuffle Movie 06
|
||||
070 | 2026-01-17 04:30:00 - 2026-01-17 05:00:00 | None | Shuffle Movie 04
|
||||
071 | 2026-01-17 05:00:00 - 2026-01-17 06:00:00 | None | Shuffle Movie 03
|
||||
@@ -1,108 +0,0 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Heavy Movie 01
|
||||
001 | 2026-01-15 00:30:00 - 2026-01-15 01:00:00 | None | Heavy Movie 02
|
||||
002 | 2026-01-15 01:00:00 - 2026-01-15 01:30:00 | None | Light Movie 04
|
||||
003 | 2026-01-15 01:30:00 - 2026-01-15 02:00:00 | None | Heavy Movie 01
|
||||
004 | 2026-01-15 02:00:00 - 2026-01-15 02:30:00 | None | Heavy Movie 02
|
||||
005 | 2026-01-15 02:30:00 - 2026-01-15 03:00:00 | None | Heavy Movie 01
|
||||
006 | 2026-01-15 03:00:00 - 2026-01-15 03:30:00 | None | Light Movie 03
|
||||
007 | 2026-01-15 03:30:00 - 2026-01-15 04:00:00 | None | Heavy Movie 02
|
||||
008 | 2026-01-15 04:00:00 - 2026-01-15 04:30:00 | None | Heavy Movie 01
|
||||
009 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Heavy Movie 02
|
||||
010 | 2026-01-15 05:00:00 - 2026-01-15 05:30:00 | None | Light Movie 01
|
||||
011 | 2026-01-15 05:30:00 - 2026-01-15 06:00:00 | None | Heavy Movie 01
|
||||
012 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Heavy Movie 02
|
||||
013 | 2026-01-15 06:30:00 - 2026-01-15 07:00:00 | None | Heavy Movie 01
|
||||
014 | 2026-01-15 07:00:00 - 2026-01-15 07:30:00 | None | Light Movie 02
|
||||
015 | 2026-01-15 07:30:00 - 2026-01-15 08:00:00 | None | Heavy Movie 02
|
||||
016 | 2026-01-15 08:00:00 - 2026-01-15 08:30:00 | None | Heavy Movie 01
|
||||
017 | 2026-01-15 08:30:00 - 2026-01-15 09:00:00 | None | Heavy Movie 02
|
||||
018 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Light Movie 02
|
||||
019 | 2026-01-15 09:30:00 - 2026-01-15 10:00:00 | None | Heavy Movie 01
|
||||
020 | 2026-01-15 10:00:00 - 2026-01-15 10:30:00 | None | Heavy Movie 02
|
||||
021 | 2026-01-15 10:30:00 - 2026-01-15 11:00:00 | None | Heavy Movie 01
|
||||
022 | 2026-01-15 11:00:00 - 2026-01-15 11:30:00 | None | Light Movie 04
|
||||
023 | 2026-01-15 11:30:00 - 2026-01-15 12:00:00 | None | Heavy Movie 02
|
||||
024 | 2026-01-15 12:00:00 - 2026-01-15 12:30:00 | None | Heavy Movie 01
|
||||
025 | 2026-01-15 12:30:00 - 2026-01-15 13:00:00 | None | Heavy Movie 02
|
||||
026 | 2026-01-15 13:00:00 - 2026-01-15 13:30:00 | None | Light Movie 01
|
||||
027 | 2026-01-15 13:30:00 - 2026-01-15 14:00:00 | None | Heavy Movie 01
|
||||
028 | 2026-01-15 14:00:00 - 2026-01-15 14:30:00 | None | Heavy Movie 02
|
||||
029 | 2026-01-15 14:30:00 - 2026-01-15 15:00:00 | None | Heavy Movie 01
|
||||
030 | 2026-01-15 15:00:00 - 2026-01-15 15:30:00 | None | Light Movie 03
|
||||
031 | 2026-01-15 15:30:00 - 2026-01-15 16:00:00 | None | Heavy Movie 02
|
||||
032 | 2026-01-15 16:00:00 - 2026-01-15 16:30:00 | None | Heavy Movie 01
|
||||
033 | 2026-01-15 16:30:00 - 2026-01-15 17:00:00 | None | Heavy Movie 02
|
||||
034 | 2026-01-15 17:00:00 - 2026-01-15 17:30:00 | None | Light Movie 02
|
||||
035 | 2026-01-15 17:30:00 - 2026-01-15 18:00:00 | None | Heavy Movie 01
|
||||
036 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Heavy Movie 02
|
||||
037 | 2026-01-15 18:30:00 - 2026-01-15 19:00:00 | None | Heavy Movie 01
|
||||
038 | 2026-01-15 19:00:00 - 2026-01-15 19:30:00 | None | Light Movie 03
|
||||
039 | 2026-01-15 19:30:00 - 2026-01-15 20:00:00 | None | Heavy Movie 02
|
||||
040 | 2026-01-15 20:00:00 - 2026-01-15 20:30:00 | None | Heavy Movie 01
|
||||
041 | 2026-01-15 20:30:00 - 2026-01-15 21:00:00 | None | Heavy Movie 02
|
||||
042 | 2026-01-15 21:00:00 - 2026-01-15 21:30:00 | None | Light Movie 01
|
||||
043 | 2026-01-15 21:30:00 - 2026-01-15 22:00:00 | None | Heavy Movie 01
|
||||
044 | 2026-01-15 22:00:00 - 2026-01-15 22:30:00 | None | Heavy Movie 02
|
||||
045 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Heavy Movie 01
|
||||
046 | 2026-01-15 23:00:00 - 2026-01-15 23:30:00 | None | Light Movie 04
|
||||
047 | 2026-01-15 23:30:00 - 2026-01-16 00:00:00 | None | Heavy Movie 02
|
||||
048 | 2026-01-16 00:00:00 - 2026-01-16 00:30:00 | None | Heavy Movie 01
|
||||
049 | 2026-01-16 00:30:00 - 2026-01-16 01:00:00 | None | Heavy Movie 02
|
||||
050 | 2026-01-16 01:00:00 - 2026-01-16 01:30:00 | None | Light Movie 02
|
||||
051 | 2026-01-16 01:30:00 - 2026-01-16 02:00:00 | None | Heavy Movie 01
|
||||
052 | 2026-01-16 02:00:00 - 2026-01-16 02:30:00 | None | Heavy Movie 02
|
||||
053 | 2026-01-16 02:30:00 - 2026-01-16 03:00:00 | None | Heavy Movie 01
|
||||
054 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Light Movie 04
|
||||
055 | 2026-01-16 03:30:00 - 2026-01-16 04:00:00 | None | Heavy Movie 02
|
||||
056 | 2026-01-16 04:00:00 - 2026-01-16 04:30:00 | None | Heavy Movie 01
|
||||
057 | 2026-01-16 04:30:00 - 2026-01-16 05:00:00 | None | Heavy Movie 02
|
||||
058 | 2026-01-16 05:00:00 - 2026-01-16 05:30:00 | None | Light Movie 01
|
||||
059 | 2026-01-16 05:30:00 - 2026-01-16 06:00:00 | None | Heavy Movie 01
|
||||
060 | 2026-01-16 06:00:00 - 2026-01-16 06:30:00 | None | Heavy Movie 02
|
||||
061 | 2026-01-16 06:30:00 - 2026-01-16 07:00:00 | None | Heavy Movie 01
|
||||
062 | 2026-01-16 07:00:00 - 2026-01-16 07:30:00 | None | Light Movie 03
|
||||
063 | 2026-01-16 07:30:00 - 2026-01-16 08:00:00 | None | Heavy Movie 02
|
||||
064 | 2026-01-16 08:00:00 - 2026-01-16 08:30:00 | None | Heavy Movie 01
|
||||
065 | 2026-01-16 08:30:00 - 2026-01-16 09:00:00 | None | Heavy Movie 02
|
||||
066 | 2026-01-16 09:00:00 - 2026-01-16 09:30:00 | None | Light Movie 01
|
||||
067 | 2026-01-16 09:30:00 - 2026-01-16 10:00:00 | None | Heavy Movie 01
|
||||
068 | 2026-01-16 10:00:00 - 2026-01-16 10:30:00 | None | Heavy Movie 02
|
||||
069 | 2026-01-16 10:30:00 - 2026-01-16 11:00:00 | None | Heavy Movie 01
|
||||
070 | 2026-01-16 11:00:00 - 2026-01-16 11:30:00 | None | Light Movie 02
|
||||
071 | 2026-01-16 11:30:00 - 2026-01-16 12:00:00 | None | Heavy Movie 02
|
||||
072 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Heavy Movie 01
|
||||
073 | 2026-01-16 12:30:00 - 2026-01-16 13:00:00 | None | Heavy Movie 02
|
||||
074 | 2026-01-16 13:00:00 - 2026-01-16 13:30:00 | None | Light Movie 03
|
||||
075 | 2026-01-16 13:30:00 - 2026-01-16 14:00:00 | None | Heavy Movie 01
|
||||
076 | 2026-01-16 14:00:00 - 2026-01-16 14:30:00 | None | Heavy Movie 02
|
||||
077 | 2026-01-16 14:30:00 - 2026-01-16 15:00:00 | None | Heavy Movie 01
|
||||
078 | 2026-01-16 15:00:00 - 2026-01-16 15:30:00 | None | Light Movie 04
|
||||
079 | 2026-01-16 15:30:00 - 2026-01-16 16:00:00 | None | Heavy Movie 02
|
||||
080 | 2026-01-16 16:00:00 - 2026-01-16 16:30:00 | None | Heavy Movie 01
|
||||
081 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Heavy Movie 02
|
||||
082 | 2026-01-16 17:00:00 - 2026-01-16 17:30:00 | None | Light Movie 03
|
||||
083 | 2026-01-16 17:30:00 - 2026-01-16 18:00:00 | None | Heavy Movie 01
|
||||
084 | 2026-01-16 18:00:00 - 2026-01-16 18:30:00 | None | Heavy Movie 02
|
||||
085 | 2026-01-16 18:30:00 - 2026-01-16 19:00:00 | None | Heavy Movie 01
|
||||
086 | 2026-01-16 19:00:00 - 2026-01-16 19:30:00 | None | Light Movie 02
|
||||
087 | 2026-01-16 19:30:00 - 2026-01-16 20:00:00 | None | Heavy Movie 02
|
||||
088 | 2026-01-16 20:00:00 - 2026-01-16 20:30:00 | None | Heavy Movie 01
|
||||
089 | 2026-01-16 20:30:00 - 2026-01-16 21:00:00 | None | Heavy Movie 02
|
||||
090 | 2026-01-16 21:00:00 - 2026-01-16 21:30:00 | None | Light Movie 04
|
||||
091 | 2026-01-16 21:30:00 - 2026-01-16 22:00:00 | None | Heavy Movie 01
|
||||
092 | 2026-01-16 22:00:00 - 2026-01-16 22:30:00 | None | Heavy Movie 02
|
||||
093 | 2026-01-16 22:30:00 - 2026-01-16 23:00:00 | None | Heavy Movie 01
|
||||
094 | 2026-01-16 23:00:00 - 2026-01-16 23:30:00 | None | Light Movie 01
|
||||
095 | 2026-01-16 23:30:00 - 2026-01-17 00:00:00 | None | Heavy Movie 02
|
||||
096 | 2026-01-17 00:00:00 - 2026-01-17 00:30:00 | None | Heavy Movie 01
|
||||
097 | 2026-01-17 00:30:00 - 2026-01-17 01:00:00 | None | Heavy Movie 02
|
||||
098 | 2026-01-17 01:00:00 - 2026-01-17 01:30:00 | None | Light Movie 04
|
||||
099 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Heavy Movie 01
|
||||
100 | 2026-01-17 02:00:00 - 2026-01-17 02:30:00 | None | Heavy Movie 02
|
||||
101 | 2026-01-17 02:30:00 - 2026-01-17 03:00:00 | None | Heavy Movie 01
|
||||
102 | 2026-01-17 03:00:00 - 2026-01-17 03:30:00 | None | Light Movie 02
|
||||
103 | 2026-01-17 03:30:00 - 2026-01-17 04:00:00 | None | Heavy Movie 02
|
||||
104 | 2026-01-17 04:00:00 - 2026-01-17 04:30:00 | None | Heavy Movie 01
|
||||
105 | 2026-01-17 04:30:00 - 2026-01-17 05:00:00 | None | Heavy Movie 02
|
||||
106 | 2026-01-17 05:00:00 - 2026-01-17 05:30:00 | None | Light Movie 01
|
||||
107 | 2026-01-17 05:30:00 - 2026-01-17 06:00:00 | None | Heavy Movie 01
|
||||
@@ -3,7 +3,6 @@ using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -87,53 +86,6 @@ public class PlayoutBuildGoldenTests
|
||||
[Test]
|
||||
public Task Block_playout() => Verify("block.txt", BuildBlockPlayout);
|
||||
|
||||
// Issue #77: clock-boundary padding. A PostRoll FillerPreset with FillerMode.Pad +
|
||||
// PadToNearestMinute = 15 snaps each ragged content item up to the next :00/:15/:30/:45 mark, filling
|
||||
// the gap with filler. This locks BOTH the exact builder output (golden) AND the invariant that every
|
||||
// content item after the first begins on a quarter-hour boundary — the end-to-end proof that the
|
||||
// existing Pad machinery already delivers #77's "clean clock-aligned guides" for Classic playouts.
|
||||
[Test]
|
||||
public async Task Classic_clock_padded()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout();
|
||||
|
||||
List<PlayoutItem> content = items
|
||||
.Where(i => i.FillerKind == FillerKind.None)
|
||||
.OrderBy(i => i.Start)
|
||||
.ToList();
|
||||
|
||||
// Sanity: the fixture must actually produce several content items and pad filler between them.
|
||||
content.Count.ShouldBeGreaterThan(3);
|
||||
items.ShouldContain(i => i.FillerKind == FillerKind.PostRoll);
|
||||
|
||||
// The invariant: content items (the first is the raw anchor at :00) start on a :15 boundary because
|
||||
// the preceding item was padded up to it. Raw Start is UTC here, matching the deterministic fixture.
|
||||
foreach (PlayoutItem item in content.Skip(1))
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0);
|
||||
}
|
||||
|
||||
await CompareGolden("classic-clock-padded.txt", items, titles);
|
||||
}
|
||||
|
||||
// Classic + PlaybackOrder.Shuffle: exercises PlayoutBuilder's call into the shuffle-source helper
|
||||
// (GetGroupedMediaItemsForShuffle) that #380 moves to ShuffleSourceBuilder, plus the wiring into
|
||||
// ShuffledMediaCollectionEnumerator. Unlike the chronological fixture, shuffle output depends on the
|
||||
// seed, so this build pins playout.Seed and uses Continue mode (Reset would randomize the seed).
|
||||
[Test]
|
||||
public Task Classic_shuffle() => Verify("classic-shuffle.txt", BuildShufflePlayout);
|
||||
|
||||
// Classic + PlaybackOrder.WeightedShuffle (#70): the weight only means anything end-to-end if it survives
|
||||
// MultiCollection -> MediaCollectionRepository -> CollectionWithItems.Weight -> ShuffleSourceBuilder ->
|
||||
// WeightedShuffleCollectionEnumerator. The unit tests pin the enumerator's sequence in isolation; this pins
|
||||
// that the real builder actually distributes by weight. The fixture is deliberately lopsided -- the LIGHTER
|
||||
// source (weight 1) is the LARGER collection (4 items vs 2) -- so a golden that merely tracked collection
|
||||
// size, or that ignored weight, would look obviously different from one that honors a 3:1 ratio.
|
||||
// Same determinism contract as Classic_shuffle: pinned Seed + Continue (Reset would randomize the seed).
|
||||
[Test]
|
||||
public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);
|
||||
|
||||
[Test]
|
||||
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
|
||||
public async Task Regenerate_goldens()
|
||||
@@ -141,8 +93,7 @@ public class PlayoutBuildGoldenTests
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", "1");
|
||||
try
|
||||
{
|
||||
foreach (Func<Task> regen in new Func<Task>[]
|
||||
{ Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle })
|
||||
foreach (Func<Task> regen in new Func<Task>[] { Classic_chronological, Block_playout })
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -167,13 +118,7 @@ public class PlayoutBuildGoldenTests
|
||||
Func<Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)>> build)
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await build();
|
||||
await CompareGolden(goldenName, items, titles);
|
||||
}
|
||||
|
||||
// Snapshot + golden-compare (or regenerate). Split out of Verify so a test that also runs explicit
|
||||
// assertions on the built items (e.g. Classic_clock_padded) can reuse the same golden mechanics.
|
||||
private async Task CompareGolden(string goldenName, List<PlayoutItem> items, Dictionary<int, string> titles)
|
||||
{
|
||||
string actual = Canonicalize(Snapshot(items, titles));
|
||||
|
||||
string path = Path.Combine(GoldenDir(), goldenName);
|
||||
@@ -240,329 +185,6 @@ public class PlayoutBuildGoldenTests
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildShufflePlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedShuffleData(cancellationToken);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
|
||||
|
||||
// Continue (not Reset): Reset randomizes playout.Seed, which would make the shuffle
|
||||
// nondeterministic. The pinned Seed (set in SeedShuffleData) flows to the enumerator seed, so the
|
||||
// shuffled order is stable. Anchor is null and there are no existing items, so Continue still
|
||||
// builds the full pinned 2-day window from the start.
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Continue,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedShuffleData(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Shuffle LibraryPath" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= 6; i++)
|
||||
{
|
||||
var movie = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
|
||||
},
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"Shuffle Movie {i:D2}",
|
||||
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
};
|
||||
movies.Add(movie);
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||||
|
||||
var collection = new Collection
|
||||
{
|
||||
Name = "Shuffle Test Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddAsync(collection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlayoutDuration = TimeSpan.FromHours(1),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
}
|
||||
};
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Shuffle FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000004"))
|
||||
{
|
||||
Name = "Shuffle Test Channel",
|
||||
Number = "4",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule { Name = "Shuffle Test Schedule", Items = scheduleItems };
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic,
|
||||
// Pinned so the shuffle is deterministic; Continue mode preserves it (Reset would overwrite).
|
||||
Seed = 1234567
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildWeightedPlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedWeightedData(cancellationToken);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Continue,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedWeightedData(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Weighted LibraryPath" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Titles carry their source so the golden reads as a rotation rather than a list of ids.
|
||||
// "Heavy" is the SMALL collection with weight 3; "Light" is the LARGE one with weight 1.
|
||||
async Task<List<Movie>> SeedMovies(string prefix, int count)
|
||||
{
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
movies.Add(
|
||||
new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(30) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"{prefix} Movie {i:D2}",
|
||||
ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return movies;
|
||||
}
|
||||
|
||||
List<Movie> heavy = await SeedMovies("Heavy", 2);
|
||||
List<Movie> light = await SeedMovies("Light", 4);
|
||||
|
||||
var titles = heavy.Concat(light).ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||||
|
||||
var heavyCollection = new Collection
|
||||
{
|
||||
Name = "Weighted Heavy Collection",
|
||||
MediaItems = heavy.Cast<MediaItem>().ToList()
|
||||
};
|
||||
var lightCollection = new Collection
|
||||
{
|
||||
Name = "Weighted Light Collection",
|
||||
MediaItems = light.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddRangeAsync([heavyCollection, lightCollection], cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var multiCollection = new MultiCollection
|
||||
{
|
||||
Name = "Weighted Multi Collection",
|
||||
MultiCollectionItems =
|
||||
[
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = heavyCollection.Id,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 3
|
||||
},
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = lightCollection.Id,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 1
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.MultiCollections.AddAsync(multiCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
MultiCollection = multiCollection,
|
||||
MultiCollectionId = multiCollection.Id,
|
||||
CollectionType = CollectionType.MultiCollection,
|
||||
PlayoutDuration = TimeSpan.FromHours(1),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle
|
||||
}
|
||||
};
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Weighted FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000005"))
|
||||
{
|
||||
Name = "Weighted Test Channel",
|
||||
Number = "5",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule { Name = "Weighted Test Schedule", Items = scheduleItems };
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic,
|
||||
// Pinned so the weighted rotation is deterministic; Continue preserves it (Reset would overwrite).
|
||||
Seed = 7654321
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
@@ -687,210 +309,6 @@ public class PlayoutBuildGoldenTests
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// --- Clock-boundary pad builder (issue #77) ---
|
||||
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedPaddedData(cancellationToken);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetPaddedReferenceData(context, playoutId);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Reset,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedPaddedData(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Padded LibraryPath" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Content: three movies with OFF-boundary durations (22/37/52 min) so padding to :15 is visible.
|
||||
int[] durationsMinutes = [22, 37, 52];
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
movies.Add(new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new() { Title = $"Padded Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
// Filler: a SINGLE 1-minute clip. The builder shuffles filler collections (seed-dependent), so a
|
||||
// one-item collection keeps the golden deterministic while still filling any integer-minute gap.
|
||||
var fillerClip = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new() { Title = "Filler Clip", ReleaseDate = new DateTime(2019, 1, 1) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
};
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.Movies.AddAsync(fillerClip, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||||
titles[fillerClip.Id] = fillerClip.MovieMetadata[0].Title;
|
||||
|
||||
var contentCollection = new Collection
|
||||
{
|
||||
Name = "Padded Content Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
var fillerCollection = new Collection
|
||||
{
|
||||
Name = "Padded Filler Collection",
|
||||
MediaItems = new List<MediaItem> { fillerClip }
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
await context.Collections.AddAsync(fillerCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// TZ-INDEPENDENCE INVARIANT: the pad ceiling is computed on the LOCAL minute-of-hour
|
||||
// (PlayoutModeSchedulerBase uses StartOffset = ToLocalTime()), yet this golden snapshots raw UTC
|
||||
// Start/Finish. Those agree across machine timezones ONLY because PadToNearestMinute = 15 divides
|
||||
// every real IANA UTC offset (all are multiples of 15 min — Kolkata +5:30, Nepal +5:45, Chatham
|
||||
// +12:45), so the pad delta is offset-invariant. Do NOT regenerate this golden with a non-15-divisor
|
||||
// increment (e.g. 10) — it would become machine-TZ dependent and need the Block-style Assume guard.
|
||||
var padFiller = new FillerPreset
|
||||
{
|
||||
Name = "Pad To Quarter Hour",
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
FillerMode = FillerMode.Pad,
|
||||
PadToNearestMinute = 15,
|
||||
CollectionType = CollectionType.Collection,
|
||||
Collection = fillerCollection,
|
||||
CollectionId = fillerCollection.Id
|
||||
};
|
||||
await context.FillerPresets.AddAsync(padFiller, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
PostRollFiller = padFiller,
|
||||
PostRollFillerId = padFiller.Id
|
||||
}
|
||||
};
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Padded FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000003"))
|
||||
{
|
||||
Name = "Padded Test Channel",
|
||||
Number = "3",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule { Name = "Padded Test Schedule", Items = scheduleItems };
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private static async Task<PlayoutReferenceData> GetPaddedReferenceData(TvContext dbContext, int playoutId)
|
||||
{
|
||||
Channel channel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
|
||||
.AsNoTracking()
|
||||
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
|
||||
.Include(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return new PlayoutReferenceData(
|
||||
channel,
|
||||
Option<Deco>.None,
|
||||
[],
|
||||
[],
|
||||
programSchedule,
|
||||
[],
|
||||
[],
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// --- Block builder ---
|
||||
//
|
||||
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
|
||||
|
||||
@@ -357,82 +357,6 @@ public class PlaylistEnumeratorTests
|
||||
enumerator.CountForFiller.ShouldBe(7);
|
||||
}
|
||||
|
||||
// Characterization of the cross-engine reach-in surface (#380): a playlist whose items use Shuffle
|
||||
// and ShuffleInOrder exercises PlaylistEnumerator.Create's two calls into PlayoutBuilder's static
|
||||
// shuffle-source helpers (GetGroupedMediaItemsForShuffle @ :174, GetCollectionItemsForShuffleInOrder
|
||||
// @ :185). Those statics move to ShuffleSourceBuilder; this pins the emitted sequence so the move is
|
||||
// proven behavior-preserving. Fixed seed => deterministic. If this changes, the refactor drifted.
|
||||
[Test]
|
||||
public async Task ReachIn_Shuffle_And_ShuffleInOrder_Items_Are_Characterized()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
|
||||
// ShuffleInOrder (collection 2) reads its source from the repo's fake-multi-collection lookup.
|
||||
repo.GetFakeMultiCollectionCollections(2, null, Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
ShowId: 0,
|
||||
ArtistId: 0,
|
||||
Key: "collection-2",
|
||||
MediaItems: [FakeMovie(20), FakeMovie(21), FakeMovie(22)],
|
||||
ScheduleAsGroup: false,
|
||||
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
|
||||
UseCustomOrder: false)
|
||||
});
|
||||
|
||||
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
|
||||
{
|
||||
{
|
||||
new PlaylistItem
|
||||
{
|
||||
Id = 1,
|
||||
Index = 0,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
PlayAll = true,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 1
|
||||
},
|
||||
[FakeMovie(10), FakeMovie(11), FakeMovie(12)]
|
||||
},
|
||||
{
|
||||
new PlaylistItem
|
||||
{
|
||||
Id = 2,
|
||||
Index = 1,
|
||||
PlaybackOrder = PlaybackOrder.ShuffleInOrder,
|
||||
PlayAll = true,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 2
|
||||
},
|
||||
[FakeMovie(20), FakeMovie(21), FakeMovie(22)]
|
||||
}
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 12345 };
|
||||
|
||||
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
|
||||
repo,
|
||||
playlistItemMap,
|
||||
state,
|
||||
shufflePlaylistItems: false,
|
||||
batchSize: Option<int>.None,
|
||||
CancellationToken.None);
|
||||
|
||||
var items = new List<int>();
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
items.AddRange(enumerator.Current.Map(mi => mi.Id));
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
// Pinned from current behavior: Shuffle collection (10-12) and ShuffleInOrder collection (20-22)
|
||||
// each cycle in a seed-stable shuffled order. This is the reach-in output; the ShuffleSourceBuilder
|
||||
// extraction must reproduce it byte-for-byte.
|
||||
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
|
||||
}
|
||||
|
||||
private static Movie FakeMovie(int id) => new()
|
||||
{
|
||||
Id = id,
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
// Direct unit coverage for the shuffle-source helper extracted from PlayoutBuilder (#380). The builder
|
||||
// goldens exercise the common non-multi-collection path; these pin the branch selection (multi-collection
|
||||
// vs fake-multi-collection lookup, multi-part grouping on/off) that the goldens don't reach, so the
|
||||
// "one well-tested place per family" the issue asks for is genuinely tested here.
|
||||
[TestFixture]
|
||||
public class ShuffleSourceBuilderTests
|
||||
{
|
||||
[Test]
|
||||
public async Task GetGroupedMediaItemsForShuffle_Single_Collection_No_MultiPart_Groups_Each_Item()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
List<MediaItem> items = [FakeMovie(1), FakeMovie(2), FakeMovie(3)];
|
||||
var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 7 };
|
||||
|
||||
List<GroupedMediaItem> result = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
|
||||
repo,
|
||||
keepMultiPartEpisodesTogether: false,
|
||||
treatCollectionsAsShows: false,
|
||||
items,
|
||||
key,
|
||||
CancellationToken.None);
|
||||
|
||||
// No multi-part grouping and no multi-collection => each item is its own group, no repo call.
|
||||
result.Count.ShouldBe(3);
|
||||
result.Select(g => g.First.Id).ShouldBe([1, 2, 3]);
|
||||
result.ShouldAllBe(g => g.Additional.Count == 0);
|
||||
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGroupedMediaItemsForShuffle_MultiCollection_Reads_From_Repo()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
repo.GetMultiCollectionCollections(42, Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
ShowId: 0,
|
||||
ArtistId: 0,
|
||||
Key: "mc",
|
||||
MediaItems: [FakeMovie(10), FakeMovie(11)],
|
||||
ScheduleAsGroup: false,
|
||||
PlaybackOrder: PlaybackOrder.Shuffle,
|
||||
UseCustomOrder: false)
|
||||
});
|
||||
|
||||
var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 42 };
|
||||
|
||||
List<GroupedMediaItem> result = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
|
||||
repo,
|
||||
keepMultiPartEpisodesTogether: false,
|
||||
treatCollectionsAsShows: false,
|
||||
// the passed items list is ignored on the multi-collection branch
|
||||
[],
|
||||
key,
|
||||
CancellationToken.None);
|
||||
|
||||
// The multi-collection branch reads from the repo and feeds MultiCollectionGrouper (whose grouping
|
||||
// behavior is covered by its own tests); here we only pin the branch selection + passthrough.
|
||||
await repo.Received(1).GetMultiCollectionCollections(42, Arg.Any<CancellationToken>());
|
||||
GroupedMediaItem.FlattenGroups(result, 2).Select(mi => mi.Id).OrderBy(id => id).ShouldBe([10, 11]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetCollectionItemsForShuffleInOrder_Single_Collection_Uses_Fake_MultiCollection_Lookup()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
var expected = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
ShowId: 0,
|
||||
ArtistId: 0,
|
||||
Key: "fake",
|
||||
MediaItems: [FakeMovie(20)],
|
||||
ScheduleAsGroup: false,
|
||||
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
|
||||
UseCustomOrder: false)
|
||||
};
|
||||
repo.GetFakeMultiCollectionCollections(5, null, Arg.Any<CancellationToken>()).Returns(expected);
|
||||
|
||||
var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 5 };
|
||||
|
||||
List<CollectionWithItems> result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
||||
repo,
|
||||
key,
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
await repo.Received(1).GetFakeMultiCollectionCollections(5, null, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetCollectionItemsForShuffleInOrder_MultiCollection_Uses_MultiCollection_Lookup()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
var expected = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
ShowId: 0,
|
||||
ArtistId: 0,
|
||||
Key: "mc",
|
||||
MediaItems: [FakeMovie(30), FakeMovie(31)],
|
||||
ScheduleAsGroup: true,
|
||||
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
|
||||
UseCustomOrder: false)
|
||||
};
|
||||
repo.GetMultiCollectionCollections(9, Arg.Any<CancellationToken>()).Returns(expected);
|
||||
|
||||
var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 9 };
|
||||
|
||||
List<CollectionWithItems> result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
||||
repo,
|
||||
key,
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
await repo.Received(1).GetMultiCollectionCollections(9, Arg.Any<CancellationToken>());
|
||||
await repo.DidNotReceiveWithAnyArgs().GetFakeMultiCollectionCollections(default, default, default);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGroupedMediaItemsForShuffle_KeepMultiPart_Groups_Adjacent_Parts()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
// "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together.
|
||||
List<MediaItem> items =
|
||||
[
|
||||
NamedEpisode("Episode 1", 1),
|
||||
NamedEpisode("Episode 2 (1)", 2),
|
||||
NamedEpisode("Episode 3 (2)", 3),
|
||||
NamedEpisode("Episode 4", 4)
|
||||
];
|
||||
|
||||
List<GroupedMediaItem> kept = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
|
||||
repo,
|
||||
keepMultiPartEpisodesTogether: true,
|
||||
treatCollectionsAsShows: false,
|
||||
items,
|
||||
new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 1 },
|
||||
CancellationToken.None);
|
||||
|
||||
// keep=true routes through MultiPartEpisodeGrouper -> the two parts collapse into one group (3 total).
|
||||
kept.Count.ShouldBe(3);
|
||||
|
||||
List<GroupedMediaItem> notKept = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
|
||||
repo,
|
||||
keepMultiPartEpisodesTogether: false,
|
||||
treatCollectionsAsShows: false,
|
||||
items,
|
||||
new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 1 },
|
||||
CancellationToken.None);
|
||||
|
||||
// keep=false leaves each item its own group (4 total) — proves the flag observably selects the branch.
|
||||
notKept.Count.ShouldBe(4);
|
||||
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
|
||||
}
|
||||
|
||||
private static Episode NamedEpisode(string title, int id) => new()
|
||||
{
|
||||
Id = id,
|
||||
EpisodeMetadata = [new EpisodeMetadata { Title = title, EpisodeNumber = id }],
|
||||
Season = new Season { SeasonNumber = 1, Show = new Show { Id = 1 }, ShowId = 1 }
|
||||
};
|
||||
|
||||
private static Movie FakeMovie(int id) => new()
|
||||
{
|
||||
Id = id,
|
||||
MediaVersions = [],
|
||||
MovieMetadata =
|
||||
[
|
||||
new MovieMetadata { ReleaseDate = new DateTime(2020, 1, id) }
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the weighted / fair-share distribution contract (#70). The sequence semantics here are the
|
||||
/// product decision, so they are asserted exactly rather than statistically.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WeightedShuffleCollectionEnumeratorTests
|
||||
{
|
||||
// ids are allocated per source so an emitted item's source is identifiable from its id
|
||||
private const int SourceAFirstId = 100;
|
||||
private const int SourceBFirstId = 200;
|
||||
private const int SourceCFirstId = 300;
|
||||
|
||||
private static CollectionWithItems Source(string key, int firstId, int itemCount, int weight) =>
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
key,
|
||||
Enumerable.Range(firstId, itemCount)
|
||||
.Select(i => new Movie { Id = i, MovieMetadata = [] })
|
||||
.Cast<MediaItem>()
|
||||
.ToList(),
|
||||
true,
|
||||
PlaybackOrder.WeightedShuffle,
|
||||
false,
|
||||
weight);
|
||||
|
||||
private static string SourceOf(int id) => id switch
|
||||
{
|
||||
>= SourceCFirstId => "C",
|
||||
>= SourceBFirstId => "B",
|
||||
_ => "A"
|
||||
};
|
||||
|
||||
private static List<string> TakeSourceSequence(WeightedShuffleCollectionEnumerator enumerator, int count)
|
||||
{
|
||||
var result = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
enumerator.Current.IsSome.ShouldBeTrue();
|
||||
result.Add(SourceOf(enumerator.Current.ValueUnsafe().Id));
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Weights_Three_To_One_Emit_A_A_B_A()
|
||||
{
|
||||
// the canonical smooth-WRR contract: 3:1 spreads B through the rotation (A A B A),
|
||||
// rather than draining A first (A A A B) the way PlaylistItem.Count does
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 3, 3),
|
||||
Source("B", SourceBFirstId, 1, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "B", "A"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Equal_Weights_Are_Fair_Share_Regardless_Of_Collection_Size()
|
||||
{
|
||||
// the heart of goal (2): a 20-item source must air as often as a 2-item source.
|
||||
// ShuffleInOrder cannot do this -- it plays every item once, so airtime tracks size.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 20, 1),
|
||||
Source("B", SourceBFirstId, 2, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
List<string> sequence = TakeSourceSequence(enumerator, 40);
|
||||
|
||||
// equal weights => strict alternation, so the small source loops rather than falling silent
|
||||
sequence.Count(s => s == "A").ShouldBe(20);
|
||||
sequence.Count(s => s == "B").ShouldBe(20);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ties_Break_To_The_Earliest_Source_In_List_Order()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 4, 1),
|
||||
Source("B", SourceBFirstId, 4, 1),
|
||||
Source("C", SourceCFirstId, 4, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 99, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// all accumulators tie every round, so list order decides
|
||||
TakeSourceSequence(enumerator, 6).ShouldBe(["A", "B", "C", "A", "B", "C"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Unweighted_Collection_Is_Fair_Share()
|
||||
{
|
||||
// a source arriving without an explicit weight must rotate as fair-share, never fall out
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(0, 0, "A", [new Movie { Id = SourceAFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false),
|
||||
new(0, 0, "B", [new Movie { Id = SourceBFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 7, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(0)]
|
||||
[TestCase(-5)]
|
||||
public void A_Non_Positive_Weight_Does_Not_Delete_The_Source(int weight)
|
||||
{
|
||||
// the write path bounds weight, but a row can predate that gate. Treating a 0/negative weight as a
|
||||
// filter would remove the source from the channel silently -- the exact failure this order avoids
|
||||
// everywhere else. It is clamped to the floor instead.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, 1),
|
||||
Source("B", SourceBFirstId, 2, weight)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 21, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 8).ShouldContain("B");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Enormous_Weight_Does_Not_Overflow_The_Rotation()
|
||||
{
|
||||
// summing unclamped weights is checked arithmetic, so an out-of-range row would throw from inside a
|
||||
// playout build rather than merely schedule oddly
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, int.MaxValue),
|
||||
Source("B", SourceBFirstId, 2, int.MaxValue)
|
||||
};
|
||||
|
||||
WeightedShuffleCollectionEnumerator enumerator = null;
|
||||
Should.NotThrow(() => enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 5, Index = 0 },
|
||||
CancellationToken.None));
|
||||
|
||||
// both clamp to the ceiling, so they tie and alternate by list order
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(12)]
|
||||
[TestCase(13)]
|
||||
[TestCase(20)]
|
||||
[TestCase(37)]
|
||||
public void Restoring_Past_A_Rotation_Wrap_Equals_Advancing_To_It(int target)
|
||||
{
|
||||
// the stateless claim has to hold ACROSS a wrap, not just within the first rotation: each wrap
|
||||
// re-derives the rotation from the new seed alone, so (Seed, Index) still determines position.
|
||||
// Cycle length here is 12, so every case but the first crosses at least one wrap.
|
||||
List<CollectionWithItems> Collections() =>
|
||||
[
|
||||
Source("A", SourceAFirstId, 5, 3),
|
||||
Source("B", SourceBFirstId, 3, 1)
|
||||
];
|
||||
|
||||
const int Seed = 909;
|
||||
|
||||
var advanced = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
|
||||
CancellationToken.None);
|
||||
for (var i = 0; i < target; i++)
|
||||
{
|
||||
advanced.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
var restored = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = advanced.State.Seed, Index = advanced.State.Index },
|
||||
CancellationToken.None);
|
||||
|
||||
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Restoring_At_An_Index_Equals_Advancing_To_It()
|
||||
{
|
||||
// the stateless contract: (Seed, Index) fully determines position, which is what lets the
|
||||
// existing CollectionEnumeratorState persistence carry this order with no per-source counters
|
||||
List<CollectionWithItems> Collections() =>
|
||||
[
|
||||
Source("A", SourceAFirstId, 5, 3),
|
||||
Source("B", SourceBFirstId, 3, 1)
|
||||
];
|
||||
|
||||
const int Seed = 4242;
|
||||
const int Target = 7;
|
||||
|
||||
var advanced = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
|
||||
CancellationToken.None);
|
||||
for (var i = 0; i < Target; i++)
|
||||
{
|
||||
advanced.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
var restored = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = Target },
|
||||
CancellationToken.None);
|
||||
|
||||
restored.State.Index.ShouldBe(advanced.State.Index);
|
||||
restored.State.Seed.ShouldBe(advanced.State.Seed);
|
||||
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Single_Source_Emits_All_Of_Its_Items()
|
||||
{
|
||||
var collections = new List<CollectionWithItems> { Source("A", SourceAFirstId, 5, 3) };
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 11, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
var seen = new System.Collections.Generic.HashSet<int>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
seen.Add(enumerator.Current.ValueUnsafe().Id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
seen.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Empty_Source_Is_Ignored_Rather_Than_Emitting_Nothing()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, 1),
|
||||
new(0, 0, "empty", [], true, PlaybackOrder.WeightedShuffle, false, 5)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 3, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// a heavily-weighted empty source must not starve the rotation or emit None
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "A", "A"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(10_000)]
|
||||
public void Single_Item_Sources_Do_Not_Hang_On_Rotation_Wrap()
|
||||
{
|
||||
// regression: the wrap retries a rebuild to avoid an immediate repeat, but this order's lead item is
|
||||
// decided by weight, so the heaviest source always leads. With one item in it the lead is invariant and
|
||||
// an unbounded retry never terminates -- a hung playout build, not a wrong one. Found by the
|
||||
// non-vacuity control, which hung instead of failing.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 1, 3),
|
||||
Source("B", SourceBFirstId, 1, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// walk several full rotations so the wrap path is exercised repeatedly
|
||||
List<string> sequence = TakeSourceSequence(enumerator, 24);
|
||||
|
||||
sequence.ShouldContain("A");
|
||||
sequence.ShouldContain("B");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Sources_Yields_No_Current()
|
||||
{
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
[],
|
||||
new CollectionEnumeratorState { Seed = 1, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
enumerator.Current.IsNone.ShouldBeTrue();
|
||||
enumerator.Count.ShouldBe(0);
|
||||
Should.NotThrow(() => enumerator.MoveNext(Option<DateTimeOffset>.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Custom_Ordered_Source_Keeps_Its_Order()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"A",
|
||||
Enumerable.Range(SourceAFirstId, 4)
|
||||
.Select(i => new Movie { Id = i, MovieMetadata = [] })
|
||||
.Cast<MediaItem>()
|
||||
.ToList(),
|
||||
true,
|
||||
PlaybackOrder.WeightedShuffle,
|
||||
true,
|
||||
1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 555, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
var ids = new List<int>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
ids.Add(enumerator.Current.ValueUnsafe().Id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
ids.ShouldBe([SourceAFirstId, SourceAFirstId + 1, SourceAFirstId + 2, SourceAFirstId + 3]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable enable
|
||||
#nullable enable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
@@ -15,5 +15,4 @@ public record ChannelResponseModel(
|
||||
string Language,
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg,
|
||||
int PlayoutCount);
|
||||
bool ShowInEpg);
|
||||
|
||||
@@ -5,14 +5,4 @@ public record HealthCheckResponseModel(
|
||||
string Title,
|
||||
string Status,
|
||||
string Detail,
|
||||
string? Brief,
|
||||
// Deprecated: the raw remediation target, kept for back-compat. Prefer `Remediation`,
|
||||
// which also carries the kind (external doc vs in-app route). Still populated.
|
||||
string? Link,
|
||||
HealthCheckRemediationResponseModel? Remediation);
|
||||
|
||||
// Where the user should go to investigate or fix the check. `Kind` is "ExternalDoc"
|
||||
// (open `Target` as an external URL) or "AppRoute" (navigate to the `Target` /app path).
|
||||
public record HealthCheckRemediationResponseModel(
|
||||
string Kind,
|
||||
string Target);
|
||||
string? Link);
|
||||
|
||||
@@ -13,8 +13,7 @@ public record MultiCollectionItemResponseModel(
|
||||
int? SmartCollectionId,
|
||||
string Name,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record PagedMultiCollectionsResponseModel(
|
||||
int TotalCount,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Search;
|
||||
|
||||
/// <summary>
|
||||
/// One filterable field in the visual rule builder's catalog.
|
||||
/// <c>Type</c> is one of: text, fulltext, number, date, enum.
|
||||
/// <c>Values</c> is populated only for <c>enum</c> fields (allowed dropdown values); empty otherwise.
|
||||
/// </summary>
|
||||
public record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values);
|
||||
@@ -11,5 +11,4 @@ public record SearchResultAllItemsResponseModel(
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds,
|
||||
List<int> RemoteStreamIds,
|
||||
SearchResultAllItemsTotalsResponseModel Totals);
|
||||
List<int> RemoteStreamIds);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Search;
|
||||
|
||||
// Per-kind total hit counts for a search all-items query, independent of the current page. Lets a
|
||||
// client page GET /api/v1/search/all-items to completeness (the SPA "add all" flow) instead of
|
||||
// materializing every id in one unbounded response (issue #293).
|
||||
public record SearchResultAllItemsTotalsResponseModel(
|
||||
int MovieCount,
|
||||
int ShowCount,
|
||||
int SeasonCount,
|
||||
int EpisodeCount,
|
||||
int ArtistCount,
|
||||
int MusicVideoCount,
|
||||
int OtherVideoCount,
|
||||
int SongCount,
|
||||
int ImageCount,
|
||||
int RemoteStreamCount);
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -12,12 +12,4 @@ public class MultiCollection : IVersionedAggregate
|
||||
public List<SmartCollection> SmartCollections { get; set; }
|
||||
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
|
||||
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When non-null, this multi collection is a system-owned artifact created for an auto-tune weighted
|
||||
/// channel (#425): its members are the per-content-source SmartCollections that carry the rotation
|
||||
/// weights. Hidden from the user-facing collection lists and deleted when the channel is deleted.
|
||||
/// Null for every user-created multi collection.
|
||||
/// </summary>
|
||||
public int? OwnedByChannelId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class MultiCollectionItem
|
||||
{
|
||||
@@ -8,10 +8,4 @@ public class MultiCollectionItem
|
||||
public Collection Collection { get; set; }
|
||||
public bool ScheduleAsGroup { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative share of airtime under <see cref="PlaybackOrder.WeightedShuffle" /> (#70). Defaults to 1,
|
||||
/// which is fair-share. Ignored by every other playback order.
|
||||
/// </summary>
|
||||
public int Weight { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds for a multi-collection member's per-source <c>Weight</c> (#70).
|
||||
/// <para>
|
||||
/// The floor is <b>1, not 0</b>. A weight is a <i>share of airtime</i>, and 0 has no meaning on that scale —
|
||||
/// "don't play this source" is expressed by removing it from the multi collection, not by weighting it to
|
||||
/// nothing. Rejecting it here keeps that intent from being expressed in a way the rotation would have to
|
||||
/// interpret. <c>WeightedShuffleCollectionEnumerator</c> independently clamps to this range rather than
|
||||
/// trusting it, so a row that predates this gate rotates at the floor instead of vanishing; the two are
|
||||
/// belt-and-braces, not duplicates — <b>neither is redundant</b>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The ceiling is the same argument as the floor, not an arithmetic guard. A weight of a billion is not a
|
||||
/// share of airtime any more than 0 is, so it is refused at the boundary rather than silently reinterpreted.
|
||||
/// The rotation arithmetic is made safe by <c>EffectiveWeight</c>'s clamp (and by <c>CycleLength</c> summing
|
||||
/// to <c>long</c>) — <b>not</b> by this constant: with the clamp in place a sum cannot overflow whatever the
|
||||
/// stored value is, which is exactly why a row that predates this gate is still safe.
|
||||
/// <c>An_Enormous_Weight_Does_Not_Overflow_The_Rotation</c> pins that.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MultiCollectionItemWeight
|
||||
{
|
||||
public const int Minimum = 1;
|
||||
public const int Maximum = 1000;
|
||||
public const int Default = 1;
|
||||
|
||||
public static bool IsValid(int weight) => weight is >= Minimum and <= Maximum;
|
||||
|
||||
public static string ValidationMessage =>
|
||||
$"Weight must be between {Minimum} and {Maximum}";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class MultiCollectionSmartItem
|
||||
{
|
||||
@@ -8,10 +8,4 @@ public class MultiCollectionSmartItem
|
||||
public SmartCollection SmartCollection { get; set; }
|
||||
public bool ScheduleAsGroup { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative share of airtime under <see cref="PlaybackOrder.WeightedShuffle" /> (#70). Defaults to 1,
|
||||
/// which is fair-share. Ignored by every other playback order.
|
||||
/// </summary>
|
||||
public int Weight { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -10,12 +10,4 @@ public class SmartCollection
|
||||
public string Query { get; set; }
|
||||
public List<MultiCollection> MultiCollections { get; set; }
|
||||
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When non-null, this is a system-owned artifact created for an auto-tune weighted channel (#425):
|
||||
/// one per-content-source SmartCollection (or the catch-all remainder) inside that channel's
|
||||
/// MultiCollection. Hidden from the user-facing collection lists and deleted when the channel is
|
||||
/// deleted. Null for every user-created smart collection.
|
||||
/// </summary>
|
||||
public int? OwnedByChannelId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum PlaybackOrder
|
||||
{
|
||||
@@ -11,9 +11,5 @@ public enum PlaybackOrder
|
||||
MultiEpisodeShuffle = 5,
|
||||
SeasonEpisode = 6,
|
||||
RandomRotation = 7,
|
||||
Marathon = 8,
|
||||
|
||||
// weighted / fair-share distribution (#70); Classic schedule items only -- write-path validation
|
||||
// rejects it elsewhere, because the other engines silently drop or substitute an unknown order
|
||||
WeightedShuffle = 9
|
||||
Marathon = 8
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Heavy transcode features detected from a cold-start's FFmpeg arguments, so each HLS
|
||||
/// cold-start latency sample is self-describing (#350). Only cleanly-detectable signals are
|
||||
/// surfaced: subtitle burn-in (the libass <c>subtitles=</c> text filter) and the hardware
|
||||
/// acceleration family. Watermark, HDR->SDR, and <em>image</em>-based subtitle burn-in are
|
||||
/// intentionally NOT flagged here: in the argument string they all reduce to <c>overlay=</c>,
|
||||
/// indistinguishable from one another, so a boolean would mislead the very measurement it
|
||||
/// serves. Inspect the full <c>ffmpeg hls arguments</c> debug log for those details.
|
||||
/// Detection is a case-sensitive substring scan of the whole argument string (which includes
|
||||
/// the input media path), so a media filename literally containing e.g. <c>subtitles=</c> or
|
||||
/// <c>_amf</c> could false-positive; this is telemetry-only (no behavior impact) and low-probability.
|
||||
/// </summary>
|
||||
public readonly record struct ColdStartFeatures(bool SubtitleBurnIn, string HardwareAcceleration)
|
||||
{
|
||||
// Encoder/filter suffix -> accel family. Matching any of these substrings means the
|
||||
// pipeline is that hardware family (the encoder codec, e.g. h264_vaapi, dominates
|
||||
// cold-start encoder init cost; VAAPI/QSV/CUDA filters carry the same suffix).
|
||||
private static readonly (string Token, string Family)[] HardwareFamilies =
|
||||
[
|
||||
("_vaapi", "vaapi"),
|
||||
("_nvenc", "nvenc"),
|
||||
("_qsv", "qsv"),
|
||||
("_videotoolbox", "videotoolbox"),
|
||||
("_amf", "amf"),
|
||||
("_rkmpp", "rkmpp")
|
||||
];
|
||||
|
||||
public static ColdStartFeatures FromFFmpegArguments(string arguments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
return new ColdStartFeatures(false, "unknown");
|
||||
}
|
||||
|
||||
bool subtitleBurnIn = arguments.Contains("subtitles=", StringComparison.Ordinal);
|
||||
return new ColdStartFeatures(subtitleBurnIn, DetectHardwareAcceleration(arguments));
|
||||
}
|
||||
|
||||
private static string DetectHardwareAcceleration(string arguments)
|
||||
{
|
||||
foreach ((string token, string family) in HardwareFamilies)
|
||||
{
|
||||
if (arguments.Contains(token, StringComparison.Ordinal))
|
||||
{
|
||||
return family;
|
||||
}
|
||||
}
|
||||
|
||||
// No hardware encoder/filter present: fall back to the decode accel (a hardware
|
||||
// decode + software encode still pays an init cost worth attributing).
|
||||
const string HwAccelFlag = "-hwaccel ";
|
||||
int index = arguments.IndexOf(HwAccelFlag, StringComparison.Ordinal);
|
||||
if (index >= 0)
|
||||
{
|
||||
string rest = arguments[(index + HwAccelFlag.Length)..].TrimStart();
|
||||
int end = rest.IndexOf(' ');
|
||||
string value = end >= 0 ? rest[..end] : rest;
|
||||
if (value.Length > 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return "software";
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Timing + descriptive result of an HLS cold-start's wait-for-first-segments phase (#350).
|
||||
/// <see cref="ProcessStartup"/> is Phase A (wait entry -> playlist file exists — approximately
|
||||
/// FFmpeg process spawn + probe + libass/encoder init + first GOP, since the wait begins right
|
||||
/// after the fire-and-forget worker is launched); <see cref="SegmentFill"/> is Phase B (playlist
|
||||
/// exists -> the requested number of segments are present, or the 8s deadline).
|
||||
/// </summary>
|
||||
public readonly record struct PlaylistSegmentsResult(
|
||||
TimeSpan ProcessStartup,
|
||||
TimeSpan SegmentFill,
|
||||
int SegmentsReached,
|
||||
int InitialSegmentCount,
|
||||
bool DeadlineExpired,
|
||||
ColdStartFeatures Features);
|
||||
@@ -1,11 +1,3 @@
|
||||
namespace ErsatzTV.Core.Health;
|
||||
|
||||
// A remediation link attached to a health-check result: where the user should go to
|
||||
// investigate or fix the reported problem, and whether that is an external doc or an
|
||||
// in-app SPA route. `Target` is the URL (ExternalDoc) or the /app path (AppRoute).
|
||||
public record HealthCheckLink(string Target, HealthCheckLinkKind Kind)
|
||||
{
|
||||
public static HealthCheckLink ExternalDoc(string url) => new(url, HealthCheckLinkKind.ExternalDoc);
|
||||
|
||||
public static HealthCheckLink AppRoute(string path) => new(path, HealthCheckLinkKind.AppRoute);
|
||||
}
|
||||
public record HealthCheckLink(string Link);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ErsatzTV.Core.Health;
|
||||
|
||||
public enum HealthCheckLinkKind
|
||||
{
|
||||
// Opens an external documentation page in a new tab.
|
||||
ExternalDoc = 0,
|
||||
|
||||
// Navigates to an in-app SPA route (an /app/... path).
|
||||
AppRoute = 1
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
@@ -10,5 +10,5 @@ public interface IHlsSessionWorker : IDisposable
|
||||
void PlayoutUpdated();
|
||||
HlsSessionModel GetModel();
|
||||
Task Run(string channelNumber, Option<TimeSpan> idleTimeout, CancellationToken incomingCancellationToken);
|
||||
Task<PlaylistSegmentsResult> WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
|
||||
Task WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
@@ -9,5 +9,4 @@ public record CollectionWithItems(
|
||||
List<MediaItem> MediaItems,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
bool UseCustomOrder,
|
||||
int Weight = 1);
|
||||
bool UseCustomOrder);
|
||||
|
||||
@@ -1217,8 +1217,7 @@ public class SchedulingEngine(
|
||||
return _state;
|
||||
}
|
||||
|
||||
// instance rather than static so an unsupported order can be reported (#70)
|
||||
private Option<IMediaCollectionEnumerator> EnumeratorForContent(
|
||||
private static Option<IMediaCollectionEnumerator> EnumeratorForContent(
|
||||
List<MediaItem> items,
|
||||
CollectionEnumeratorState state,
|
||||
PlaybackOrder playbackOrder,
|
||||
@@ -1236,12 +1235,6 @@ public class SchedulingEngine(
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
}
|
||||
|
||||
// None means the caller's foreach never runs, so the content is simply absent from the playout with
|
||||
// nothing said. Report it instead of leaving a silently empty schedule (#70).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
|
||||
playbackOrder);
|
||||
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
}
|
||||
|
||||
|
||||
@@ -171,11 +171,10 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
|
||||
// TODO: fix multi episode shuffle?
|
||||
case PlaybackOrder.MultiEpisodeShuffle:
|
||||
case PlaybackOrder.Shuffle:
|
||||
List<GroupedMediaItem> i = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
|
||||
List<GroupedMediaItem> i = await PlayoutBuilder.GetGroupedMediaItemsForShuffle(
|
||||
mediaCollectionRepository,
|
||||
// playlist items don't (yet) carry these schedule flags; preserve prior behavior
|
||||
keepMultiPartEpisodesTogether: false,
|
||||
treatCollectionsAsShows: false,
|
||||
// TODO: fix this
|
||||
new ProgramSchedule { KeepMultiPartEpisodesTogether = false },
|
||||
items,
|
||||
CollectionKey.ForPlaylistItem(playlistItem),
|
||||
cancellationToken);
|
||||
@@ -183,7 +182,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
|
||||
break;
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
enumerator = new ShuffleInOrderCollectionEnumerator(
|
||||
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
||||
await PlayoutBuilder.GetCollectionItemsForShuffleInOrder(
|
||||
mediaCollectionRepository,
|
||||
CollectionKey.ForPlaylistItem(playlistItem),
|
||||
cancellationToken),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -594,7 +594,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
CollectionEnumeratorState scheduleItemsEnumeratorState =
|
||||
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState
|
||||
{ Seed = random.Next(), Index = 0 };
|
||||
{ Seed = random.Next(), Index = 0 };
|
||||
IScheduleItemsEnumerator scheduleItemsEnumerator = activeSchedule.ShuffleScheduleItems
|
||||
? new ShuffledScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState)
|
||||
: new OrderedScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState);
|
||||
@@ -735,17 +735,17 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
switch (scheduleItem.FillWithGroupMode)
|
||||
{
|
||||
case FillWithGroupMode.FillWithOrderedGroups:
|
||||
{
|
||||
var enumerator = new OrderedScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
|
||||
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
|
||||
break;
|
||||
}
|
||||
{
|
||||
var enumerator = new OrderedScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
|
||||
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
|
||||
break;
|
||||
}
|
||||
case FillWithGroupMode.FillWithShuffledGroups:
|
||||
{
|
||||
var enumerator = new ShuffledScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
|
||||
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
|
||||
break;
|
||||
}
|
||||
{
|
||||
var enumerator = new ShuffledScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
|
||||
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1376,21 +1376,13 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
return new ShuffleInOrderCollectionEnumerator(
|
||||
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
||||
await GetCollectionItemsForShuffleInOrder(
|
||||
_mediaCollectionRepository,
|
||||
collectionKey,
|
||||
cancellationToken),
|
||||
state,
|
||||
activeSchedule.RandomStartPoint,
|
||||
cancellationToken);
|
||||
case PlaybackOrder.WeightedShuffle:
|
||||
return new WeightedShuffleCollectionEnumerator(
|
||||
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
||||
_mediaCollectionRepository,
|
||||
collectionKey,
|
||||
cancellationToken),
|
||||
state,
|
||||
cancellationToken);
|
||||
case PlaybackOrder.MultiEpisodeShuffle when
|
||||
collectionKey.CollectionType == CollectionType.TelevisionShow &&
|
||||
collectionKey.MediaItemId.HasValue:
|
||||
@@ -1428,10 +1420,9 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
case PlaybackOrder.MultiEpisodeShuffle:
|
||||
case PlaybackOrder.Shuffle:
|
||||
return new ShuffledMediaCollectionEnumerator(
|
||||
await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
|
||||
await GetGroupedMediaItemsForShuffle(
|
||||
_mediaCollectionRepository,
|
||||
activeSchedule.KeepMultiPartEpisodesTogether,
|
||||
activeSchedule.TreatCollectionsAsShows,
|
||||
activeSchedule,
|
||||
mediaItems,
|
||||
collectionKey,
|
||||
cancellationToken),
|
||||
@@ -1463,6 +1454,50 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ProgramSchedule activeSchedule,
|
||||
List<MediaItem> mediaItems,
|
||||
CollectionKey collectionKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (collectionKey.MultiCollectionId != null)
|
||||
{
|
||||
List<CollectionWithItems> collections = await mediaCollectionRepository
|
||||
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value, cancellationToken);
|
||||
|
||||
return MultiCollectionGrouper.GroupMediaItems(collections);
|
||||
}
|
||||
|
||||
return activeSchedule.KeepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, activeSchedule.TreatCollectionsAsShows)
|
||||
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
}
|
||||
|
||||
internal static async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
CollectionKey collectionKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<CollectionWithItems> result;
|
||||
|
||||
if (collectionKey.MultiCollectionId != null)
|
||||
{
|
||||
result = await mediaCollectionRepository.GetMultiCollectionCollections(
|
||||
collectionKey.MultiCollectionId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await mediaCollectionRepository.GetFakeMultiCollectionCollections(
|
||||
collectionKey.CollectionId,
|
||||
collectionKey.SmartCollectionId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static string DisplayTitle(MediaItem mediaItem)
|
||||
{
|
||||
switch (mediaItem)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shuffle "source" for a collection key — the grouped media items fed to
|
||||
/// <see cref="ShuffledMediaCollectionEnumerator" /> and the per-collection lists fed to
|
||||
/// <see cref="ShuffleInOrderCollectionEnumerator" />.
|
||||
/// <para>
|
||||
/// Extracted from <c>PlayoutBuilder</c> (issue #380) so both the Classic builder and
|
||||
/// <see cref="PlaylistEnumerator" /> share ONE stateless place to construct shuffle sources,
|
||||
/// eliminating <see cref="PlaylistEnumerator" />'s cross-engine reach-in into
|
||||
/// <c>PlayoutBuilder</c>'s statics. Kept as a static helper (dependencies passed as parameters)
|
||||
/// to match the sibling <see cref="MultiCollectionGrouper" /> / <see cref="MultiPartEpisodeGrouper" />
|
||||
/// (also <c>public static</c>) and because <see cref="PlaylistEnumerator.Create" /> is itself a
|
||||
/// static factory.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ShuffleSourceBuilder
|
||||
{
|
||||
public static async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
bool keepMultiPartEpisodesTogether,
|
||||
bool treatCollectionsAsShows,
|
||||
List<MediaItem> mediaItems,
|
||||
CollectionKey collectionKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (collectionKey.MultiCollectionId != null)
|
||||
{
|
||||
List<CollectionWithItems> collections = await mediaCollectionRepository
|
||||
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value, cancellationToken);
|
||||
|
||||
return MultiCollectionGrouper.GroupMediaItems(collections);
|
||||
}
|
||||
|
||||
return keepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
|
||||
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
}
|
||||
|
||||
public static async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
CollectionKey collectionKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<CollectionWithItems> result;
|
||||
|
||||
if (collectionKey.MultiCollectionId != null)
|
||||
{
|
||||
result = await mediaCollectionRepository.GetMultiCollectionCollections(
|
||||
collectionKey.MultiCollectionId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await mediaCollectionRepository.GetFakeMultiCollectionCollections(
|
||||
collectionKey.CollectionId,
|
||||
collectionKey.SmartCollectionId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Weighted / fair-share distribution (issue #70). Picks a *source* by smooth weighted round-robin,
|
||||
/// then takes that source's next item. A source's <see cref="CollectionWithItems.Weight" /> is its
|
||||
/// relative share of airtime: weights 3:1 emit <c>A A B A</c> per rotation.
|
||||
/// <para>
|
||||
/// Fair-share is the equal-weights degenerate case (the default, since Weight defaults to 1):
|
||||
/// every source airs equally often regardless of library size, so a small show loops while a
|
||||
/// large one works through its items. This is what distinguishes it from
|
||||
/// <see cref="ShuffleInOrderCollectionEnumerator" />, whose balanced shuffle plays every item
|
||||
/// exactly once per cycle and therefore leaves airtime proportional to collection size
|
||||
/// (it prevents clumping, not domination).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deterministic and stateless: the emitted sequence is a pure function of
|
||||
/// (<see cref="CollectionEnumeratorState.Seed" />, <see cref="CollectionEnumeratorState.Index" />),
|
||||
/// so it restores by replay exactly like its siblings and needs no per-source persisted counters.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class WeightedShuffleCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Upper bound on the precomputed rotation, so a large collection paired with a low weight
|
||||
/// (picks needed grows as items * totalWeight / weight) can't allocate without limit. Hitting the
|
||||
/// clamp only means the largest source doesn't finish its items within one rotation before the
|
||||
/// reseed; the weight ratio itself is unaffected.
|
||||
/// </summary>
|
||||
private const int MaxCycleLength = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds the avoid-an-immediate-repeat retry when a rotation wraps. Unlike
|
||||
/// <see cref="ShuffleInOrderCollectionEnumerator" />, whose reshuffle randomizes which item leads the
|
||||
/// next cycle, this order's lead item is decided by weight — the heaviest source always wins the first
|
||||
/// pick. So when that source has a single item the lead never changes and an unbounded retry would spin
|
||||
/// forever. Avoiding a back-to-back repeat is a nicety; never terminating is not.
|
||||
/// </summary>
|
||||
private const int MaxReshuffleAttempts = 10;
|
||||
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private readonly IList<CollectionWithItems> _collections;
|
||||
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
|
||||
private Random _random;
|
||||
private MediaItem[] _shuffled;
|
||||
|
||||
public WeightedShuffleCollectionEnumerator(
|
||||
IList<CollectionWithItems> collections,
|
||||
CollectionEnumeratorState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CurrentIncludeInProgramGuide = Option<bool>.None;
|
||||
|
||||
_collections = collections;
|
||||
_cancellationToken = cancellationToken;
|
||||
|
||||
int cycleLength = CycleLength(collections);
|
||||
if (cycleLength > 0 && state.Index >= cycleLength)
|
||||
{
|
||||
state.Index = 0;
|
||||
state.Seed = new Random(state.Seed).Next();
|
||||
}
|
||||
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
// computed over every source's items, not the current rotation: a rotation can be a strict subset
|
||||
// (see MaxCycleLength) and is rebuilt on every wrap, so caching over it would go stale
|
||||
_lazyMinimumDuration =
|
||||
new Lazy<Option<TimeSpan>>(() =>
|
||||
_collections
|
||||
.Bind(c => c.MediaItems)
|
||||
.Bind(i => i.GetNonZeroDuration())
|
||||
.OrderBy(identity)
|
||||
.HeadOrNone());
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
{
|
||||
MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetState(CollectionEnumeratorState state)
|
||||
{
|
||||
// only rebuild if needed
|
||||
if (State.Seed != state.Seed)
|
||||
{
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
}
|
||||
|
||||
State.Seed = state.Seed;
|
||||
State.Index = state.Index;
|
||||
}
|
||||
|
||||
public string SchedulingContextName => "Weighted Shuffle";
|
||||
|
||||
public CollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _shuffled.Length != 0 ? _shuffled[State.Index % _shuffled.Length] : None;
|
||||
|
||||
public Option<bool> CurrentIncludeInProgramGuide { get; }
|
||||
|
||||
public void MoveNext(Option<DateTimeOffset> scheduledAt)
|
||||
{
|
||||
if (_shuffled.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((State.Index + 1) % _shuffled.Length == 0)
|
||||
{
|
||||
Option<MediaItem> tail = Current;
|
||||
|
||||
State.Index = 0;
|
||||
var attempts = 0;
|
||||
do
|
||||
{
|
||||
State.Seed = _random.Next();
|
||||
_random = new Random(State.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
attempts++;
|
||||
// guard on the rotation, not the raw collection count: an empty source contributes nothing,
|
||||
// so a 2-collection/1-item rotation would otherwise burn every attempt chasing an
|
||||
// impossible non-repeat on every wrap
|
||||
} while (!_cancellationToken.IsCancellationRequested && _shuffled.Length > 1 &&
|
||||
attempts < MaxReshuffleAttempts &&
|
||||
Current.Map(x => x.Id) == tail.Map(x => x.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
State.Index++;
|
||||
}
|
||||
|
||||
if (_shuffled.Length > 0)
|
||||
{
|
||||
State.Index %= _shuffled.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
|
||||
|
||||
public int Count => _shuffled.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Length of one rotation: enough picks for the source that needs the most of them to work through
|
||||
/// all of its items at its share of the rotation. Sources needing fewer picks loop within the
|
||||
/// rotation — that looping is exactly what makes equal weights mean equal airtime.
|
||||
/// </summary>
|
||||
private static int CycleLength(IList<CollectionWithItems> collections)
|
||||
{
|
||||
List<CollectionWithItems> active = ActiveSources(collections);
|
||||
if (active.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long totalWeight = active.Sum(c => (long)EffectiveWeight(c));
|
||||
|
||||
var length = 0;
|
||||
foreach (CollectionWithItems collection in active)
|
||||
{
|
||||
// picks needed for this source to emit every item once, given it wins Weight of every totalWeight picks
|
||||
var needed = (int)Math.Min(
|
||||
MaxCycleLength,
|
||||
Math.Ceiling(collection.MediaItems.Count * (double)totalWeight / EffectiveWeight(collection)));
|
||||
length = Math.Max(length, needed);
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
private static List<CollectionWithItems> ActiveSources(IList<CollectionWithItems> collections) =>
|
||||
collections.Filter(c => c.MediaItems.Count > 0).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Weight as the rotation should treat it. The write path bounds this (<see cref="MultiCollectionItemWeight" />),
|
||||
/// but a row can predate that gate, so it is clamped rather than trusted: an out-of-range value must not
|
||||
/// drop a source from the channel (0 or negative) or overflow the weight sum inside a playout build.
|
||||
/// </summary>
|
||||
private static int EffectiveWeight(CollectionWithItems collection) =>
|
||||
Math.Clamp(collection.Weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum);
|
||||
|
||||
private static MediaItem[] Build(IList<CollectionWithItems> collections, Random random)
|
||||
{
|
||||
List<CollectionWithItems> active = ActiveSources(collections);
|
||||
if (active.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var sources = active
|
||||
.Map(c => new WeightedSource { Weight = EffectiveWeight(c), Items = OrderItems(c, random) })
|
||||
.ToList();
|
||||
|
||||
int totalWeight = sources.Sum(s => s.Weight);
|
||||
int cycleLength = CycleLength(collections);
|
||||
|
||||
var result = new List<MediaItem>(cycleLength);
|
||||
for (var i = 0; i < cycleLength; i++)
|
||||
{
|
||||
// smooth weighted round-robin: every source gains its weight, the richest wins and pays the total.
|
||||
// Strict '>' keeps ties on the earliest source in list order, which makes the sequence deterministic.
|
||||
WeightedSource pick = null;
|
||||
foreach (WeightedSource source in sources)
|
||||
{
|
||||
source.Accumulator += source.Weight;
|
||||
if (pick is null || source.Accumulator > pick.Accumulator)
|
||||
{
|
||||
pick = source;
|
||||
}
|
||||
}
|
||||
|
||||
pick.Accumulator -= totalWeight;
|
||||
result.Add(pick.Items[pick.Cursor % pick.Items.Length]);
|
||||
pick.Cursor++;
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static MediaItem[] OrderItems(CollectionWithItems collectionWithItems, Random random)
|
||||
{
|
||||
// A custom-ordered collection is an explicit user sequence, so it is honored rather than shuffled
|
||||
// (same rule as ShuffleInOrderCollectionEnumerator). Otherwise the source's items are shuffled per
|
||||
// rotation, which is what makes the reseed on wrap produce a different rotation.
|
||||
if (collectionWithItems.UseCustomOrder)
|
||||
{
|
||||
return collectionWithItems.MediaItems.ToArray();
|
||||
}
|
||||
|
||||
return Shuffle(collectionWithItems.MediaItems, random);
|
||||
}
|
||||
|
||||
private static MediaItem[] Shuffle(IEnumerable<MediaItem> list, Random random)
|
||||
{
|
||||
MediaItem[] copy = list.ToArray();
|
||||
|
||||
int n = copy.Length;
|
||||
while (n > 1)
|
||||
{
|
||||
n--;
|
||||
int k = random.Next(n + 1);
|
||||
(copy[k], copy[n]) = (copy[n], copy[k]);
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
private class WeightedSource
|
||||
{
|
||||
public int Weight { get; init; }
|
||||
public MediaItem[] Items { get; init; }
|
||||
public int Cursor { get; set; }
|
||||
public int Accumulator { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
cancellationToken);
|
||||
break;
|
||||
|
||||
// playlist is handled later
|
||||
// playlist is handled later
|
||||
}
|
||||
|
||||
_mediaItems[content.Key] = items;
|
||||
@@ -178,8 +178,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
|
||||
switch (parsedOrder)
|
||||
switch (Enum.Parse<PlaybackOrder>(content.Order, true))
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
return new ChronologicalMediaCollectionEnumerator(items, state);
|
||||
@@ -191,13 +190,6 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
}
|
||||
|
||||
// this path schedules nothing for the content, which is indistinguishable from "no items" downstream.
|
||||
// Orders are addressed by name here, so any order the enum knows parses fine and then lands here --
|
||||
// say so, rather than leaving an empty schedule to be explained (#70).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
|
||||
parsedOrder);
|
||||
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MySqlConnector;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies MySQL provider exceptions wrapped by EF Core. Wired to
|
||||
/// <see cref="ErsatzTV.Infrastructure.Data.TvContext.IsUniqueConstraintViolation" /> at startup.
|
||||
/// </summary>
|
||||
public static class MySqlErrorClassifier
|
||||
{
|
||||
// ER_DUP_ENTRY (1062): a duplicate value for a PRIMARY KEY or UNIQUE index.
|
||||
private const int ErDupEntry = 1062;
|
||||
|
||||
public static bool IsUniqueConstraintViolation(DbUpdateException ex) =>
|
||||
ex.InnerException is MySqlException { Number: ErDupEntry };
|
||||
}
|
||||
Generated
-7250
File diff suppressed because it is too large
Load Diff
-40
@@ -1,40 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiCollectionItemWeight : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-7260
File diff suppressed because it is too large
Load Diff
-56
@@ -1,56 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCollectionOwnedByChannelId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OwnedByChannelId",
|
||||
table: "SmartCollection",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OwnedByChannelId",
|
||||
table: "MultiCollection",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SmartCollection_OwnedByChannelId",
|
||||
table: "SmartCollection",
|
||||
column: "OwnedByChannelId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MultiCollection_OwnedByChannelId",
|
||||
table: "MultiCollection",
|
||||
column: "OwnedByChannelId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_SmartCollection_OwnedByChannelId",
|
||||
table: "SmartCollection");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MultiCollection_OwnedByChannelId",
|
||||
table: "MultiCollection");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnedByChannelId",
|
||||
table: "SmartCollection");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnedByChannelId",
|
||||
table: "MultiCollection");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -1780,9 +1780,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.UseCollation("utf8mb4_general_ci");
|
||||
|
||||
b.Property<int?>("OwnedByChannelId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("int");
|
||||
@@ -1792,8 +1789,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnedByChannelId");
|
||||
|
||||
b.ToTable("MultiCollection", (string)null);
|
||||
});
|
||||
|
||||
@@ -1811,11 +1806,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "CollectionId");
|
||||
|
||||
b.HasIndex("CollectionId");
|
||||
@@ -1837,11 +1827,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "SmartCollectionId");
|
||||
|
||||
b.HasIndex("SmartCollectionId");
|
||||
@@ -3530,9 +3515,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.UseCollation("utf8mb4_general_ci");
|
||||
|
||||
b.Property<int?>("OwnedByChannelId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Query")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
@@ -3541,8 +3523,6 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnedByChannelId");
|
||||
|
||||
b.ToTable("SmartCollection", (string)null);
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user