Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e786c1f22 | ||
|
|
4efb1a1b03 |
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# design-sync-reminder — single hook, both directions (#388). Keeps the Claude Design project
|
||||
# (`ChicoryTV Design System`, eb3b6122 / local mirror `design-system/`) in step with the shipped
|
||||
# SPA. Trigger is PURELY MECHANICAL: "touching the UI" == a file matching UI_RE below. No prompt
|
||||
# keyword guessing. Wired to two boundaries:
|
||||
#
|
||||
# start (PreToolUse / Write|Edit) — the FIRST time this session edits a UI file, remind to PULL
|
||||
# the current design from Claude Design first.
|
||||
# finish (Stop) — if the working tree actually changed a UI file, remind to
|
||||
# MIRROR/PUSH the change back before wrapping up.
|
||||
#
|
||||
# UI_RE is the one place the "what counts as UI" fileset is defined: SPA .tsx/.css under web/src
|
||||
# (test files excluded). Widen it here if the design surface grows.
|
||||
#
|
||||
# Fail-open: any parse trouble / non-match → emit nothing, exit 0. Throttled once per session per
|
||||
# phase so it informs without nagging. DesignSync runs only from the main session (docs/design-sync.md).
|
||||
# This is a reminder, never a hard gate — `start` only injects context; `finish` is a one-shot Stop nudge.
|
||||
set -euo pipefail
|
||||
|
||||
UI_RE='(^|/)web/src/.*\.(tsx|css)$'
|
||||
TEST_RE='\.test\.(tsx|ts)$'
|
||||
|
||||
phase="${1:-}"
|
||||
input=$(cat)
|
||||
me=$(printf '%s' "$input" | jq -r '.session_id // "nosess"' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
[ -z "$cwd" ] && cwd="$PWD"
|
||||
marker="${TMPDIR:-/tmp}/ctv-designsync-${phase}-${me}"
|
||||
|
||||
case "$phase" in
|
||||
start)
|
||||
fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null || true)
|
||||
[ -z "$fp" ] && exit 0
|
||||
printf '%s' "$fp" | grep -qE "$TEST_RE" && exit 0 # skip test files
|
||||
printf '%s' "$fp" | grep -qE "$UI_RE" || exit 0 # not a UI file → nothing
|
||||
[ -f "$marker" ] && exit 0
|
||||
: > "$marker" 2>/dev/null || true
|
||||
read -r -d '' MSG <<'EOF' || true
|
||||
[design-sync #388] About to edit a ChicoryTV SPA UI file. The `design-system/` prototypes mirror the Claude Design project (eb3b6122). If you're changing how a screen LOOKS, first PULL its current prototype from Claude Design so you start from the live design (docs/design-sync.md, pull = DesignSync list_files/get_file → design-system/, incremental). You'll be reminded to MIRROR the change back when the task finishes. DesignSync runs only from the main session.
|
||||
EOF
|
||||
jq -n --arg m "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$m}}'
|
||||
exit 0
|
||||
;;
|
||||
finish)
|
||||
# Did this turn actually change a UI file? (tracked diff vs HEAD + untracked, minus tests)
|
||||
changed=$( { git -C "$cwd" diff --name-only HEAD 2>/dev/null; git -C "$cwd" ls-files --others --exclude-standard 2>/dev/null; } | grep -vE "$TEST_RE" | grep -E "$UI_RE" || true )
|
||||
[ -z "$changed" ] && exit 0
|
||||
[ -f "$marker" ] && exit 0
|
||||
: > "$marker" 2>/dev/null || true
|
||||
n=$(printf '%s\n' "$changed" | sed '/^$/d' | wc -l | tr -d ' ')
|
||||
reason="[design-sync #388] This task changed ${n} SPA UI file(s) under web/src. Before wrapping up, MIRROR the visual change into the matching design-system/templates/chicorytv-admin/*.jsx prototype and push it to Claude Design (eb3b6122) in this same session, per docs/design-sync.md — so the design system does not drift from prod. If you already synced, or are deliberately deferring the mirror (say why), just note it and stop. DesignSync runs only from the main session. This one-shot reminder won't fire again this session."
|
||||
jq -n --arg r "$reason" '{decision:"block",reason:$r}'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -51,16 +46,6 @@
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" start",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -74,17 +59,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" finish",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.3.4.1",
|
||||
"version": "2025.3.0.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
|
||||
+5
-9
@@ -106,17 +106,13 @@ ij_json_wrap_long_lines = false
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
# --- Static-analysis pack adoption (ersatztv#15) ---
|
||||
# Threading analyzers and Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are enabled centrally.
|
||||
# Default their diagnostics to `suggestion`; the SDK's exact per-rule suggestion baseline lives in
|
||||
# eng/analyzers/sdk-all-suggestion.globalconfig because AnalysisLevel=latest-All otherwise injects
|
||||
# exact warning severities that outrank this bulk setting. High-value rules are promoted one at a
|
||||
# time. Explicit per-rule severities (e.g. ca1848 above) take precedence over both baselines.
|
||||
# Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are referenced centrally
|
||||
# (Directory.Build.targets). Default every analyzer diagnostic to `suggestion` so the new
|
||||
# packs don't fail the TreatWarningsAsErrors build; high-value rules get promoted to
|
||||
# warning/error one at a time (see ersatztv#15 / docs/contributing.md). Explicit per-rule
|
||||
# severities (e.g. ca1848 above) still take precedence over this bulk default.
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
# A collection count can never be negative. Treat comparisons that therefore collapse to a
|
||||
# constant as errors; the first promotion caught a busy/idle branch that was permanently busy.
|
||||
dotnet_diagnostic.S3981.severity = warning
|
||||
|
||||
# Blazor components: analyzers run on .razor/.cshtml @code too, and TWAE would otherwise
|
||||
# turn their default-severity findings into build errors — keep them at suggestion as well.
|
||||
[*.razor]
|
||||
|
||||
@@ -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,167 +61,51 @@ 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
|
||||
|
||||
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
|
||||
# per test project (via --collect above); ReportGenerator merges them into a human-readable
|
||||
# summary printed to the log and the job step summary. No floor is enforced yet ("decide on a
|
||||
# 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
|
||||
shopt -s globstar nullglob
|
||||
reports=(coverage/**/coverage.cobertura.xml)
|
||||
if [ ${#reports[@]} -eq 0 ]; then
|
||||
echo "No coverage reports found under ./coverage -- skipping summary."
|
||||
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).
|
||||
reportgenerator \
|
||||
"-reports:coverage/**/coverage.cobertura.xml" \
|
||||
"-targetdir:coverage/report" \
|
||||
"-reporttypes:TextSummary;MarkdownSummaryGithub"
|
||||
echo "::group::Coverage summary"
|
||||
cat coverage/report/Summary.txt
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then
|
||||
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
|
||||
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
|
||||
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 +118,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 +129,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 +142,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 +168,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 +177,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
|
||||
@@ -419,89 +202,6 @@ jobs:
|
||||
done
|
||||
echo "::endgroup::"
|
||||
|
||||
functional-e2e:
|
||||
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.
|
||||
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: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('web/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
|
||||
|
||||
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
|
||||
# the toolchain image ships the same ffmpeg build prod runs, so the binary is already here.
|
||||
# That step also cost 110s of every run. The harness never *transcodes*, but since ersatztv#363
|
||||
# it does use ffmpeg to synthesize ~60 tiny testsrc clips to seed the scan-lock 409 flow (and
|
||||
# python3's stdlib sqlite3 to seed the DB rows the API can't create) — both already present in
|
||||
# the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent.
|
||||
|
||||
- name: Boot instance and run functional-E2E harness
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
CFG="$(mktemp -d)"
|
||||
# e2e-local.sh copies wwwroot, launches the DLL in the background (logging to a file, so
|
||||
# this command substitution returns as soon as the app is ready), and prints PID/CONFIG_DIR.
|
||||
OUT="$(scripts/e2e-local.sh "$CFG")"
|
||||
printf '%s\n' "$OUT"
|
||||
PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')"
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# `small` = the dedicated small-jobs runner lane (server-management#574).
|
||||
@@ -518,16 +218,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 +242,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 +249,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 +256,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 +271,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 +329,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 +404,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 +427,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 +445,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 +486,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 +510,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
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
*.*~
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
# Code-coverage output (dotnet test --results-directory ./coverage, ersatztv#15)
|
||||
/coverage/
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-13
@@ -3,12 +3,6 @@
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<!-- Analyzer posture (ersatztv#15): enable the complete SDK rule set and the
|
||||
threading analyzer in every centrally managed project. The checked-in globalconfig
|
||||
keeps the SDK baseline at suggestion; individually promoted rules become CI-blocking. -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>latest-All</AnalysisLevel>
|
||||
<EnableThreadingAnalyzers>true</EnableThreadingAnalyzers>
|
||||
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
|
||||
packages as NU1901-1904 warnings. Several projects set
|
||||
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
|
||||
@@ -16,13 +10,8 @@
|
||||
advisories to warnings (still printed in build logs); NU1904 (critical)
|
||||
stays an error so criticals still block. Track fixes separately.
|
||||
WarningsAsErrors promotes NU1904 in EVERY project (even those without
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide.
|
||||
S3981 is the first explicitly promoted analyzer rule (ersatztv#15). -->
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904;S3981</WarningsAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EditorConfigFiles Include="$(MSBuildThisFileDirectory)eng/analyzers/sdk-all-suggestion.globalconfig" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+10
-8
@@ -1,7 +1,9 @@
|
||||
<Project>
|
||||
<!-- Guard on CPM so the gitignored .mcp tool, which deliberately uses inline package
|
||||
versions, does not inherit a versionless analyzer PackageReference. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PropertyGroup>
|
||||
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
@@ -10,11 +12,11 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every centrally managed project.
|
||||
Versions are central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored
|
||||
.mcp tool (which opts out of CPM) doesn't pull versionless references. They start at
|
||||
`suggestion` severity in .editorconfig so they don't fail the TreatWarningsAsErrors build;
|
||||
high-value rules are promoted to warning/error incrementally. -->
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every project. Versions are
|
||||
central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored .mcp tool
|
||||
(which opts out of CPM) doesn't pull versionless references. They start at `suggestion`
|
||||
severity in .editorconfig so they don't fail the TreatWarningsAsErrors build; high-value
|
||||
rules are promoted to warning/error incrementally. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference Include="Roslynator.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.10" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageVersion Include="Jint" Version="4.5.0" />
|
||||
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
|
||||
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public static class AutoTuneAxisMap
|
||||
{
|
||||
// Server-owned Lucene smart-collection query for an axis value.
|
||||
public static string GenerateQuery(AutoTuneAxis axis, string value)
|
||||
{
|
||||
string escaped = EscapeLuceneValue(value);
|
||||
return axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => $"type:episode AND show_title:\"{escaped}\"",
|
||||
AutoTuneAxis.TvGenre => $"type:episode AND genre:\"{escaped}\"",
|
||||
AutoTuneAxis.MovieGenre => $"type:movie AND genre:\"{escaped}\"",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
}
|
||||
|
||||
// Human-facing channel name. Movie-genre channels are suffixed so a genre that exists for
|
||||
// both TV and movies ("Comedy" vs "Comedy Movies") does not produce two identically-named channels.
|
||||
public static string GenerateName(AutoTuneAxis axis, string value) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => value,
|
||||
AutoTuneAxis.TvGenre => value,
|
||||
AutoTuneAxis.MovieGenre => $"{value} Movies",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// PseudoTV per-type defaults: single-show channels play in episode order; genre channels shuffle.
|
||||
public static PlaybackOrder PlaybackOrderFor(AutoTuneAxis axis) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => PlaybackOrder.SeasonEpisode,
|
||||
AutoTuneAxis.TvGenre => PlaybackOrder.Shuffle,
|
||||
AutoTuneAxis.MovieGenre => PlaybackOrder.Shuffle,
|
||||
_ => 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("\"", "\\\"");
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public static class AutoTuneNumberAllocator
|
||||
{
|
||||
// Allocate `count` sequential integer channel numbers starting at `startingNumber`,
|
||||
// skipping any number already present in `existingNumbers`. Channel.Number is a string,
|
||||
// so numbers are returned as invariant-culture strings.
|
||||
public static List<string> Allocate(int startingNumber, int count, ISet<string> existingNumbers)
|
||||
{
|
||||
var result = new List<string>(count);
|
||||
int next = startingNumber;
|
||||
while (result.Count < count)
|
||||
{
|
||||
string candidate = next.ToString(CultureInfo.InvariantCulture);
|
||||
if (!existingNumbers.Contains(candidate))
|
||||
{
|
||||
result.Add(candidate);
|
||||
}
|
||||
|
||||
next++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -52,6 +52,16 @@ public class UpdateChannelHandler(
|
||||
UpdateChannel update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// don't save mirror when playout exists
|
||||
if (c.Playouts.Count > 0)
|
||||
{
|
||||
update = update with
|
||||
{
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
MirrorSourceChannelId = null
|
||||
};
|
||||
}
|
||||
|
||||
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
|
||||
|
||||
c.Name = update.Name;
|
||||
@@ -130,8 +140,6 @@ public class UpdateChannelHandler(
|
||||
c.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
|
||||
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -139,6 +147,8 @@ public class UpdateChannelHandler(
|
||||
c.PlayoutOffset = null;
|
||||
}
|
||||
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
@@ -166,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);
|
||||
}
|
||||
|
||||
@@ -184,7 +187,7 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
@@ -259,7 +262,6 @@ public class UpdateChannelHandler(
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
|
||||
@@ -267,18 +269,6 @@ public class UpdateChannelHandler(
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// a channel with its own playout already built (Generated mode) cannot become a Mirror —
|
||||
// Mirror channels relay another channel's playout and never build one of their own, so
|
||||
// switching this transition on would strand the existing playout. This used to be
|
||||
// silently coerced back to Generated (issue #401); reject the transition instead so the
|
||||
// caller sees why the requested Mirror source was not applied. A round-trip that keeps
|
||||
// PlayoutSource as Generated never reaches this check.
|
||||
if (channel.Playouts.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
"Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first.");
|
||||
}
|
||||
|
||||
Option<Channel> maybeMirrorSource = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record CreateAutoTunedChannels(
|
||||
int TemplateId,
|
||||
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);
|
||||
|
||||
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
|
||||
{
|
||||
public int CreatedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Created);
|
||||
public int SkippedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Skipped);
|
||||
public int FailedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Failed);
|
||||
}
|
||||
|
||||
public record AutoTuneChannelOutcome(
|
||||
string Name,
|
||||
AutoTuneOutcomeStatus Status,
|
||||
int? ChannelId,
|
||||
string Reason);
|
||||
|
||||
public enum AutoTuneOutcomeStatus
|
||||
{
|
||||
Created,
|
||||
Skipped,
|
||||
Failed
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
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)
|
||||
: 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)
|
||||
{
|
||||
string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim();
|
||||
var outcomes = new List<AutoTuneChannelOutcome>();
|
||||
|
||||
foreach (AutoTuneChannelSelection selection in request.Channels ?? [])
|
||||
{
|
||||
outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken));
|
||||
}
|
||||
|
||||
return new AutoTuneResult(outcomes);
|
||||
}
|
||||
|
||||
private async Task<AutoTuneChannelOutcome> CreateOne(
|
||||
int templateId,
|
||||
string group,
|
||||
AutoTuneChannelSelection selection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string name = (selection.Name ?? string.Empty).Trim();
|
||||
if (name.Length is 0 or > 50)
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
// 1. Create the smart collection that drives this channel.
|
||||
Either<BaseError, SmartCollectionViewModel> scResult =
|
||||
await mediator.Send(new CreateSmartCollection(query, name), cancellationToken);
|
||||
|
||||
SmartCollectionViewModel smartCollection = null;
|
||||
foreach (BaseError error in scResult.LeftToSeq())
|
||||
{
|
||||
return new AutoTuneChannelOutcome(
|
||||
name, AutoTuneOutcomeStatus.Failed, null, $"Smart collection: {error.Value}");
|
||||
}
|
||||
|
||||
foreach (SmartCollectionViewModel vm in scResult.RightToSeq())
|
||||
{
|
||||
smartCollection = vm;
|
||||
}
|
||||
|
||||
// 2. Create the channel from a single-item lineup referencing the smart collection.
|
||||
var command = new CreateChannelFromLineup(
|
||||
name,
|
||||
selection.Number,
|
||||
group,
|
||||
string.Empty,
|
||||
logo,
|
||||
IsEnabled: true,
|
||||
ShowInEpg: true,
|
||||
effectiveTemplateId,
|
||||
advanced,
|
||||
[
|
||||
new CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType.SmartCollection,
|
||||
CollectionType.SmartCollection,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: null,
|
||||
SmartCollectionId: smartCollection.Id,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null)
|
||||
]);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
foreach (BaseError error in channelResult.LeftToSeq())
|
||||
{
|
||||
// Roll back the smart collection we just created so a retry of this
|
||||
// axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort;
|
||||
// the primary outcome below is still Skipped/Failed regardless of the delete result.
|
||||
// Swallow any exception (not just an Either.Left) so a transient infra failure
|
||||
// during rollback never aborts this channel's outcome or the batch; the
|
||||
// orphaned SmartCollection is an acceptable degraded outcome.
|
||||
try
|
||||
{
|
||||
await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// intentionally ignored; see comment above
|
||||
}
|
||||
|
||||
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);
|
||||
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,19 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record PreviewAutoTuneChannels(
|
||||
List<AutoTuneAxis> Axes,
|
||||
int MinItems,
|
||||
int StartingNumber) : IRequest<Either<BaseError, List<AutoTuneProposal>>>;
|
||||
|
||||
public record AutoTuneProposal(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number,
|
||||
int ItemCount,
|
||||
bool AlreadyExists);
|
||||
@@ -1,144 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class PreviewAutoTuneChannelsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<PreviewAutoTuneChannels, Either<BaseError, List<AutoTuneProposal>>>
|
||||
{
|
||||
public async Task<Either<BaseError, List<AutoTuneProposal>>> Handle(
|
||||
PreviewAutoTuneChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Axes is null || request.Axes.Count == 0)
|
||||
{
|
||||
return BaseError.New("At least one axis is required");
|
||||
}
|
||||
|
||||
if (request.MinItems < 1)
|
||||
{
|
||||
return BaseError.New("Minimum items must be at least 1");
|
||||
}
|
||||
|
||||
if (request.StartingNumber < 1)
|
||||
{
|
||||
return BaseError.New("Starting channel number must be at least 1");
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Enumerate (axis, value, count) triples per requested axis, preserving axis order.
|
||||
var raw = new List<(AutoTuneAxis Axis, string Value, int Count)>();
|
||||
foreach (AutoTuneAxis axis in request.Axes.Distinct())
|
||||
{
|
||||
raw.AddRange(await EnumerateAxis(dbContext, axis, request.MinItems, cancellationToken));
|
||||
}
|
||||
|
||||
System.Collections.Generic.HashSet<string> existingNumbers = (await dbContext.Channels.AsNoTracking()
|
||||
.Select(c => c.Number).ToListAsync(cancellationToken))
|
||||
.ToHashSet();
|
||||
System.Collections.Generic.HashSet<string> existingNames = (await dbContext.Channels.AsNoTracking()
|
||||
.Select(c => c.Name).ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Drop entries whose generated name would be rejected at create time (Channel name <= 50
|
||||
// chars) before number allocation, so numbers aren't wasted on proposals that can never
|
||||
// be created.
|
||||
List<(AutoTuneAxis Axis, string Value, int Count, string Name)> survivors = raw
|
||||
.Select(r => (r.Axis, r.Value, r.Count, Name: AutoTuneAxisMap.GenerateName(r.Axis, r.Value)))
|
||||
.Where(r => r.Name.Length <= 50)
|
||||
.ToList();
|
||||
|
||||
List<string> numbers = AutoTuneNumberAllocator.Allocate(
|
||||
request.StartingNumber, survivors.Count, existingNumbers);
|
||||
|
||||
var proposals = new List<AutoTuneProposal>(survivors.Count);
|
||||
for (int i = 0; i < survivors.Count; i++)
|
||||
{
|
||||
(AutoTuneAxis axis, string value, int count, string name) = survivors[i];
|
||||
proposals.Add(new AutoTuneProposal(
|
||||
axis, value, name, numbers[i], count, existingNames.Contains(name)));
|
||||
}
|
||||
|
||||
return proposals;
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateAxis(
|
||||
TvContext dbContext, AutoTuneAxis axis, int minItems, CancellationToken cancellationToken) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => await EnumerateTvShows(dbContext, minItems, cancellationToken),
|
||||
AutoTuneAxis.TvGenre => await EnumerateEpisodeGenres(dbContext, minItems, cancellationToken),
|
||||
AutoTuneAxis.MovieGenre => await EnumerateMovieGenres(dbContext, minItems, cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateTvShows(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
// Episode count per show id (Episode -> Season -> ShowId). Proven query style from LibraryBrowseItemMapper.
|
||||
Dictionary<int, int> episodeCounts = await dbContext.Episodes.AsNoTracking()
|
||||
.GroupBy(e => e.Season.ShowId)
|
||||
.Select(g => new { ShowId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
|
||||
|
||||
var showTitles = await dbContext.ShowMetadata.AsNoTracking()
|
||||
.Select(sm => new { sm.ShowId, sm.Title })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Collapse shows that share a title (the generated show_title query matches them together).
|
||||
var byTitle = new Dictionary<string, int>();
|
||||
foreach (var row in showTitles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Title))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
episodeCounts.TryGetValue(row.ShowId, out int count);
|
||||
byTitle[row.Title] = byTitle.GetValueOrDefault(row.Title) + count;
|
||||
}
|
||||
|
||||
return byTitle
|
||||
.Where(kv => kv.Value >= minItems)
|
||||
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kv => (AutoTuneAxis.TvShow, kv.Key, kv.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateEpisodeGenres(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = await dbContext.EpisodeMetadata.AsNoTracking()
|
||||
.SelectMany(m => m.Genres)
|
||||
.GroupBy(g => g.Name)
|
||||
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return counts
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
|
||||
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(c => (AutoTuneAxis.TvGenre, c.Name, c.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateMovieGenres(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = await dbContext.MovieMetadata.AsNoTracking()
|
||||
.SelectMany(m => m.Genres)
|
||||
.GroupBy(g => g.Name)
|
||||
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return counts
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
|
||||
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(c => (AutoTuneAxis.MovieGenre, c.Name, c.Count))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -18,8 +15,7 @@ public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IConfigElementRepository configElementRepository)
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
@@ -27,21 +23,6 @@ public partial class GetChannelGuideHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
maybeBaseUrl.IfNone(string.Empty),
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl);
|
||||
|
||||
// The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already
|
||||
// emitted as &), so the substituted base must be XML-escaped. A path prefix can legally
|
||||
// contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and
|
||||
// malform the whole guide. Normal URLs have no special chars, so this is a no-op for them.
|
||||
string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}");
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
@@ -60,17 +41,14 @@ 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);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
.Replace("{RequestBase}", requestBase)
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
@@ -92,7 +70,7 @@ public partial class GetChannelGuideHandler(
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", requestBase)
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
@@ -4,31 +4,19 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelPlaylistHandler(
|
||||
IChannelRepository channelRepository,
|
||||
IConfigElementRepository configElementRepository)
|
||||
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
{
|
||||
public async Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
maybeBaseUrl.IfNone(string.Empty),
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl);
|
||||
|
||||
List<Channel> channels = EnsureMode(await channelRepository.GetAll(cancellationToken), request.Mode);
|
||||
return new ChannelPlaylist(
|
||||
scheme,
|
||||
host,
|
||||
baseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken);
|
||||
}
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
|
||||
@@ -11,8 +10,7 @@ namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStatesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker)
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
|
||||
{
|
||||
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
|
||||
@@ -143,8 +141,7 @@ public class GetChannelStatesForApiHandler(
|
||||
return new ChannelStateResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
ffmpegSegmenterService.IsActive(channel.Number) ||
|
||||
directStreamSessionTracker.IsActive(channel.Number),
|
||||
ffmpegSegmenterService.IsActive(channel.Number),
|
||||
nowPlaying);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateIptvSettings(IptvSettingsViewModel IptvSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,51 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateIptvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateIptvSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateIptvSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Unit> validation = Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(request.IptvSettings, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(IptvSettingsViewModel iptvSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
string baseUrl = (iptvSettings.BaseUrl ?? string.Empty).Trim();
|
||||
|
||||
// A blank value clears the setting so the request-derived behavior is restored.
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
await configElementRepository.Delete(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.IptvBaseUrl, baseUrl, cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, Unit> Validate(UpdateIptvSettings request)
|
||||
{
|
||||
string baseUrl = request.IptvSettings.BaseUrl;
|
||||
|
||||
// Blank is valid (clears the override); a non-blank value must be a well-formed advertised base URL.
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
return AdvertisedBaseUrl.TryParse(baseUrl)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>(
|
||||
"Advertised base URL must be an absolute http(s) URL with no credentials, query, or fragment");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class IptvSettingsViewModel
|
||||
{
|
||||
public string BaseUrl { get; set; }
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetIptvSettings : IRequest<IptvSettingsViewModel>;
|
||||
@@ -1,19 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetIptvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetIptvSettings, IptvSettingsViewModel>
|
||||
{
|
||||
public async Task<IptvSettingsViewModel> Handle(GetIptvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
return new IptvSettingsViewModel
|
||||
{
|
||||
BaseUrl = await maybeBaseUrl.IfNoneAsync(string.Empty)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
</PropertyGroup>
|
||||
@@ -26,10 +27,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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count > 0 || FFmpegProcess.ProcessCount > 0;
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count >= 0 || FFmpegProcess.ProcessCount > 0;
|
||||
if (request.ForceAggressive || !hasActiveWorkers)
|
||||
{
|
||||
_logger.LogDebug("Starting aggressive garbage collection");
|
||||
|
||||
@@ -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,30 +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));
|
||||
|
||||
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,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReshufflePlayout(int PlayoutId) : IRequest;
|
||||
@@ -1,40 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class ReshufflePlayoutHandler(
|
||||
IMediator mediator,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ReshufflePlayout>
|
||||
{
|
||||
public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Where(p => p.ScheduleKind == PlayoutScheduleKind.Classic ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Block ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
// Roll a new play order. BuildPlayout(Reset) only reseeds Playout.Seed for CLASSIC playouts
|
||||
// (PlayoutBuilder); Block/Sequential/Scripted rebuild deterministically from the existing seed.
|
||||
// ErasePlayoutHistory is the one primitive that reseeds + clears the derived per-collection
|
||||
// enumerator anchors for ALL four kinds — run it first, then rebuild from scratch.
|
||||
await mediator.Send(new ErasePlayoutHistory(playout.Id), cancellationToken);
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -65,8 +65,7 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
playout.Version);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -57,8 +57,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
playout.Version);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.CommandLine.Parsing;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
@@ -60,8 +60,7 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
playout.Version);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -65,8 +65,7 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
playout.Version);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
@@ -20,8 +20,7 @@ internal static class Mapper
|
||||
// the paged-playouts query does not eager-load Deco (the list response does not surface
|
||||
// the default deco); GetPlayoutById includes it for the detail response
|
||||
playout.Deco?.Name,
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
playout.Version);
|
||||
|
||||
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
@@ -14,8 +14,7 @@ public record PlayoutNameViewModel(
|
||||
PlayoutBuildStatus BuildStatus,
|
||||
int? DecoId,
|
||||
string DecoName,
|
||||
int Version,
|
||||
int Seed)
|
||||
int Version)
|
||||
{
|
||||
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -31,7 +31,6 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
p.BuildStatus,
|
||||
p.DecoId,
|
||||
p.DecoId == null ? null : p.Deco.Name,
|
||||
p.Version,
|
||||
p.Seed));
|
||||
p.Version));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -8,10 +8,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class ColdStartFeaturesTests
|
||||
{
|
||||
[Test]
|
||||
public void Should_Detect_Subtitle_BurnIn_From_Subtitles_Filter()
|
||||
{
|
||||
const string Args =
|
||||
"-i ep.mkv -filter_complex \"[0:0]subtitles=http://localhost:8409/media/subtitle/1.ass[v]\" " +
|
||||
"-c:v h264_vaapi -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Flag_Subtitle_BurnIn_For_Copy_Or_Overlay()
|
||||
{
|
||||
// subtitle stream copy (-c:s) and a watermark/image-subtitle overlay must NOT be read as burn-in
|
||||
const string Args = "-i ep.mkv -filter_complex \"[v][wm]overlay=10:10:format=0[vwm]\" -c:s copy -c:v libx264 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestCase("-c:v h264_vaapi -f hls", "vaapi")]
|
||||
[TestCase("-c:v hevc_vaapi -f hls", "vaapi")]
|
||||
[TestCase("-c:v h264_nvenc -f hls", "nvenc")]
|
||||
[TestCase("-c:v h264_qsv -f hls", "qsv")]
|
||||
[TestCase("-c:v h264_videotoolbox -f hls", "videotoolbox")]
|
||||
[TestCase("-c:v h264_amf -f hls", "amf")]
|
||||
public void Should_Detect_Hardware_Family_From_Encoder(string args, string expected)
|
||||
{
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Detect_Hardware_Family_From_Vaapi_Filter_Even_Without_Encoder_Token()
|
||||
{
|
||||
// a vaapi-accelerated pipeline surfaces the family via filters (e.g. scale_vaapi) too
|
||||
const string Args = "-hwaccel vaapi -i ep.mkv -vf scale_vaapi=1920:1080 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe("vaapi");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fall_Back_To_Hwaccel_Flag_When_No_Hardware_Encoder_Or_Filter()
|
||||
{
|
||||
// hardware decode, software encode: attribute the decode accel
|
||||
const string Args = "-hwaccel cuda -i ep.mkv -c:v libx264 -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.HardwareAcceleration.ShouldBe("cuda");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Report_Software_When_No_Acceleration()
|
||||
{
|
||||
const string Args = "-i ep.mkv -c:v libx264 -c:a aac -f hls";
|
||||
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(Args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
features.HardwareAcceleration.ShouldBe("software");
|
||||
}
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
[TestCase(null)]
|
||||
public void Should_Report_Unknown_For_Empty_Arguments(string args)
|
||||
{
|
||||
ColdStartFeatures features = ColdStartFeatures.FromFFmpegArguments(args);
|
||||
|
||||
features.SubtitleBurnIn.ShouldBeFalse();
|
||||
features.HardwareAcceleration.ShouldBe("unknown");
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
[TestFixture]
|
||||
public class AdvertisedBaseUrlTests
|
||||
{
|
||||
private const string RequestScheme = "http";
|
||||
private const string RequestHost = "ersatztv:8409";
|
||||
private const string RequestBaseUrl = "";
|
||||
|
||||
[Test]
|
||||
public void Resolve_Uses_Configured_Value_When_Set()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
"https://tv.example.com",
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe("https");
|
||||
host.ShouldBe("tv.example.com");
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Resolve_Falls_Back_To_Request_When_Blank(string configured)
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
configured,
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe(RequestScheme);
|
||||
host.ShouldBe(RequestHost);
|
||||
baseUrl.ShouldBe(RequestBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_Falls_Back_To_Request_When_Invalid()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
"not a url",
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe(RequestScheme);
|
||||
host.ShouldBe(RequestHost);
|
||||
baseUrl.ShouldBe(RequestBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Preserves_Non_Default_Port()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse("http://192.168.1.99:8409").IfNone(("", "", ""));
|
||||
|
||||
scheme.ShouldBe("http");
|
||||
host.ShouldBe("192.168.1.99:8409");
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[TestCase("http://tv.example.com:80", "http", "tv.example.com")]
|
||||
[TestCase("https://tv.example.com:443", "https", "tv.example.com")]
|
||||
public void TryParse_Drops_Redundant_Default_Port(string configured, string expectedScheme, string expectedHost)
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", ""));
|
||||
|
||||
scheme.ShouldBe(expectedScheme);
|
||||
host.ShouldBe(expectedHost);
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Preserves_Path_Prefix()
|
||||
{
|
||||
(string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse("https://tv.example.com/etv").IfNone(("", "", ""));
|
||||
baseUrl.ShouldBe("/etv");
|
||||
}
|
||||
|
||||
[TestCase("https://tv.example.com/", "")]
|
||||
[TestCase("https://tv.example.com/etv/", "/etv")]
|
||||
[TestCase("https://tv.example.com/etv//", "/etv")]
|
||||
public void TryParse_Normalizes_Trailing_Slash(string configured, string expectedBaseUrl)
|
||||
{
|
||||
(string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", ""));
|
||||
baseUrl.ShouldBe(expectedBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Trims_Whitespace()
|
||||
{
|
||||
Option<(string Scheme, string Host, string BaseUrl)> result =
|
||||
AdvertisedBaseUrl.TryParse(" https://tv.example.com/etv ");
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
result.IfNone(("", "", "")).BaseUrl.ShouldBe("/etv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Handles_IPv6_Host()
|
||||
{
|
||||
(string _, string host, string _) = AdvertisedBaseUrl.TryParse("http://[2001:db8::1]:8409").IfNone(("", "", ""));
|
||||
host.ShouldBe("[2001:db8::1]:8409");
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
[TestCase("not a url")]
|
||||
[TestCase("tv.example.com")] // no scheme (relative)
|
||||
[TestCase("//tv.example.com")] // protocol-relative (not absolute)
|
||||
[TestCase("ftp://tv.example.com")] // non-http(s) scheme
|
||||
[TestCase("ws://tv.example.com")] // non-http(s) scheme
|
||||
[TestCase("http://user:pass@tv.example.com")] // credentials
|
||||
[TestCase("http://tv.example.com?foo=bar")] // query
|
||||
[TestCase("http://tv.example.com/etv?foo=bar")] // query on a path
|
||||
[TestCase("http://tv.example.com#frag")] // fragment
|
||||
[TestCase("http://")] // no host
|
||||
public void TryParse_Rejects_Invalid_Input(string configured) =>
|
||||
AdvertisedBaseUrl.TryParse(configured).IsNone.ShouldBeTrue();
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -105,129 +104,6 @@ public class ChannelGuideGoldenTests
|
||||
public Task Guide_with_base_url() =>
|
||||
Verify("guide-base-url.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "/etv", AccessToken: null));
|
||||
|
||||
// When an advertised base URL is configured (issue #340), {RequestBase} must use it instead of the
|
||||
// request-derived scheme/host — proving the override reaches both fragment substitution sites.
|
||||
[Test]
|
||||
public async Task Guide_uses_advertised_base_url_when_configured()
|
||||
{
|
||||
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>.Some("https://public.example.com/etv"));
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
string xml = result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// The advertised origin replaces {RequestBase} on both the channel <icon> and the programme <icon>.
|
||||
xml.ShouldContain("https://public.example.com/etv/iptv/logos/news.jpg");
|
||||
xml.ShouldContain("https://public.example.com/etv/iptv/artwork/posters/abc.jpg");
|
||||
xml.ShouldNotContain(Host);
|
||||
}
|
||||
|
||||
// A configured base URL whose path prefix contains an XML-special character ('&' is a legal URL
|
||||
// path char, so it passes AdvertisedBaseUrl validation) must be XML-escaped when substituted into
|
||||
// the guide fragments — otherwise a bare '&' malforms the whole document. (Reviewer finding, #340.)
|
||||
[Test]
|
||||
public async Task Guide_xml_escapes_advertised_base_url()
|
||||
{
|
||||
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>.Some("https://tv.example.com/a&b"));
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
string xml = result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// The '&' from the base URL must be emitted as '&', never a bare '&'.
|
||||
xml.ShouldContain("https://tv.example.com/a&b/iptv/logos/news.jpg");
|
||||
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)
|
||||
@@ -247,19 +123,11 @@ public class ChannelGuideGoldenTests
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
// No advertised base URL configured — the {RequestBase} substitution must use the request-derived
|
||||
// scheme/host/base, keeping today's output byte-for-byte identical (issue #340).
|
||||
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);
|
||||
localFileSystem);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(request, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelPlaylistHandlerTests
|
||||
{
|
||||
private const string Scheme = "https";
|
||||
private const string Host = "tv.example.com";
|
||||
|
||||
[Test]
|
||||
public async Task Uses_Advertised_Base_Url_When_Configured()
|
||||
{
|
||||
string m3u = await BuildM3U(configuredBaseUrl: "https://public.example.com/etv");
|
||||
|
||||
m3u.ShouldContain("https://public.example.com/etv/iptv/channel/1.");
|
||||
m3u.ShouldContain("https://public.example.com/etv/iptv/xmltv.xml");
|
||||
m3u.ShouldNotContain(Host);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Falls_Back_To_Request_When_Unset()
|
||||
{
|
||||
string m3u = await BuildM3U(configuredBaseUrl: null);
|
||||
|
||||
m3u.ShouldContain("https://tv.example.com/iptv/channel/1.");
|
||||
m3u.ShouldContain("https://tv.example.com/iptv/xmltv.xml");
|
||||
m3u.ShouldNotContain("public.example.com");
|
||||
}
|
||||
|
||||
private static async Task<string> BuildM3U(string configuredBaseUrl)
|
||||
{
|
||||
var channelRepository = Substitute.For<IChannelRepository>();
|
||||
channelRepository.GetAll(Arg.Any<CancellationToken>()).Returns([BuildChannel()]);
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Optional(configuredBaseUrl));
|
||||
|
||||
var handler = new GetChannelPlaylistHandler(channelRepository, configElementRepository);
|
||||
|
||||
ChannelPlaylist playlist = await handler.Handle(
|
||||
new GetChannelPlaylist(Scheme, Host, BaseUrl: "", Mode: "mixed", UserAgent: "VLC/3.0", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
return playlist.ToM3U();
|
||||
}
|
||||
|
||||
private static Channel BuildChannel() =>
|
||||
new(new Guid("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Number = "1",
|
||||
Name = "News",
|
||||
Group = "ErsatzTV",
|
||||
IsEnabled = true,
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
|
||||
Artwork = [],
|
||||
FFmpegProfile = new FFmpegProfile
|
||||
{
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
000 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Block Movie 01
|
||||
001 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Block Movie 02
|
||||
002 | 2026-01-16 09:00:00 - 2026-01-16 10:00:00 | None | Block Movie 03
|
||||
@@ -1,72 +0,0 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Movie 01
|
||||
001 | 2026-01-15 00:30:00 - 2026-01-15 01:15:00 | None | Movie 02
|
||||
002 | 2026-01-15 01:15:00 - 2026-01-15 02:15:00 | None | Movie 03
|
||||
003 | 2026-01-15 02:15:00 - 2026-01-15 02:45:00 | None | Movie 04
|
||||
004 | 2026-01-15 02:45:00 - 2026-01-15 03:30:00 | None | Movie 05
|
||||
005 | 2026-01-15 03:30:00 - 2026-01-15 04:30:00 | None | Movie 06
|
||||
006 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Movie 01
|
||||
007 | 2026-01-15 05:00:00 - 2026-01-15 05:45:00 | None | Movie 02
|
||||
008 | 2026-01-15 05:45:00 - 2026-01-15 06:45:00 | None | Movie 03
|
||||
009 | 2026-01-15 06:45:00 - 2026-01-15 07:15:00 | None | Movie 04
|
||||
010 | 2026-01-15 07:15:00 - 2026-01-15 08:00:00 | None | Movie 05
|
||||
011 | 2026-01-15 08:00:00 - 2026-01-15 09:00:00 | None | Movie 06
|
||||
012 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Movie 01
|
||||
013 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Movie 02
|
||||
014 | 2026-01-15 10:15:00 - 2026-01-15 11:15:00 | None | Movie 03
|
||||
015 | 2026-01-15 11:15:00 - 2026-01-15 11:45:00 | None | Movie 04
|
||||
016 | 2026-01-15 11:45:00 - 2026-01-15 12:30:00 | None | Movie 05
|
||||
017 | 2026-01-15 12:30:00 - 2026-01-15 13:30:00 | None | Movie 06
|
||||
018 | 2026-01-15 13:30:00 - 2026-01-15 14:00:00 | None | Movie 01
|
||||
019 | 2026-01-15 14:00:00 - 2026-01-15 14:45:00 | None | Movie 02
|
||||
020 | 2026-01-15 14:45:00 - 2026-01-15 15:45:00 | None | Movie 03
|
||||
021 | 2026-01-15 15:45:00 - 2026-01-15 16:15:00 | None | Movie 04
|
||||
022 | 2026-01-15 16:15:00 - 2026-01-15 17:00:00 | None | Movie 05
|
||||
023 | 2026-01-15 17:00:00 - 2026-01-15 18:00:00 | None | Movie 06
|
||||
024 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Movie 01
|
||||
025 | 2026-01-15 18:30:00 - 2026-01-15 19:15:00 | None | Movie 02
|
||||
026 | 2026-01-15 19:15:00 - 2026-01-15 20:15:00 | None | Movie 03
|
||||
027 | 2026-01-15 20:15:00 - 2026-01-15 20:45:00 | None | Movie 04
|
||||
028 | 2026-01-15 20:45:00 - 2026-01-15 21:30:00 | None | Movie 05
|
||||
029 | 2026-01-15 21:30:00 - 2026-01-15 22:30:00 | None | Movie 06
|
||||
030 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Movie 01
|
||||
031 | 2026-01-15 23:00:00 - 2026-01-15 23:45:00 | None | Movie 02
|
||||
032 | 2026-01-15 23:45:00 - 2026-01-16 00:45:00 | None | Movie 03
|
||||
033 | 2026-01-16 00:45:00 - 2026-01-16 01:15:00 | None | Movie 04
|
||||
034 | 2026-01-16 01:15:00 - 2026-01-16 02:00:00 | None | Movie 05
|
||||
035 | 2026-01-16 02:00:00 - 2026-01-16 03:00:00 | None | Movie 06
|
||||
036 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Movie 01
|
||||
037 | 2026-01-16 03:30:00 - 2026-01-16 04:15:00 | None | Movie 02
|
||||
038 | 2026-01-16 04:15:00 - 2026-01-16 05:15:00 | None | Movie 03
|
||||
039 | 2026-01-16 05:15:00 - 2026-01-16 05:45:00 | None | Movie 04
|
||||
040 | 2026-01-16 05:45:00 - 2026-01-16 06:30:00 | None | Movie 05
|
||||
041 | 2026-01-16 06:30:00 - 2026-01-16 07:30:00 | None | Movie 06
|
||||
042 | 2026-01-16 07:30:00 - 2026-01-16 08:00:00 | None | Movie 01
|
||||
043 | 2026-01-16 08:00:00 - 2026-01-16 08:45:00 | None | Movie 02
|
||||
044 | 2026-01-16 08:45:00 - 2026-01-16 09:45:00 | None | Movie 03
|
||||
045 | 2026-01-16 09:45:00 - 2026-01-16 10:15:00 | None | Movie 04
|
||||
046 | 2026-01-16 10:15:00 - 2026-01-16 11:00:00 | None | Movie 05
|
||||
047 | 2026-01-16 11:00:00 - 2026-01-16 12:00:00 | None | Movie 06
|
||||
048 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Movie 01
|
||||
049 | 2026-01-16 12:30:00 - 2026-01-16 13:15:00 | None | Movie 02
|
||||
050 | 2026-01-16 13:15:00 - 2026-01-16 14:15:00 | None | Movie 03
|
||||
051 | 2026-01-16 14:15:00 - 2026-01-16 14:45:00 | None | Movie 04
|
||||
052 | 2026-01-16 14:45:00 - 2026-01-16 15:30:00 | None | Movie 05
|
||||
053 | 2026-01-16 15:30:00 - 2026-01-16 16:30:00 | None | Movie 06
|
||||
054 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Movie 01
|
||||
055 | 2026-01-16 17:00:00 - 2026-01-16 17:45:00 | None | Movie 02
|
||||
056 | 2026-01-16 17:45:00 - 2026-01-16 18:45:00 | None | Movie 03
|
||||
057 | 2026-01-16 18:45:00 - 2026-01-16 19:15:00 | None | Movie 04
|
||||
058 | 2026-01-16 19:15:00 - 2026-01-16 20:00:00 | None | Movie 05
|
||||
059 | 2026-01-16 20:00:00 - 2026-01-16 21:00:00 | None | Movie 06
|
||||
060 | 2026-01-16 21:00:00 - 2026-01-16 21:30:00 | None | Movie 01
|
||||
061 | 2026-01-16 21:30:00 - 2026-01-16 22:15:00 | None | Movie 02
|
||||
062 | 2026-01-16 22:15:00 - 2026-01-16 23:15:00 | None | Movie 03
|
||||
063 | 2026-01-16 23:15:00 - 2026-01-16 23:45:00 | None | Movie 04
|
||||
064 | 2026-01-16 23:45:00 - 2026-01-17 00:30:00 | None | Movie 05
|
||||
065 | 2026-01-17 00:30:00 - 2026-01-17 01:30:00 | None | Movie 06
|
||||
066 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Movie 01
|
||||
067 | 2026-01-17 02:00:00 - 2026-01-17 02:45:00 | None | Movie 02
|
||||
068 | 2026-01-17 02:45:00 - 2026-01-17 03:45:00 | None | Movie 03
|
||||
069 | 2026-01-17 03:45:00 - 2026-01-17 04:15:00 | None | Movie 04
|
||||
070 | 2026-01-17 04:15:00 - 2026-01-17 05:00:00 | None | Movie 05
|
||||
071 | 2026-01-17 05:00:00 - 2026-01-17 06:00:00 | None | Movie 06
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user