Compare commits

..
Author SHA1 Message Date
timothyandOpenAI Codex 97397906e3 docs(jellyfin): record player-owned playback verdict
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 31s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 31s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m37s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Record the isolated Jellyfin 12 live evidence and the server-plugin-only no-go. Remove the discovery snapshot invalidated by Jellyfin's actual playback call path, scope the guide cache to connection settings, and fail closed for dangling mirror sources.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 20:15:35 +02:00
timothyandOpenAI Codex 63f72c7c50 fix(jellyfin): harden player-owned playback probe
Pin discovery/open to a short schedule-identity snapshot without sharing mutable Jellyfin media sources, gate the authorization-incomplete lab probe behind an explicit opt-in, and add focused plugin tests to the main solution. Redact remote URLs and serialize OpenAPI ApiKey requirements correctly.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-15 19:07:55 +02:00
timothyandOpenAI Codex dd4ce6f378 feat(api): resolve scheduled playback sources
Add an authenticated playback-source endpoint for player-owned playback and document the Jellyfin Live TV proof-of-concept boundary. Cache guide snapshots for the probe plugin while leaving native Jellyfin source handling in control.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 21:44:07 +02:00
timothyandOpenAI Codex 724ca5168f feat(jellyfin): add player-owned playback probe
Add a Jellyfin 12 rc2 ILiveTvService probe that reads ErsatzTV guide/source data and returns native Jellyfin media sources while preserving the LiveTvChannel session item.

Refs #357

Co-Authored-By: OpenAI Codex <codex@openai.com>
2026-07-14 21:37:35 +02:00
224 changed files with 8843 additions and 27704 deletions
-58
View File
@@ -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
-21
View File
@@ -46,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": [
@@ -69,17 +59,6 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" finish",
"timeout": 10
}
]
}
]
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2025.3.4.1",
"version": "2025.3.0.2",
"commands": [
"jb"
],
+5 -9
View File
@@ -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]
-136
View File
@@ -1,136 +0,0 @@
name: Build CI Toolchain Image
# Builds the shared CI toolchain image (.NET 10 SDK + Node 22 + prod-identical ffmpeg) and
# pushes it to the Gitea container registry (ersatztv#390). The toolchain jobs in
# docker-build.yml consume it via `container:`, pinned to an immutable :<sha>.
#
# push touching docker/ci/** -> :<short-sha> (+ :latest only from main)
# workflow_dispatch -> manual rebuild
# schedule (weekly) -> picks up base-image security updates
#
# Deliberately separate from docker-build.yml: this image changes rarely (a Dockerfile edit or
# the weekly cron), while docker-build.yml runs on every push/PR. Coupling them would rebuild a
# ~2GB toolchain image on every commit.
#
# ROLLOUT NOTE: the jobs pin an immutable :<sha>, never :latest — a broken toolchain image would
# otherwise block every converted job the moment it was pushed. Bumping the toolchain is therefore
# a deliberate two-step: merge a docker/ci/Dockerfile change (this workflow publishes a new :<sha>),
# then update the pin in docker-build.yml in a follow-up PR whose CI proves the new image works.
# See docs/ci-cd.md -> "CI toolchain image".
#
# Like docker-build.yml: the Gitea registry is HTTP-only, so BuildKit needs the inline
# `http = true` config (it does not inherit the host daemon's insecure-registries setting).
on:
workflow_dispatch:
push:
paths:
- 'docker/ci/**'
- '.gitea/workflows/ci-image.yml'
schedule:
# Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main).
#
# What this cron does and does NOT do — it does **not** update any running job. The jobs in
# docker-build.yml pin an immutable :<sha> (deliberately), so a rebuilt image is consumed only
# when a human bumps that pin. Its actual value is twofold:
# 1. a weekly CANARY — catches "the toolchain image no longer builds" (a NodeSource/apt/base
# change) at a time of our choosing, rather than when you next need to bump the pin;
# 2. it leaves a freshly-patched :latest so the next pin bump starts from a current base.
# `no-cache` on this path is what makes both real: with the shared :buildcache, the
# `apt-get update && apt-get install` layer would restore from cache and re-fetch nothing.
- cron: '0 5 * * 1'
# Serialize per ref: concurrent builds would race on the shared :buildcache tag.
# No cancel-in-progress — a half-pushed toolchain image is worse than a redundant build.
concurrency:
group: ersatztv-ci-image-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: 192.168.1.95:3000
CI_IMAGE: 192.168.1.95:3000/timothy/ersatztv-ci
jobs:
build:
name: Build & push CI image
# `small` = the small-jobs runner lane. This is a docker-only job (no toolchain needed —
# it *builds* the toolchain), same as docker-build.yml's `build` job.
runs-on: small
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# only docker/ci/Dockerfile is needed; no git describe/log here
fetch-depth: 1
- name: Compute tags
id: meta
run: |
set -euo pipefail
SHORT=$(git rev-parse --short HEAD)
# Always publish the immutable :<sha> — that is what docker-build.yml pins.
TAGS=("${CI_IMAGE}:${SHORT}")
# :latest is a convenience/floating pointer for humans and the weekly rebuild; jobs must
# never consume it. Only main may move it.
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
TAGS+=("${CI_IMAGE}:latest")
fi
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
{
echo "tags<<__EOT__"
printf '%s\n' "${TAGS[@]}"
echo "__EOT__"
} >> "$GITHUB_OUTPUT"
printf 'tag: %s\n' "${TAGS[@]}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."192.168.1.95:3000"]
http = true
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/ci/Dockerfile
platforms: linux/amd64
push: true
provenance: false
tags: ${{ steps.meta.outputs.tags }}
# The scheduled rebuild must bypass the cache or it is pointless: `mode=max` buildcache
# would restore the `apt-get update && apt-get install` layer verbatim and pull in none of
# the base updates the cron exists to collect. Push-triggered builds keep the cache.
no-cache: ${{ github.event_name == 'schedule' }}
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache,mode=max,ignore-error=true
# The Dockerfile's own build-time smoke test (dotnet --info, node, ffmpeg, ...) already ran
# inside the build. This re-checks the *pushed* artifact end-to-end: that the registry copy
# pulls and its toolchain runs, which is exactly what `container:` will do on every job.
- name: Verify the pushed image
run: |
set -euo pipefail
IMG="${CI_IMAGE}:${{ steps.meta.outputs.short }}"
echo "Pulling ${IMG}"
docker pull "$IMG"
docker run --rm --entrypoint /bin/bash "$IMG" -euxc '
dotnet --version
dotnet ef --version
node --version
ffmpeg -version | head -1
git --version
python3 --version
# reportgenerator --version exits 1 ("No report files specified"); probe the shim.
command -v reportgenerator
'
echo "CI image OK. Pin this in .gitea/workflows/docker-build.yml -> CI_IMAGE_REF:"
echo " ${IMG}"
+37 -229
View File
@@ -12,20 +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
on:
workflow_dispatch:
@@ -46,16 +32,6 @@ 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
@@ -64,11 +40,6 @@ 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
@@ -77,6 +48,11 @@ jobs:
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
@@ -87,14 +63,12 @@ jobs:
- name: Restore
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
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
working-directory: web
@@ -127,47 +101,11 @@ jobs:
run: dotnet build --configuration Release --no-restore
- name: Test
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
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
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)
@@ -191,6 +129,11 @@ jobs:
# default fetch-depth: 1 -- this job never runs git describe/log, only
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
@@ -204,13 +147,14 @@ jobs:
- name: Build
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
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
@@ -233,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
@@ -257,75 +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
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
# the unit-test gate — promote it to a required check / build dependency once it's proven
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
# Runs on PRs and on main (regression net); skipped for v* tag builds.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
container:
image: 192.168.1.95:3000/timothy/ersatztv-ci:07048b8
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Cache NuGet packages
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
run: dotnet restore
- name: Cache npm packages
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
working-directory: web
run: npm ci
- name: Build SPA
working-directory: web
run: npm run build
- name: Build (Release)
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
# the toolchain image ships the same ffmpeg build prod runs, so the binary is already here.
# That step also cost 110s of every run to install a dependency this harness never uses —
# it boots the app (whose only ffmpeg touch at startup is a LogWarning from
# FFmpegLocatorService) and drives curl-only contracts that never transcode.
- name: Boot instance and run functional-E2E harness
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).
@@ -453,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
@@ -579,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
@@ -622,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
@@ -634,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'
@@ -675,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
@@ -708,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
View File
@@ -2,8 +2,6 @@
*.*~
project.lock.json
.DS_Store
# Code-coverage output (dotnet test --results-directory ./coverage, ersatztv#15)
/coverage/
*.pyc
.worktrees/
+2 -13
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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,44 +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)
};
// 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;
}
}
@@ -1,35 +0,0 @@
using ErsatzTV.Core.Domain;
using MediatR;
namespace ErsatzTV.Application.Channels;
public record CreateAutoTunedChannels(
int TemplateId,
string Group,
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
public record AutoTuneChannelSelection(
AutoTuneAxis Axis,
string Value,
string Name,
string Number);
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,116 +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 LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels;
public class CreateAutoTunedChannelsHandler(ISender mediator)
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
{
private const string NumberTakenError = "Channel number must be unique";
private const string DefaultGroup = "Auto-Tuned";
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");
}
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
// 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,
ArtworkContentTypeModel.None,
IsEnabled: true,
ShowInEpg: true,
templateId,
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
[
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);
}
}
@@ -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,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();
}
}
@@ -119,6 +119,7 @@ public class GetChannelGuideDataHandler(
responseChannels.Add(
new ChannelGuideChannelResponseModel(
channel.Id,
channel.Number,
channel.Name,
programmes.OrderBy(p => p.Start).ToList()));
@@ -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 &amp;), 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)
@@ -67,7 +48,7 @@ public partial class GetChannelGuideHandler(
// 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>();
@@ -89,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);
@@ -0,0 +1,6 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelPlaybackSource(int ChannelId, DateTimeOffset At)
: IRequest<Option<ChannelPlaybackSourceResponseModel>>;
@@ -0,0 +1,171 @@
#nullable enable
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Resolves the physical playout item at a point in time without invoking the streaming or FFmpeg pipeline.
/// Guide projection is deliberately not used here: guide entries may merge filler or split a block differently
/// from the actual media-item boundaries a player must follow.
/// </summary>
public class GetChannelPlaybackSourceHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelPlaybackSource, Option<ChannelPlaybackSourceResponseModel>>
{
public async Task<Option<ChannelPlaybackSourceResponseModel>> Handle(
GetChannelPlaybackSource request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Channel? channel = await dbContext.Channels
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Id == request.ChannelId, cancellationToken);
if (channel is null)
{
return None;
}
// Deleting a mirror's source sets this nullable FK to null. Do not self-resolve to a stale
// playout that may remain attached to the mirror channel.
if (channel.PlayoutSource == ChannelPlayoutSource.Mirror && channel.MirrorSourceChannelId is null)
{
return None;
}
int sourceChannelId = channel.PlayoutSource == ChannelPlayoutSource.Mirror
? channel.MirrorSourceChannelId!.Value
: channel.Id;
TimeSpan playoutOffset = channel.PlayoutSource == ChannelPlayoutSource.Mirror
? channel.PlayoutOffset ?? TimeSpan.Zero
: TimeSpan.Zero;
DateTime sourceAtUtc = request.At.UtcDateTime - playoutOffset;
PlayoutItem? active = await ActiveItems(dbContext, sourceChannelId, sourceAtUtc)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync(cancellationToken);
DateTime? nextSourceTransition = active?.Finish;
if (nextSourceTransition is null)
{
nextSourceTransition = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Playout.ChannelId == sourceChannelId && pi.Start > sourceAtUtc)
.OrderBy(pi => pi.Start)
.Select(pi => (DateTime?)pi.Start)
.FirstOrDefaultAsync(cancellationToken);
}
DateTimeOffset resolvedAt = request.At.ToUniversalTime();
DateTimeOffset sourceAt = new(sourceAtUtc, TimeSpan.Zero);
DateTimeOffset? nextTransitionAt = nextSourceTransition.HasValue
? new DateTimeOffset(nextSourceTransition.Value + playoutOffset, TimeSpan.Zero)
: null;
ChannelPlaybackItemResponseModel? playbackItem = active is null
? null
: ToPlaybackItem(active, sourceAtUtc, playoutOffset);
return new ChannelPlaybackSourceResponseModel(
channel.Id,
sourceChannelId,
resolvedAt,
sourceAt,
nextTransitionAt,
playbackItem);
}
private static IQueryable<PlayoutItem> ActiveItems(TvContext dbContext, int sourceChannelId, DateTime sourceAtUtc) =>
dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Playout.ChannelId == sourceChannelId)
.Where(pi => pi.Start <= sourceAtUtc && pi.Finish > sourceAtUtc)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream)!.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.AsSplitQuery();
private static ChannelPlaybackItemResponseModel ToPlaybackItem(
PlayoutItem item,
DateTime sourceAtUtc,
TimeSpan playoutOffset)
{
TimeSpan currentOffset = item.InPoint + (sourceAtUtc - item.Start);
if (currentOffset < item.InPoint)
{
currentOffset = item.InPoint;
}
if (item.OutPoint > item.InPoint && currentOffset > item.OutPoint)
{
currentOffset = item.OutPoint;
}
return new ChannelPlaybackItemResponseModel(
item.Id,
item.MediaItemId,
new DateTimeOffset(item.Start + playoutOffset, TimeSpan.Zero),
new DateTimeOffset(item.Finish + playoutOffset, TimeSpan.Zero),
item.InPoint.Ticks,
currentOffset.Ticks,
item.OutPoint.Ticks,
item.FillerKind,
GetSourceReference(item.MediaItem));
}
private static ChannelPlaybackSourceReferenceResponseModel GetSourceReference(MediaItem mediaItem) =>
mediaItem switch
{
JellyfinMovie movie => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: movie.ItemId),
JellyfinEpisode episode => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: episode.ItemId),
PlexMovie movie => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: movie.Key),
PlexEpisode episode => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: episode.Key),
PlexOtherVideo video => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: video.Key),
EmbyMovie movie => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: movie.ItemId),
EmbyEpisode episode => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: episode.ItemId),
RemoteStream stream => Reference(ChannelPlaybackSourceKind.RemoteUrl, isLive: stream.IsLive),
Movie movie => LocalFile(movie.MediaVersions),
Episode episode => LocalFile(episode.MediaVersions),
MusicVideo video => LocalFile(video.MediaVersions),
OtherVideo video => LocalFile(video.MediaVersions),
Song song => LocalFile(song.MediaVersions),
Image image => LocalFile(image.MediaVersions),
_ => Reference(ChannelPlaybackSourceKind.Unsupported)
};
private static ChannelPlaybackSourceReferenceResponseModel LocalFile(IEnumerable<MediaVersion> versions)
{
string? path = versions.FirstOrDefault()?.MediaFiles.FirstOrDefault()?.Path;
return string.IsNullOrWhiteSpace(path)
? Reference(ChannelPlaybackSourceKind.Unsupported)
: Reference(ChannelPlaybackSourceKind.LocalFile, path: path);
}
private static ChannelPlaybackSourceReferenceResponseModel Reference(
ChannelPlaybackSourceKind kind,
string? itemId = null,
string? path = null,
bool isLive = false) =>
new(kind, itemId, path, isLive);
}
@@ -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,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>
@@ -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,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(
+2 -3
View File
@@ -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,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,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,88 +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 '&amp;', never a bare '&'.
xml.ShouldContain("https://tv.example.com/a&amp;b/iptv/logos/news.jpg");
xml.ShouldNotContain("a&b");
}
// --- harness ---
private async Task Verify(string goldenName, GetChannelGuide request)
@@ -206,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,997 +0,0 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem;
namespace ErsatzTV.Core.Tests.Scheduling;
// Golden-file characterization tests that lock the output of the playout builders — the core
// scheduling surface that turns a ProgramSchedule/Block calendar + Collection into a concrete list of
// PlayoutItems (issue #163). This slice covers the Classic builder (PlaybackOrder.Chronological) and
// the Block builder. Sequential (YAML) + Scripted goldens are tracked as a follow-up in #381 (they need
// a YAML fixture / an external-process harness respectively — not just a clock seam).
//
// This is the scheduling counterpart to ChannelPlaylistGoldenTests (#11, M3U) and
// ChannelGuideGoldenTests (#28, XMLTV). Goldens live under Goldens/Goldens/ and are regenerated via
// the Regenerate_goldens test or ETV_UPDATE_PLAYOUT_GOLDENS=1 — review the diff before committing.
//
// DETERMINISM: time enters the build ONLY via the pinned start (finish = start + 2 days); no wall clock
// is read. We snapshot the raw PlayoutItem.Start/Finish (DateTime, treated as UTC) — NOT the *Offset
// properties, which call .ToLocalTime() and would make the golden machine-timezone dependent.
//
// A few determinism invariants worth stating so a future reader doesn't "helpfully" break them:
// * We snapshot the builder's raw output (buildResult.AddedItems), NOT the persisted playout. With
// TrimStart, production would delete items before RemoveBefore (~start - 4h), so the golden's early
// lines are pre-trim. That is intentional: this locks the BUILDER's output, and it is deterministic.
// * Classic is TZ-independent for the captured fields (its internal DateTime->offset conversions only
// gate the day-by-day loop; the anchor carries currentTime forward as UTC), so it needs no TZ guard —
// unlike Block below. Do not add/remove a guard without re-checking this.
// * ResetPlayout randomizes playout.Seed, but the classic fixture neutralizes it: RandomStartPoint and
// ShuffleScheduleItems both default false and Chronological orders by (distinct) release date, so the
// seed cannot perturb output. Introducing release-date ties or flipping those flags would reintroduce
// nondeterminism.
[TestFixture]
public class PlayoutBuildGoldenTests
{
// Pinned build window — deterministic, UTC, no wall-clock dependency.
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
private SqliteConnection _connection;
private IDbContextFactory<TvContext> _dbContextFactory;
[OneTimeSetUp]
public async Task SetUpDatabase()
{
// Shared in-memory SQLite: the connection must stay open for the DB to live across contexts.
_connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
await _connection.OpenAsync();
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
.UseSqlite(_connection)
.Options;
_dbContextFactory = new TestTvContextFactory(options);
await using TvContext context = _dbContextFactory.CreateDbContext();
// EnsureCreated builds the schema from the model directly — sufficient here and far cheaper than
// replaying every migration. The MediaCollectionRepository's Dapper queries run against it fine.
await context.Database.EnsureCreatedAsync();
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
}
[OneTimeTearDown]
public void TearDownDatabase() => _connection?.Dispose();
[Test]
public Task Classic_chronological() => Verify("classic-chronological.txt", BuildChronologicalPlayout);
[Test]
public Task Block_playout() => Verify("block.txt", BuildBlockPlayout);
// Issue #77: clock-boundary padding. A PostRoll FillerPreset with FillerMode.Pad +
// PadToNearestMinute = 15 snaps each ragged content item up to the next :00/:15/:30/:45 mark, filling
// the gap with filler. This locks BOTH the exact builder output (golden) AND the invariant that every
// content item after the first begins on a quarter-hour boundary — the end-to-end proof that the
// existing Pad machinery already delivers #77's "clean clock-aligned guides" for Classic playouts.
[Test]
public async Task Classic_clock_padded()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout();
List<PlayoutItem> content = items
.Where(i => i.FillerKind == FillerKind.None)
.OrderBy(i => i.Start)
.ToList();
// Sanity: the fixture must actually produce several content items and pad filler between them.
content.Count.ShouldBeGreaterThan(3);
items.ShouldContain(i => i.FillerKind == FillerKind.PostRoll);
// The invariant: content items (the first is the raw anchor at :00) start on a :15 boundary because
// the preceding item was padded up to it. Raw Start is UTC here, matching the deterministic fixture.
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
await CompareGolden("classic-clock-padded.txt", items, titles);
}
// Classic + PlaybackOrder.Shuffle: exercises PlayoutBuilder's call into the shuffle-source helper
// (GetGroupedMediaItemsForShuffle) that #380 moves to ShuffleSourceBuilder, plus the wiring into
// ShuffledMediaCollectionEnumerator. Unlike the chronological fixture, shuffle output depends on the
// seed, so this build pins playout.Seed and uses Continue mode (Reset would randomize the seed).
[Test]
public Task Classic_shuffle() => Verify("classic-shuffle.txt", BuildShufflePlayout);
[Test]
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
public async Task Regenerate_goldens()
{
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", "1");
try
{
foreach (Func<Task> regen in new Func<Task>[]
{ Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle })
{
try
{
await regen();
}
catch (InconclusiveException)
{
// expected — Verify writes its golden then reports inconclusive
}
}
}
finally
{
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", null);
}
}
// --- harness ---
private async Task Verify(
string goldenName,
Func<Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)>> build)
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await build();
await CompareGolden(goldenName, items, titles);
}
// Snapshot + golden-compare (or regenerate). Split out of Verify so a test that also runs explicit
// assertions on the built items (e.g. Classic_clock_padded) can reuse the same golden mechanics.
private async Task CompareGolden(string goldenName, List<PlayoutItem> items, Dictionary<int, string> titles)
{
string actual = Canonicalize(Snapshot(items, titles));
string path = Path.Combine(GoldenDir(), goldenName);
if (Environment.GetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS") == "1")
{
Directory.CreateDirectory(GoldenDir());
await File.WriteAllTextAsync(path, actual);
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
return;
}
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline can't pass CI.
File.Exists(path).ShouldBeTrue(
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_PLAYOUT_GOLDENS=1) and commit it.");
string expected = Canonicalize(await File.ReadAllTextAsync(path));
actual.ShouldBe(expected);
}
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildChronologicalPlayout()
{
var cancellationToken = CancellationToken.None;
// Seed a fresh, deterministic dataset for this build. Titles + release dates are fixed and the
// durations vary (30/45/60) so the chronological ordering and item boundaries are visible.
var (playoutId, titles) = await SeedData(cancellationToken);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
NullLogger<PlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
// Build ONCE with Reset over the pinned 2-day window (internal overload = explicit start/finish).
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildShufflePlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedShuffleData(cancellationToken);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
NullLogger<PlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
// Continue (not Reset): Reset randomizes playout.Seed, which would make the shuffle
// nondeterministic. The pinned Seed (set in SeedShuffleData) flows to the enumerator seed, so the
// shuffled order is stable. Anchor is null and there are no existing items, so Continue still
// builds the full pinned 2-day window from the start.
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Continue,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedShuffleData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Shuffle LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Shuffle Movie {i:D2}",
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
var collection = new Collection
{
Name = "Shuffle Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItems = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
Collection = collection,
CollectionId = collection.Id,
CollectionType = CollectionType.Collection,
PlayoutDuration = TimeSpan.FromHours(1),
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Shuffle
}
};
var ffmpegProfile = new FFmpegProfile { Name = "Shuffle FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000004"))
{
Name = "Shuffle Test Channel",
Number = "4",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = "Shuffle Test Schedule", Items = scheduleItems };
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id,
ScheduleKind = PlayoutScheduleKind.Classic,
// Pinned so the shuffle is deterministic; Continue mode preserves it (Reset would overwrite).
Seed = 1234567
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Test LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Six movies, fixed titles + release dates, varied durations to make ordering/boundaries visible.
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Movie {i:D2}",
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
var collection = new Collection
{
Name = "Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItems = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
Collection = collection,
CollectionId = collection.Id,
CollectionType = CollectionType.Collection,
PlayoutDuration = TimeSpan.FromHours(1),
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Chronological
}
};
var ffmpegProfile = new FFmpegProfile { Name = "Test FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
{
Name = "Test Channel",
Number = "1",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = "Test Schedule", Items = scheduleItems };
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id,
ScheduleKind = PlayoutScheduleKind.Classic
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
.AsNoTracking()
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
.Include(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.FirstOrDefaultAsync();
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
programSchedule,
[],
[],
TimeSpan.Zero);
}
// --- Clock-boundary pad builder (issue #77) ---
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedPaddedData(cancellationToken);
var builder = new PlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
new MockFileSystem(),
Substitute.For<IRerunHelper>(),
NullLogger<PlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetPaddedReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
Start,
Start.AddDays(2),
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedPaddedData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Padded LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Content: three movies with OFF-boundary durations (22/37/52 min) so padding to :15 is visible.
int[] durationsMinutes = [22, 37, 52];
var movies = new List<Movie>();
for (var i = 1; i <= 3; i++)
{
movies.Add(new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = $"Padded Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
},
LibraryPath = path,
LibraryPathId = path.Id
});
}
// Filler: a SINGLE 1-minute clip. The builder shuffles filler collections (seed-dependent), so a
// one-item collection keeps the golden deterministic while still filling any integer-minute gap.
var fillerClip = new Movie
{
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
MovieMetadata = new List<MovieMetadata>
{
new() { Title = "Filler Clip", ReleaseDate = new DateTime(2019, 1, 1) }
},
LibraryPath = path,
LibraryPathId = path.Id
};
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.Movies.AddAsync(fillerClip, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
titles[fillerClip.Id] = fillerClip.MovieMetadata[0].Title;
var contentCollection = new Collection
{
Name = "Padded Content Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
var fillerCollection = new Collection
{
Name = "Padded Filler Collection",
MediaItems = new List<MediaItem> { fillerClip }
};
await context.Collections.AddAsync(contentCollection, cancellationToken);
await context.Collections.AddAsync(fillerCollection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// TZ-INDEPENDENCE INVARIANT: the pad ceiling is computed on the LOCAL minute-of-hour
// (PlayoutModeSchedulerBase uses StartOffset = ToLocalTime()), yet this golden snapshots raw UTC
// Start/Finish. Those agree across machine timezones ONLY because PadToNearestMinute = 15 divides
// every real IANA UTC offset (all are multiples of 15 min — Kolkata +5:30, Nepal +5:45, Chatham
// +12:45), so the pad delta is offset-invariant. Do NOT regenerate this golden with a non-15-divisor
// increment (e.g. 10) — it would become machine-TZ dependent and need the Block-style Assume guard.
var padFiller = new FillerPreset
{
Name = "Pad To Quarter Hour",
FillerKind = FillerKind.PostRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 15,
CollectionType = CollectionType.Collection,
Collection = fillerCollection,
CollectionId = fillerCollection.Id
};
await context.FillerPresets.AddAsync(padFiller, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var scheduleItems = new List<ProgramScheduleItem>
{
new ProgramScheduleItemOne
{
Collection = contentCollection,
CollectionId = contentCollection.Id,
CollectionType = CollectionType.Collection,
PlaybackOrder = PlaybackOrder.Chronological,
PostRollFiller = padFiller,
PostRollFillerId = padFiller.Id
}
};
var ffmpegProfile = new FFmpegProfile { Name = "Padded FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000003"))
{
Name = "Padded Test Channel",
Number = "3",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var schedule = new ProgramSchedule { Name = "Padded Test Schedule", Items = scheduleItems };
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ProgramSchedule = schedule,
ProgramScheduleId = schedule.Id,
ScheduleKind = PlayoutScheduleKind.Classic
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetPaddedReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
.AsNoTracking()
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
.Include(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.Include(ps => ps.Items)
.ThenInclude(psi => psi.PostRollFiller)
.FirstOrDefaultAsync();
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
programSchedule,
[],
[],
TimeSpan.Zero);
}
// --- Block builder ---
//
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
// EffectiveBlock.GetEffectiveBlocks(..., TimeZoneInfo.Local, ...), so its output is machine-timezone
// dependent. This is a CHARACTERIZATION test: rather than change production code to inject the zone
// (that seam is issue #380's scope), we capture the golden under UTC and GUARD with Assume.That so the
// test RUNS under TZ=UTC (CI) and reports INCONCLUSIVE (a graceful skip, not a failure) under any other
// TZ — mirroring ChannelPlaylistGoldenTests' GuardVolatileEnvironment. Classic + other TZ-independent
// goldens are unaffected.
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildBlockPlayout()
{
// Guard on the offset AT the build instant (GetUtcOffset(Start)), not BaseUtcOffset: the latter is
// zero for DST zones like Europe/London year-round, so it would pass in a summer-dated fixture where
// London != UTC. GetUtcOffset pins the actual instant and stays correct regardless of fixture date.
Assume.That(
TimeZoneInfo.Local.GetUtcOffset(Start),
Is.EqualTo(TimeSpan.Zero),
"Block golden is captured under UTC; run with TZ=UTC. A real TZ seam is issue #380's scope.");
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedBlockData(cancellationToken);
var builder = new BlockPlayoutBuilder(
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
new ArtistRepository(_dbContextFactory),
Substitute.For<ICollectionEtag>(),
NullLogger<BlockPlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetBlockReferenceData(context, playoutId);
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
Start,
playout,
referenceData,
PlayoutBuildMode.Reset,
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedBlockData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Block LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Six movies, fixed titles + release dates, varied durations so chronological ordering and block
// boundaries are visible across the scheduled blocks.
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Block Movie {i:D2}",
ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
var collection = new Collection
{
Name = "Block Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// A single 60-minute block with three chronological items over the same collection. With
// AfterDurationEnd, each block fills until currentTime passes the block finish; history carries the
// chronological cursor across the blocks scheduled on successive days.
var blockGroup = new BlockGroup { Name = "Block Test Group" };
await context.BlockGroups.AddAsync(blockGroup, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var block = new Block
{
BlockGroup = blockGroup,
BlockGroupId = blockGroup.Id,
Name = "Test Block",
Minutes = 60,
StopScheduling = BlockStopScheduling.AfterDurationEnd,
Items = new List<BlockItem>
{
new()
{
Index = 1,
CollectionType = CollectionType.Collection,
Collection = collection,
CollectionId = collection.Id,
PlaybackOrder = PlaybackOrder.Chronological
},
new()
{
Index = 2,
CollectionType = CollectionType.Collection,
Collection = collection,
CollectionId = collection.Id,
PlaybackOrder = PlaybackOrder.Chronological
},
new()
{
Index = 3,
CollectionType = CollectionType.Collection,
Collection = collection,
CollectionId = collection.Id,
PlaybackOrder = PlaybackOrder.Chronological
}
}
};
await context.Blocks.AddAsync(block, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var templateGroup = new TemplateGroup { Name = "Template Test Group" };
await context.TemplateGroups.AddAsync(templateGroup, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var template = new Template
{
TemplateGroup = templateGroup,
TemplateGroupId = templateGroup.Id,
Name = "Test Template",
Items = new List<TemplateItem>()
};
template.Items.Add(new TemplateItem
{
Block = block,
BlockId = block.Id,
StartTime = TimeSpan.FromHours(9)
});
await context.Templates.AddAsync(template, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var ffmpegProfile = new FFmpegProfile { Name = "Block FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000002"))
{
Name = "Block Test Channel",
Number = "2",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ScheduleKind = PlayoutScheduleKind.Block
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var playoutTemplate = new PlayoutTemplate
{
Playout = playout,
PlayoutId = playout.Id,
Template = template,
TemplateId = template.Id,
Index = 1,
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
};
await context.PlayoutTemplates.AddAsync(playoutTemplate, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetBlockReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
List<PlayoutItem> existingItems = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.PlayoutId == playoutId)
.ToListAsync();
List<PlayoutTemplate> playoutTemplates = await dbContext.PlayoutTemplates
.AsNoTracking()
.Where(pt => pt.PlayoutId == playoutId)
.Include(t => t.Template)
.ThenInclude(t => t.Items)
.ThenInclude(i => i.Block)
.ThenInclude(b => b.Items)
.Include(t => t.DecoTemplate)
.ThenInclude(t => t.Items)
.ThenInclude(i => i.Deco)
.ToListAsync();
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
existingItems,
playoutTemplates,
null,
[],
[],
TimeSpan.Zero);
}
// One line per PlayoutItem, ordered by Start then MediaItemId (stable tiebreak). Raw UTC Start/Finish
// serialized invariant — NOT the *Offset properties (those localize). Title resolved from the seed map.
private static string Snapshot(List<PlayoutItem> items, Dictionary<int, string> titles)
{
var ordered = items
.OrderBy(i => i.Start)
.ThenBy(i => i.MediaItemId)
.ToList();
var sb = new StringBuilder();
for (var index = 0; index < ordered.Count; index++)
{
PlayoutItem item = ordered[index];
string title = titles.TryGetValue(item.MediaItemId, out string t) ? t : $"#{item.MediaItemId}";
sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" - ");
sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.FillerKind.ToString());
sb.Append(" | ");
sb.Append(title);
sb.Append('\n');
}
return sb.ToString();
}
private static string Canonicalize(string text) =>
text.TrimStart('').ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() =>
new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
}
}
@@ -357,82 +357,6 @@ public class PlaylistEnumeratorTests
enumerator.CountForFiller.ShouldBe(7);
}
// Characterization of the cross-engine reach-in surface (#380): a playlist whose items use Shuffle
// and ShuffleInOrder exercises PlaylistEnumerator.Create's two calls into PlayoutBuilder's static
// shuffle-source helpers (GetGroupedMediaItemsForShuffle @ :174, GetCollectionItemsForShuffleInOrder
// @ :185). Those statics move to ShuffleSourceBuilder; this pins the emitted sequence so the move is
// proven behavior-preserving. Fixed seed => deterministic. If this changes, the refactor drifted.
[Test]
public async Task ReachIn_Shuffle_And_ShuffleInOrder_Items_Are_Characterized()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
// ShuffleInOrder (collection 2) reads its source from the repo's fake-multi-collection lookup.
repo.GetFakeMultiCollectionCollections(2, null, Arg.Any<CancellationToken>())
.Returns(
new List<CollectionWithItems>
{
new(
ShowId: 0,
ArtistId: 0,
Key: "collection-2",
MediaItems: [FakeMovie(20), FakeMovie(21), FakeMovie(22)],
ScheduleAsGroup: false,
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
UseCustomOrder: false)
});
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
Index = 0,
PlaybackOrder = PlaybackOrder.Shuffle,
PlayAll = true,
CollectionType = CollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10), FakeMovie(11), FakeMovie(12)]
},
{
new PlaylistItem
{
Id = 2,
Index = 1,
PlaybackOrder = PlaybackOrder.ShuffleInOrder,
PlayAll = true,
CollectionType = CollectionType.Collection,
CollectionId = 2
},
[FakeMovie(20), FakeMovie(21), FakeMovie(22)]
}
};
var state = new CollectionEnumeratorState { Seed = 12345 };
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None);
var items = new List<int>();
for (var i = 0; i < 12; i++)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
// Pinned from current behavior: Shuffle collection (10-12) and ShuffleInOrder collection (20-22)
// each cycle in a seed-stable shuffled order. This is the reach-in output; the ShuffleSourceBuilder
// extraction must reproduce it byte-for-byte.
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
}
private static Movie FakeMovie(int id) => new()
{
Id = id,
@@ -1,185 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
// Direct unit coverage for the shuffle-source helper extracted from PlayoutBuilder (#380). The builder
// goldens exercise the common non-multi-collection path; these pin the branch selection (multi-collection
// vs fake-multi-collection lookup, multi-part grouping on/off) that the goldens don't reach, so the
// "one well-tested place per family" the issue asks for is genuinely tested here.
[TestFixture]
public class ShuffleSourceBuilderTests
{
[Test]
public async Task GetGroupedMediaItemsForShuffle_Single_Collection_No_MultiPart_Groups_Each_Item()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
List<MediaItem> items = [FakeMovie(1), FakeMovie(2), FakeMovie(3)];
var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 7 };
List<GroupedMediaItem> result = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
repo,
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
items,
key,
CancellationToken.None);
// No multi-part grouping and no multi-collection => each item is its own group, no repo call.
result.Count.ShouldBe(3);
result.Select(g => g.First.Id).ShouldBe([1, 2, 3]);
result.ShouldAllBe(g => g.Additional.Count == 0);
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
}
[Test]
public async Task GetGroupedMediaItemsForShuffle_MultiCollection_Reads_From_Repo()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.GetMultiCollectionCollections(42, Arg.Any<CancellationToken>())
.Returns(
new List<CollectionWithItems>
{
new(
ShowId: 0,
ArtistId: 0,
Key: "mc",
MediaItems: [FakeMovie(10), FakeMovie(11)],
ScheduleAsGroup: false,
PlaybackOrder: PlaybackOrder.Shuffle,
UseCustomOrder: false)
});
var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 42 };
List<GroupedMediaItem> result = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
repo,
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
// the passed items list is ignored on the multi-collection branch
[],
key,
CancellationToken.None);
// The multi-collection branch reads from the repo and feeds MultiCollectionGrouper (whose grouping
// behavior is covered by its own tests); here we only pin the branch selection + passthrough.
await repo.Received(1).GetMultiCollectionCollections(42, Arg.Any<CancellationToken>());
GroupedMediaItem.FlattenGroups(result, 2).Select(mi => mi.Id).OrderBy(id => id).ShouldBe([10, 11]);
}
[Test]
public async Task GetCollectionItemsForShuffleInOrder_Single_Collection_Uses_Fake_MultiCollection_Lookup()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var expected = new List<CollectionWithItems>
{
new(
ShowId: 0,
ArtistId: 0,
Key: "fake",
MediaItems: [FakeMovie(20)],
ScheduleAsGroup: false,
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
UseCustomOrder: false)
};
repo.GetFakeMultiCollectionCollections(5, null, Arg.Any<CancellationToken>()).Returns(expected);
var key = new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 5 };
List<CollectionWithItems> result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
repo,
key,
CancellationToken.None);
result.ShouldBe(expected);
await repo.Received(1).GetFakeMultiCollectionCollections(5, null, Arg.Any<CancellationToken>());
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
}
[Test]
public async Task GetCollectionItemsForShuffleInOrder_MultiCollection_Uses_MultiCollection_Lookup()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var expected = new List<CollectionWithItems>
{
new(
ShowId: 0,
ArtistId: 0,
Key: "mc",
MediaItems: [FakeMovie(30), FakeMovie(31)],
ScheduleAsGroup: true,
PlaybackOrder: PlaybackOrder.ShuffleInOrder,
UseCustomOrder: false)
};
repo.GetMultiCollectionCollections(9, Arg.Any<CancellationToken>()).Returns(expected);
var key = new CollectionKey { CollectionType = CollectionType.MultiCollection, MultiCollectionId = 9 };
List<CollectionWithItems> result = await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
repo,
key,
CancellationToken.None);
result.ShouldBe(expected);
await repo.Received(1).GetMultiCollectionCollections(9, Arg.Any<CancellationToken>());
await repo.DidNotReceiveWithAnyArgs().GetFakeMultiCollectionCollections(default, default, default);
}
[Test]
public async Task GetGroupedMediaItemsForShuffle_KeepMultiPart_Groups_Adjacent_Parts()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
// "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together.
List<MediaItem> items =
[
NamedEpisode("Episode 1", 1),
NamedEpisode("Episode 2 (1)", 2),
NamedEpisode("Episode 3 (2)", 3),
NamedEpisode("Episode 4", 4)
];
List<GroupedMediaItem> kept = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
repo,
keepMultiPartEpisodesTogether: true,
treatCollectionsAsShows: false,
items,
new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 1 },
CancellationToken.None);
// keep=true routes through MultiPartEpisodeGrouper -> the two parts collapse into one group (3 total).
kept.Count.ShouldBe(3);
List<GroupedMediaItem> notKept = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
repo,
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
items,
new CollectionKey { CollectionType = CollectionType.Collection, CollectionId = 1 },
CancellationToken.None);
// keep=false leaves each item its own group (4 total) — proves the flag observably selects the branch.
notKept.Count.ShouldBe(4);
await repo.DidNotReceiveWithAnyArgs().GetMultiCollectionCollections(default, default);
}
private static Episode NamedEpisode(string title, int id) => new()
{
Id = id,
EpisodeMetadata = [new EpisodeMetadata { Title = title, EpisodeNumber = id }],
Season = new Season { SeasonNumber = 1, Show = new Show { Id = 1 }, ShowId = 1 }
};
private static Movie FakeMovie(int id) => new()
{
Id = id,
MediaVersions = [],
MovieMetadata =
[
new MovieMetadata { ReleaseDate = new DateTime(2020, 1, id) }
]
};
}
@@ -1,22 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
public record AutoTuneProposalResponseModel(
string Axis,
string Value,
string Name,
string Number,
int ItemCount,
bool AlreadyExists);
public record AutoTuneChannelResultModel(
string Name,
string Status,
int? ChannelId,
string? Reason);
public record AutoTuneResultResponseModel(
List<AutoTuneChannelResultModel> Results,
int CreatedCount,
int SkippedCount,
int FailedCount);
@@ -14,6 +14,7 @@ public record ChannelGuideProgrammeResponseModel(
/// <summary>One channel's guide programmes for the requested window.</summary>
public record ChannelGuideChannelResponseModel(
int Id,
string Number,
string Name,
List<ChannelGuideProgrammeResponseModel> Programmes);
@@ -0,0 +1,46 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Channels;
/// <summary>The kind of source selected by the channel schedule.</summary>
public enum ChannelPlaybackSourceKind
{
LocalFile,
JellyfinItem,
PlexItem,
EmbyItem,
RemoteUrl,
Unsupported
}
/// <summary>A token-free reference to the scheduled media source.</summary>
public record ChannelPlaybackSourceReferenceResponseModel(
ChannelPlaybackSourceKind Kind,
string? ItemId,
string? Path,
bool IsLive);
/// <summary>The physical playout item covering the requested channel time.</summary>
public record ChannelPlaybackItemResponseModel(
int PlayoutItemId,
int MediaItemId,
DateTimeOffset Start,
DateTimeOffset Finish,
long InPointTicks,
long CurrentOffsetTicks,
long OutPointTicks,
FillerKind FillerKind,
ChannelPlaybackSourceReferenceResponseModel Source);
/// <summary>
/// Resolves a viewer-facing channel time to the physical media item selected by the schedule.
/// This is intentionally playback-engine neutral and never starts an ErsatzTV transcoder.
/// </summary>
public record ChannelPlaybackSourceResponseModel(
int ChannelId,
int SourceChannelId,
DateTimeOffset ResolvedAt,
DateTimeOffset SourceAt,
DateTimeOffset? NextTransitionAt,
ChannelPlaybackItemResponseModel? Active);
@@ -12,5 +12,4 @@ public record PlayoutListItemResponseModel(
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus,
ChannelPlayoutMode PlayoutMode,
bool IsLocked,
int Seed);
bool IsLocked);
@@ -15,8 +15,7 @@ public record PlayoutResponseModel(
PlayoutBuildStatusResponseModel? BuildStatus,
int? DecoId,
string? DecoName,
bool IsLocked,
int Seed)
bool IsLocked)
{
public static PlayoutResponseModel From(
int id,
@@ -30,8 +29,7 @@ public record PlayoutResponseModel(
PlayoutBuildStatusResponseModel? buildStatus,
int? decoId,
string? decoName,
bool isLocked,
int seed) =>
bool isLocked) =>
new(
id,
scheduleKind,
@@ -44,6 +42,5 @@ public record PlayoutResponseModel(
buildStatus,
decoId,
decoName,
isLocked,
seed);
isLocked);
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
/// <summary>
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
/// absolute M3U/XMLTV URLs; an empty string means "use the incoming request's scheme/host/path".
/// </summary>
public record IptvSettingsResponseModel(string BaseUrl);
-8
View File
@@ -1,8 +0,0 @@
namespace ErsatzTV.Core.Domain;
public enum AutoTuneAxis
{
TvShow = 0,
TvGenre = 1,
MovieGenre = 2
}
-5
View File
@@ -62,11 +62,6 @@ public class ConfigElementKey
public static ConfigElementKey XmltvDaysToBuild => new("xmltv.days_to_build");
public static ConfigElementKey XmltvBlockBehavior => new("xmltv.block_behavior");
// Optional advertised IPTV base URL (issue #340). When set, overrides the request-derived
// scheme/host/PathBase used to build absolute M3U + XMLTV URLs; when blank/unset, request-derived
// values are used (today's behavior). Distinct from ETV_BASE_URL, which only sets ASP.NET PathBase.
public static ConfigElementKey IptvBaseUrl => new("iptv.base_url");
// Browser SPA authentication (issue #295). The single local-admin credential lives in ConfigElement
// rows (no DB migration): a username, a PBKDF2 password hash, and a security stamp that is rotated on
// every password change so a stamp mismatch in OnValidatePrincipal revokes all outstanding sessions.
+1
View File
@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>disable</Nullable>
</PropertyGroup>
-67
View File
@@ -1,67 +0,0 @@
namespace ErsatzTV.Core.Iptv;
// Central resolution/validation for the optional advertised IPTV base URL (issue #340).
//
// ErsatzTV builds every absolute M3U/XMLTV URL from the incoming request's scheme + host + PathBase.
// When a downstream consumer fetches ErsatzTV via a host that other consumers can't resolve (e.g.
// Dispatcharr fetching over Docker DNS, then Kodi receiving those internal hostnames), the emitted
// URLs break. An operator can configure an advertised base URL to override those request-derived
// values consistently across the M3U (guide/logo/stream) and XMLTV ({RequestBase}) surfaces.
//
// When the configured value is blank or invalid, resolution falls back to the request-derived values
// so today's behavior is preserved byte-for-byte.
public static class AdvertisedBaseUrl
{
// Returns the effective (scheme, host, baseUrl) to use for absolute IPTV URLs. When the configured
// value is blank or invalid, returns the request-derived values unchanged.
public static (string Scheme, string Host, string BaseUrl) Resolve(
string configured,
string requestScheme,
string requestHost,
string requestBaseUrl) =>
TryParse(configured).Match(
Some: parsed => parsed,
None: () => (requestScheme, requestHost, requestBaseUrl));
// Validates + normalizes an advertised base URL. None => blank or invalid. A valid value is an
// absolute http(s) URL with no credentials, query, or fragment; an optional port and path prefix
// are preserved, and a trailing slash is normalized away (so a root "/" yields an empty base, and
// "/etv/" yields "/etv" — matching the PathBase convention the URL builders concatenate).
public static Option<(string Scheme, string Host, string BaseUrl)> TryParse(string configured)
{
if (string.IsNullOrWhiteSpace(configured))
{
return None;
}
if (!Uri.TryCreate(configured.Trim(), UriKind.Absolute, out Uri uri))
{
return None;
}
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
{
return None;
}
if (!string.IsNullOrEmpty(uri.UserInfo))
{
return None;
}
if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
{
return None;
}
if (string.IsNullOrEmpty(uri.Host))
{
return None;
}
// Uri.Authority is host[:port] (omitting a redundant default port, wrapping IPv6 in brackets)
// and excludes any userinfo — exactly the "{host}" the URL builders expect.
string baseUrl = uri.AbsolutePath.TrimEnd('/');
return (uri.Scheme, uri.Authority, baseUrl);
}
}
@@ -171,11 +171,10 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
// TODO: fix multi episode shuffle?
case PlaybackOrder.MultiEpisodeShuffle:
case PlaybackOrder.Shuffle:
List<GroupedMediaItem> i = await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
List<GroupedMediaItem> i = await PlayoutBuilder.GetGroupedMediaItemsForShuffle(
mediaCollectionRepository,
// playlist items don't (yet) carry these schedule flags; preserve prior behavior
keepMultiPartEpisodesTogether: false,
treatCollectionsAsShows: false,
// TODO: fix this
new ProgramSchedule { KeepMultiPartEpisodesTogether = false },
items,
CollectionKey.ForPlaylistItem(playlistItem),
cancellationToken);
@@ -183,7 +182,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
break;
case PlaybackOrder.ShuffleInOrder:
enumerator = new ShuffleInOrderCollectionEnumerator(
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
await PlayoutBuilder.GetCollectionItemsForShuffleInOrder(
mediaCollectionRepository,
CollectionKey.ForPlaylistItem(playlistItem),
cancellationToken),
+59 -16
View File
@@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
@@ -594,7 +594,7 @@ public class PlayoutBuilder : IPlayoutBuilder
var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList();
CollectionEnumeratorState scheduleItemsEnumeratorState =
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState
{ Seed = random.Next(), Index = 0 };
{ Seed = random.Next(), Index = 0 };
IScheduleItemsEnumerator scheduleItemsEnumerator = activeSchedule.ShuffleScheduleItems
? new ShuffledScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState)
: new OrderedScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState);
@@ -735,17 +735,17 @@ public class PlayoutBuilder : IPlayoutBuilder
switch (scheduleItem.FillWithGroupMode)
{
case FillWithGroupMode.FillWithOrderedGroups:
{
var enumerator = new OrderedScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
break;
}
{
var enumerator = new OrderedScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
break;
}
case FillWithGroupMode.FillWithShuffledGroups:
{
var enumerator = new ShuffledScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
break;
}
{
var enumerator = new ShuffledScheduleItemsEnumerator(fakeScheduleItems, enumeratorState);
scheduleItemsFillGroupEnumerators[scheduleItem.Id] = enumerator;
break;
}
}
}
@@ -1376,7 +1376,7 @@ public class PlayoutBuilder : IPlayoutBuilder
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
case PlaybackOrder.ShuffleInOrder:
return new ShuffleInOrderCollectionEnumerator(
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
await GetCollectionItemsForShuffleInOrder(
_mediaCollectionRepository,
collectionKey,
cancellationToken),
@@ -1420,10 +1420,9 @@ public class PlayoutBuilder : IPlayoutBuilder
case PlaybackOrder.MultiEpisodeShuffle:
case PlaybackOrder.Shuffle:
return new ShuffledMediaCollectionEnumerator(
await ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle(
await GetGroupedMediaItemsForShuffle(
_mediaCollectionRepository,
activeSchedule.KeepMultiPartEpisodesTogether,
activeSchedule.TreatCollectionsAsShows,
activeSchedule,
mediaItems,
collectionKey,
cancellationToken),
@@ -1455,6 +1454,50 @@ public class PlayoutBuilder : IPlayoutBuilder
}
}
internal static async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
IMediaCollectionRepository mediaCollectionRepository,
ProgramSchedule activeSchedule,
List<MediaItem> mediaItems,
CollectionKey collectionKey,
CancellationToken cancellationToken)
{
if (collectionKey.MultiCollectionId != null)
{
List<CollectionWithItems> collections = await mediaCollectionRepository
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value, cancellationToken);
return MultiCollectionGrouper.GroupMediaItems(collections);
}
return activeSchedule.KeepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, activeSchedule.TreatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
}
internal static async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(
IMediaCollectionRepository mediaCollectionRepository,
CollectionKey collectionKey,
CancellationToken cancellationToken)
{
List<CollectionWithItems> result;
if (collectionKey.MultiCollectionId != null)
{
result = await mediaCollectionRepository.GetMultiCollectionCollections(
collectionKey.MultiCollectionId.Value,
cancellationToken);
}
else
{
result = await mediaCollectionRepository.GetFakeMultiCollectionCollections(
collectionKey.CollectionId,
collectionKey.SmartCollectionId,
cancellationToken);
}
return result;
}
internal static string DisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
@@ -1,66 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// Builds the shuffle "source" for a collection key — the grouped media items fed to
/// <see cref="ShuffledMediaCollectionEnumerator" /> and the per-collection lists fed to
/// <see cref="ShuffleInOrderCollectionEnumerator" />.
/// <para>
/// Extracted from <c>PlayoutBuilder</c> (issue #380) so both the Classic builder and
/// <see cref="PlaylistEnumerator" /> share ONE stateless place to construct shuffle sources,
/// eliminating <see cref="PlaylistEnumerator" />'s cross-engine reach-in into
/// <c>PlayoutBuilder</c>'s statics. Kept as a static helper (dependencies passed as parameters)
/// to match the sibling <see cref="MultiCollectionGrouper" /> / <see cref="MultiPartEpisodeGrouper" />
/// (also <c>public static</c>) and because <see cref="PlaylistEnumerator.Create" /> is itself a
/// static factory.
/// </para>
/// </summary>
public static class ShuffleSourceBuilder
{
public static async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
IMediaCollectionRepository mediaCollectionRepository,
bool keepMultiPartEpisodesTogether,
bool treatCollectionsAsShows,
List<MediaItem> mediaItems,
CollectionKey collectionKey,
CancellationToken cancellationToken)
{
if (collectionKey.MultiCollectionId != null)
{
List<CollectionWithItems> collections = await mediaCollectionRepository
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value, cancellationToken);
return MultiCollectionGrouper.GroupMediaItems(collections);
}
return keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
}
public static async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(
IMediaCollectionRepository mediaCollectionRepository,
CollectionKey collectionKey,
CancellationToken cancellationToken)
{
List<CollectionWithItems> result;
if (collectionKey.MultiCollectionId != null)
{
result = await mediaCollectionRepository.GetMultiCollectionCollections(
collectionKey.MultiCollectionId.Value,
cancellationToken);
}
else
{
result = await mediaCollectionRepository.GetFakeMultiCollectionCollections(
collectionKey.CollectionId,
collectionKey.SmartCollectionId,
cancellationToken);
}
return result;
}
}
+1
View File
@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>VSTHRD200,CA1873</NoWarn>
@@ -5,6 +5,7 @@
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+1
View File
@@ -8,6 +8,7 @@
<Configurations>Debug;Release;Debug No Sync</Configurations>
<Platforms>AnyCPU</Platforms>
<UserSecretsId>729e6271-c307-43c8-8e36-1b36c39f6de2</UserSecretsId>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>VSTHRD200,CA1873</NoWarn>
</PropertyGroup>
@@ -1,44 +0,0 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class AutoTuneAxisMapTests
{
[Test]
public void GenerateQuery_Builds_Expected_Lucene()
{
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "The Office")
.ShouldBe("type:episode AND show_title:\"The Office\"");
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvGenre, "Comedy")
.ShouldBe("type:episode AND genre:\"Comedy\"");
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.MovieGenre, "Action")
.ShouldBe("type:movie AND genre:\"Action\"");
}
[Test]
public void GenerateQuery_Escapes_Quotes_And_Backslashes()
{
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "Bob\"s \\Show")
.ShouldBe("type:episode AND show_title:\"Bob\\\"s \\\\Show\"");
}
[Test]
public void GenerateName_Suffixes_Movie_Genres_Only()
{
AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvShow, "The Office").ShouldBe("The Office");
AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvGenre, "Comedy").ShouldBe("Comedy");
AutoTuneAxisMap.GenerateName(AutoTuneAxis.MovieGenre, "Action").ShouldBe("Action Movies");
}
[Test]
public void PlaybackOrderFor_Uses_PseudoTV_Defaults()
{
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvShow).ShouldBe(PlaybackOrder.SeasonEpisode);
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvGenre).ShouldBe(PlaybackOrder.Shuffle);
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.MovieGenre).ShouldBe(PlaybackOrder.Shuffle);
}
}
@@ -1,31 +0,0 @@
using System.Collections.Generic;
using ErsatzTV.Application.Channels;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class AutoTuneNumberAllocatorTests
{
[Test]
public void Allocate_Skips_Taken_Numbers()
{
var existing = new HashSet<string> { "500", "502" };
List<string> result = AutoTuneNumberAllocator.Allocate(500, 3, existing);
result.ShouldBe(new List<string> { "501", "503", "504" });
}
[Test]
public void Allocate_From_Empty_Is_Sequential()
{
List<string> result = AutoTuneNumberAllocator.Allocate(1, 3, new HashSet<string>());
result.ShouldBe(new List<string> { "1", "2", "3" });
}
[Test]
public void Allocate_Zero_Count_Is_Empty()
{
AutoTuneNumberAllocator.Allocate(500, 0, new HashSet<string>()).ShouldBeEmpty();
}
}
@@ -1,121 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class CreateAutoTunedChannelsHandlerTests
{
private ISender _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<ISender>();
// Smart collection creation always succeeds, echoing an incrementing id.
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var cmd = ci.Arg<CreateSmartCollection>();
return (Either<BaseError, SmartCollectionViewModel>)
new SmartCollectionViewModel(7, cmd.Name, cmd.Query);
});
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, LanguageExt.Unit>)LanguageExt.Unit.Default);
}
[Test]
public async Task Creates_Selected_Channels_And_Reports_Counts()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
new CreateChannelFromLineupResponseModel(88, null, 1, 2));
var result = await Handle(new CreateAutoTunedChannels(
TemplateId: 3, Group: "Auto-Tuned",
new List<AutoTuneChannelSelection>
{
new(AutoTuneAxis.TvShow, "The Office", "The Office", "500")
}));
result.CreatedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Created);
result.Results[0].ChannelId.ShouldBe(88);
// Smart collection built with the server-generated query.
await _mediator.Received().Send(
Arg.Is<CreateSmartCollection>(c => c.Query == "type:episode AND show_title:\"The Office\""),
Arg.Any<CancellationToken>());
// Channel created referencing the smart collection id, number, and SeasonEpisode order.
await _mediator.Received().Send(
Arg.Is<CreateChannelFromLineup>(c =>
c.Number == "500" &&
c.TemplateId == 3 &&
c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode &&
c.Lineup.Count == 1 &&
c.Lineup[0].CollectionType == CollectionType.SmartCollection &&
c.Lineup[0].SmartCollectionId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Number_Collision_Is_Skipped_Not_Failed()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("Channel number must be unique"));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.SkippedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped);
}
[Test]
public async Task Number_Collision_Rolls_Back_Orphaned_SmartCollection()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("Channel number must be unique"));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped);
await _mediator.Received().Send(
Arg.Is<DeleteSmartCollection>(d => d.SmartCollectionId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Other_Errors_Are_Failed()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
BaseError.New("FFmpegProfile 9 does not exist."));
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
result.FailedCount.ShouldBe(1);
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed);
}
private Task<AutoTuneResult> Handle(CreateAutoTunedChannels request) =>
new CreateAutoTunedChannelsHandler(_mediator).Handle(request, CancellationToken.None);
}
@@ -1,334 +0,0 @@
using ErsatzTV.Application.Channels;
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 ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetAutoTuneChannelMembersHandlerTests
{
private InMemoryTvContext _db = null!;
private ISearchIndex _searchIndex = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_searchIndex = Substitute.For<ISearchIndex>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private void ReturnsSearch(params SearchItem[] items) =>
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult(items.ToList(), items.Length));
[Test]
public async Task Handle_Should_Generate_The_Server_Owned_Query_For_The_Axis_Value()
{
ReturnsSearch();
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 100),
CancellationToken.None);
// The client never sends Lucene; the handler builds it from AutoTuneAxisMap.GenerateQuery.
await _searchIndex.Received(1).Search(
Arg.Is<string>(q => q == "type:episode AND genre:\"Comedy\""),
string.Empty,
0,
10_000,
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Not_Search_For_A_Blank_Value()
{
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, " ", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(0);
result.Page.ShouldBeEmpty();
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Roll_Episodes_Up_To_Distinct_Parent_Shows_With_Matching_Counts()
{
await SeedTwoShowGenreGraph();
// Show 10 contributes episodes 101 & 102; show 20 contributes episode 201. Episode 103 (show 10)
// exists but does NOT match the query, so it must not inflate show 10's count.
ReturnsSearch(
new SearchItem(LuceneSearchIndex.EpisodeType, 101),
new SearchItem(LuceneSearchIndex.EpisodeType, 102),
new SearchItem(LuceneSearchIndex.EpisodeType, 201));
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
// Ordered by title: "Alpha Show" (10) before "Beta Show" (20).
result.Page.Count.ShouldBe(2);
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.TelevisionShow);
result.Page[0].Title.ShouldBe("Alpha Show");
result.Page[0].Id.ShouldBe(10);
result.Page[0].ItemCount.ShouldBe(2); // matching episodes only, not the show's 3 total
result.Page[1].Title.ShouldBe("Beta Show");
result.Page[1].Id.ShouldBe(20);
result.Page[1].ItemCount.ShouldBe(1);
}
[Test]
public async Task Handle_Should_Page_Distinct_Shows_By_Title()
{
await SeedTwoShowGenreGraph();
ReturnsSearch(
new SearchItem(LuceneSearchIndex.EpisodeType, 101),
new SearchItem(LuceneSearchIndex.EpisodeType, 201));
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel page0 = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 1),
CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page1 = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 1, 1),
CancellationToken.None);
page0.TotalCount.ShouldBe(2);
page0.Page.Count.ShouldBe(1);
page0.Page[0].Title.ShouldBe("Alpha Show");
page1.TotalCount.ShouldBe(2);
page1.Page.Count.ShouldBe(1);
page1.Page[0].Title.ShouldBe("Beta Show");
}
[Test]
public async Task Handle_Should_Return_Movies_As_Members_For_The_Movie_Genre_Axis()
{
await SeedMovieGenreGraph();
ReturnsSearch(
new SearchItem(LuceneSearchIndex.MovieType, 30),
new SearchItem(LuceneSearchIndex.MovieType, 31));
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Comedy", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
// Ordered by title: "Aardvark" before "Zebra".
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
result.Page[0].Title.ShouldBe("Aardvark");
result.Page[0].Id.ShouldBe(31);
result.Page[0].ItemCount.ShouldBe(1);
result.Page[1].Title.ShouldBe("Zebra");
result.Page[1].Id.ShouldBe(30);
}
[Test]
public async Task Handle_Should_Return_Empty_When_The_Query_Matches_Nothing()
{
await SeedTwoShowGenreGraph();
ReturnsSearch();
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Nonexistent", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(0);
result.Page.ShouldBeEmpty();
}
[Test]
public async Task Handle_Should_Return_Empty_For_An_Out_Of_Range_Axis_Without_Searching()
{
// A crafted numeric axis (?axis=5) binds successfully; the handler must not let
// GenerateQuery throw (which would surface as a 500) — it returns empty like a blank value.
var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetAutoTuneChannelMembers((AutoTuneAxis)999, "Comedy", 0, 100),
CancellationToken.None);
result.TotalCount.ShouldBe(0);
result.Page.ShouldBeEmpty();
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>());
}
private async Task SeedTwoShowGenreGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(1, "TV");
var alpha = MakeShow(10, path, "Alpha Show");
Season alphaSeason = MakeSeason(11, path, alpha);
alphaSeason.Episodes.AddRange([
MakeEpisode(101, path, alphaSeason),
MakeEpisode(102, path, alphaSeason),
MakeEpisode(103, path, alphaSeason)
]);
alpha.Seasons.Add(alphaSeason);
var beta = MakeShow(20, path, "Beta Show");
Season betaSeason = MakeSeason(21, path, beta);
betaSeason.Episodes.Add(MakeEpisode(201, path, betaSeason));
beta.Seasons.Add(betaSeason);
path.MediaItems.AddRange([
alpha, beta, alphaSeason, betaSeason,
.. alphaSeason.Episodes, .. betaSeason.Episodes
]);
context.LocalLibraries.Add(library);
context.Shows.AddRange(alpha, beta);
context.Seasons.AddRange(alphaSeason, betaSeason);
context.Episodes.AddRange([.. alphaSeason.Episodes, .. betaSeason.Episodes]);
await context.SaveChangesAsync();
}
private async Task SeedMovieGenreGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(2, "Movies");
var zebra = MakeMovie(30, path, "Zebra");
var aardvark = MakeMovie(31, path, "Aardvark");
path.MediaItems.AddRange([zebra, aardvark]);
context.LocalLibraries.Add(library);
context.Movies.AddRange(zebra, aardvark);
await context.SaveChangesAsync();
}
private static (LocalLibrary Library, LibraryPath Path) MakeLibrary(int id, string name)
{
var library = new LocalLibrary
{
Id = id,
Name = name,
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = id,
Path = $"/media/{id}",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
return (library, path);
}
private static Show MakeShow(int id, LibraryPath path, string title) =>
new()
{
Id = id,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata =
[
new ShowMetadata
{
Title = title,
SortTitle = title,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
}
]
};
private static Season MakeSeason(int id, LibraryPath path, Show show) =>
new()
{
Id = id,
LibraryPath = path,
Show = show,
SeasonNumber = 1,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Episodes = [],
SeasonMetadata = []
};
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
new()
{
Id = id,
LibraryPath = path,
Season = season,
Collections = [],
CollectionItems = [],
TraktListItems = [],
EpisodeMetadata = [],
MediaVersions = []
};
private static Movie MakeMovie(int id, LibraryPath path, string title) =>
new()
{
Id = id,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(90) }],
MovieMetadata =
[
new MovieMetadata
{
Title = title,
SortTitle = title,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
]
};
}
@@ -61,6 +61,7 @@ public class GetChannelGuideDataHandlerTests
CancellationToken.None);
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
result.Channels.Single().Id.ShouldBeGreaterThan(0);
}
[Test]
@@ -0,0 +1,467 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelPlaybackSourceHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 14, 20, 0, 0, TimeSpan.Zero);
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Resolve_Jellyfin_Item_And_Schedule_Offset()
{
string jellyfinItemId = Guid.NewGuid().ToString("N");
DateTime start = Now.UtcDateTime.AddMinutes(-10);
DateTime finish = Now.UtcDateTime.AddMinutes(20);
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(7, "7.1");
var movie = new JellyfinMovie
{
Id = 70,
ItemId = jellyfinItemId,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var playout = new Playout { Id = 71, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 72,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
InPoint = TimeSpan.FromMinutes(5),
OutPoint = TimeSpan.FromMinutes(35),
FillerKind = FillerKind.None
};
context.Channels.Add(channel);
context.JellyfinMovies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(7, Now);
result.ChannelId.ShouldBe(7);
result.SourceChannelId.ShouldBe(7);
result.SourceAt.ShouldBe(Now);
result.NextTransitionAt.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
result.Active.ShouldNotBeNull();
result.Active.Start.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.FromMinutes(15).Ticks);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.JellyfinItem);
result.Active.Source.ItemId.ShouldBe(jellyfinItemId);
result.Active.Source.Path.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Use_Actual_Filler_Item_Not_Guide_Display_Item()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(8, "8");
var filler = new OtherVideo
{
Id = 80,
OtherVideoMetadata = [],
MediaVersions =
[
new MediaVersion
{
MediaFiles = [new MediaFile { Path = "/media/bumper.mkv", PathHash = "bumper" }]
}
]
};
var playout = new Playout { Id = 81, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 82,
MediaItem = filler,
MediaItemId = filler.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1),
OutPoint = TimeSpan.FromMinutes(2),
FillerKind = FillerKind.MidRoll,
GuideGroup = 4
};
context.Channels.Add(channel);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(8, Now);
result.Active.ShouldNotBeNull();
result.Active.PlayoutItemId.ShouldBe(82);
result.Active.FillerKind.ShouldBe(FillerKind.MidRoll);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.LocalFile);
result.Active.Source.Path.ShouldBe("/media/bumper.mkv");
}
[Test]
public async Task Handle_Should_Apply_Mirror_Clock_And_Viewer_Facing_Timestamps()
{
TimeSpan offset = TimeSpan.FromHours(1);
DateTime sourceStart = Now.UtcDateTime.Subtract(offset).AddMinutes(-5);
DateTime sourceFinish = Now.UtcDateTime.Subtract(offset).AddMinutes(25);
await using (TvContext context = _db.CreateContext())
{
Channel source = MakeChannel(9, "9");
Channel mirror = MakeChannel(10, "10");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannelId = source.Id;
mirror.PlayoutOffset = offset;
var remote = new RemoteStream
{
Id = 90,
Url = "https://example.invalid/live.m3u8",
IsLive = true,
RemoteStreamMetadata = [],
MediaVersions = []
};
var playout = new Playout { Id = 91, Channel = source, ChannelId = source.Id, Items = [] };
var item = new PlayoutItem
{
Id = 92,
MediaItem = remote,
MediaItemId = remote.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = sourceStart,
Finish = sourceFinish,
OutPoint = TimeSpan.FromMinutes(30)
};
context.Channels.AddRange(source, mirror);
context.RemoteStreams.Add(remote);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(10, Now);
result.SourceChannelId.ShouldBe(9);
result.SourceAt.ShouldBe(Now.Subtract(offset));
result.Active.ShouldNotBeNull();
result.Active.Start.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
result.Active.Finish.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
result.NextTransitionAt.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.FromMinutes(5).Ticks);
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.RemoteUrl);
result.Active.Source.IsLive.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Select_Item_At_Start_And_Exclude_Item_At_Finish()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(11, "11");
var first = new Movie { Id = 110, MovieMetadata = [], MediaVersions = [] };
var second = new Movie { Id = 111, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 112, Channel = channel, ChannelId = channel.Id, Items = [] };
context.Channels.Add(channel);
context.Movies.AddRange(first, second);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
new PlayoutItem
{
Id = 113,
MediaItem = first,
MediaItemId = first.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-30),
Finish = Now.UtcDateTime
},
new PlayoutItem
{
Id = 114,
MediaItem = second,
MediaItemId = second.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime,
Finish = Now.UtcDateTime.AddMinutes(30)
});
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(11, Now);
result.Active.ShouldNotBeNull();
result.Active.PlayoutItemId.ShouldBe(114);
result.Active.MediaItemId.ShouldBe(111);
result.Active.Start.ShouldBe(Now);
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.Zero.Ticks);
result.NextTransitionAt.ShouldBe(Now.AddMinutes(30));
}
[Test]
public async Task Handle_Should_Return_Next_Start_During_Gap()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(12, "12");
var movie = new Movie { Id = 120, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 121, Channel = channel, ChannelId = channel.Id, Items = [] };
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
new PlayoutItem
{
Id = 122,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-30),
Finish = Now.UtcDateTime
},
new PlayoutItem
{
Id = 123,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(10),
Finish = Now.UtcDateTime.AddMinutes(40)
});
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(12, Now);
result.Active.ShouldBeNull();
result.NextTransitionAt.ShouldBe(Now.AddMinutes(10));
}
[Test]
public async Task Handle_Should_Return_No_Next_Transition_During_Terminal_Gap()
{
await using (TvContext context = _db.CreateContext())
{
Channel channel = MakeChannel(13, "13");
var movie = new Movie { Id = 130, MovieMetadata = [], MediaVersions = [] };
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
var finishedItem = new PlayoutItem
{
Id = 132,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddHours(-1),
Finish = Now.UtcDateTime.AddMinutes(-30)
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(finishedItem);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceResponseModel result = await GetResult(13, Now);
result.Active.ShouldBeNull();
result.NextTransitionAt.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Map_Server_Items_And_Unsupported_Empty_Local_Media()
{
const string jellyfinItemId = "jellyfin-episode";
const string plexKey = "/library/metadata/123";
const string embyItemId = "emby-movie";
await using (TvContext context = _db.CreateContext())
{
var jellyfinEpisode = new JellyfinEpisode
{
Id = 140,
ItemId = jellyfinItemId,
Etag = string.Empty,
EpisodeMetadata = [],
MediaVersions = []
};
var plexMovie = new PlexMovie
{
Id = 150,
Key = plexKey,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var embyMovie = new EmbyMovie
{
Id = 160,
ItemId = embyItemId,
Etag = string.Empty,
MovieMetadata = [],
MediaVersions = []
};
var emptyLocalMovie = new Movie { Id = 170, MovieMetadata = [], MediaVersions = [] };
AddActiveItem(context, MakeChannel(14, "14"), jellyfinEpisode, 141, 142);
AddActiveItem(context, MakeChannel(15, "15"), plexMovie, 151, 152);
AddActiveItem(context, MakeChannel(16, "16"), embyMovie, 161, 162);
AddActiveItem(context, MakeChannel(17, "17"), emptyLocalMovie, 171, 172);
await context.SaveChangesAsync();
}
ChannelPlaybackSourceReferenceResponseModel jellyfin = (await GetResult(14, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel plex = (await GetResult(15, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel emby = (await GetResult(16, Now)).Active!.Source;
ChannelPlaybackSourceReferenceResponseModel unsupported = (await GetResult(17, Now)).Active!.Source;
jellyfin.Kind.ShouldBe(ChannelPlaybackSourceKind.JellyfinItem);
jellyfin.ItemId.ShouldBe(jellyfinItemId);
plex.Kind.ShouldBe(ChannelPlaybackSourceKind.PlexItem);
plex.ItemId.ShouldBe(plexKey);
emby.Kind.ShouldBe(ChannelPlaybackSourceKind.EmbyItem);
emby.ItemId.ShouldBe(embyItemId);
unsupported.Kind.ShouldBe(ChannelPlaybackSourceKind.Unsupported);
unsupported.ItemId.ShouldBeNull();
unsupported.Path.ShouldBeNull();
unsupported.IsLive.ShouldBeFalse();
}
[Test]
public async Task Handle_Should_Return_None_For_Missing_Channel()
{
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(404, Now),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Return_None_For_Mirror_Without_Source_Channel()
{
await using (TvContext context = _db.CreateContext())
{
Channel mirror = MakeChannel(18, "18");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
var staleMovie = new Movie { Id = 180, MovieMetadata = [], MediaVersions = [] };
var stalePlayout = new Playout
{
Id = 181,
Channel = mirror,
ChannelId = mirror.Id,
Items = []
};
var staleItem = new PlayoutItem
{
Id = 182,
MediaItem = staleMovie,
MediaItemId = staleMovie.Id,
Playout = stalePlayout,
PlayoutId = stalePlayout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1)
};
context.Channels.Add(mirror);
context.Movies.Add(staleMovie);
context.Playouts.Add(stalePlayout);
context.PlayoutItems.Add(staleItem);
await context.SaveChangesAsync();
}
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(18, Now),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
private async Task<ChannelPlaybackSourceResponseModel> GetResult(int channelId, DateTimeOffset at)
{
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
new GetChannelPlaybackSource(channelId, at),
CancellationToken.None);
return result.Match(
Some: value => value,
None: () => throw new AssertionException("Expected a playback-source response"));
}
private static void AddActiveItem(
TvContext context,
Channel channel,
MediaItem mediaItem,
int playoutId,
int playoutItemId)
{
var playout = new Playout { Id = playoutId, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = playoutItemId,
MediaItem = mediaItem,
MediaItemId = mediaItem.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.UtcDateTime.AddMinutes(-1),
Finish = Now.UtcDateTime.AddMinutes(1)
};
context.Channels.Add(channel);
context.MediaItems.Add(mediaItem);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
}
private static Channel MakeChannel(int id, string number) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
SortNumber = id,
Name = $"Channel {number}",
Group = "Test",
Categories = string.Empty,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated
};
}
@@ -3,7 +3,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 ErsatzTV.Tests.Support;
using NSubstitute;
@@ -19,14 +18,12 @@ public class GetChannelStatesForApiHandlerTests
private InMemoryTvContext _db = null!;
private IFFmpegSegmenterService _segmenter = null!;
private IDirectStreamSessionTracker _directStreams = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_segmenter = Substitute.For<IFFmpegSegmenterService>();
_directStreams = Substitute.For<IDirectStreamSessionTracker>();
}
[TearDown]
@@ -39,7 +36,7 @@ public class GetChannelStatesForApiHandlerTests
DateTime finish = Now.AddMinutes(20);
await SeedChannelWithMovie(start, finish);
_segmenter.IsActive("7.1").Returns(true);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -54,26 +51,11 @@ public class GetChannelStatesForApiHandlerTests
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Return_OnAir_For_Direct_Stream_Session()
{
await using TvContext context = _db.CreateContext();
context.Channels.Add(MakeChannel(8, "8"));
await context.SaveChangesAsync();
_directStreams.IsActive("8").Returns(true);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().OnAir.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
{
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -85,7 +67,7 @@ public class GetChannelStatesForApiHandlerTests
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
{
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -99,7 +81,7 @@ public class GetChannelStatesForApiHandlerTests
await using TvContext context = _db.CreateContext();
context.Channels.Add(MakeChannel(8, "8"));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -151,7 +133,7 @@ public class GetChannelStatesForApiHandlerTests
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -194,7 +176,7 @@ public class GetChannelStatesForApiHandlerTests
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -234,7 +216,7 @@ public class GetChannelStatesForApiHandlerTests
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
ChannelStateResponseModel state =
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
@@ -282,7 +264,7 @@ public class GetChannelStatesForApiHandlerTests
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
@@ -1,152 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class PreviewAutoTuneChannelsHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Enumerates_Shows_Above_MinItems_With_Counts_And_Numbers()
{
// Show 1 "The Office" with 3 episodes; Show 2 "Short" with 1 episode.
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102, 103 });
await SeedShow(showId: 2, title: "Short", seasonId: 22, episodeIds: new[] { 201 });
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.Count.ShouldBe(1);
proposals[0].Value.ShouldBe("The Office");
proposals[0].Name.ShouldBe("The Office");
proposals[0].ItemCount.ShouldBe(3);
proposals[0].Number.ShouldBe("500");
proposals[0].AlreadyExists.ShouldBeFalse();
}
[Test]
public async Task Flags_AlreadyExists_By_Channel_Name_And_Skips_Taken_Numbers()
{
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102 });
await SeedChannel(number: "500", name: "The Office");
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 1, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals[0].AlreadyExists.ShouldBeTrue();
proposals[0].Number.ShouldBe("501"); // 500 is taken
}
[Test]
public async Task Enumerates_Movie_Genres_With_Suffixed_Names()
{
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: "Action");
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: "Action");
await SeedMovieWithGenre(movieId: 3, metadataId: 3, genre: "Drama");
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.Count.ShouldBe(1); // Drama has only 1 movie, below minItems
proposals[0].Value.ShouldBe("Action");
proposals[0].Name.ShouldBe("Action Movies");
proposals[0].ItemCount.ShouldBe(2);
}
[Test]
public async Task Excludes_Proposals_Whose_Generated_Name_Exceeds_50_Chars()
{
// "Movies" suffix (7 chars) pushes this over the 50-char Channel.Name limit.
const string longGenre = "A Really Really Long And Overly Descriptive Genre"; // 50 chars, +" Movies" = 57
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: longGenre);
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: longGenre);
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
List<AutoTuneProposal> proposals = RightOf(result);
proposals.ShouldNotContain(p => p.Value == longGenre);
proposals.ShouldBeEmpty();
}
[Test]
public async Task Empty_Axes_Is_Error()
{
var result = await Handle(new PreviewAutoTuneChannels(
new List<AutoTuneAxis>(), MinItems: 5, StartingNumber: 500));
LeftOf(result).Value.ShouldContain("axis");
}
private Task<Either<BaseError, List<AutoTuneProposal>>> Handle(PreviewAutoTuneChannels request) =>
new PreviewAutoTuneChannelsHandler(_db.Factory).Handle(request, CancellationToken.None);
private async Task SeedShow(int showId, string title, int seasonId, int[] episodeIds)
{
await using TvContext context = _db.CreateContext();
context.Shows.Add(new Show
{
Id = showId,
ShowMetadata = new List<ShowMetadata> { new() { ShowId = showId, Title = title } },
Seasons = new List<Season>
{
new()
{
Id = seasonId, ShowId = showId,
Episodes = episodeIds.Select(id => new Episode { Id = id, SeasonId = seasonId }).ToList()
}
}
});
await context.SaveChangesAsync();
}
private async Task SeedMovieWithGenre(int movieId, int metadataId, string genre)
{
await using TvContext context = _db.CreateContext();
context.Movies.Add(new Movie
{
Id = movieId,
MovieMetadata = new List<MovieMetadata>
{
new() { Id = metadataId, MovieId = movieId, Title = $"Movie {movieId}",
Genres = new List<Genre> { new() { Name = genre } } }
}
});
await context.SaveChangesAsync();
}
private async Task SeedChannel(string number, string name)
{
await using TvContext context = _db.CreateContext();
context.Channels.Add(new Channel(System.Guid.NewGuid())
{
Number = number, Name = name, Group = "Test", SortNumber = double.Parse(number)
});
await context.SaveChangesAsync();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> e) =>
e.Match(Left: x => x, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> e) =>
e.Match(Left: x => throw new AssertionException($"Expected Right, got {x.Value}"), Right: r => r);
}
@@ -1,108 +0,0 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.Configuration;
[TestFixture]
public class IptvSettingsHandlerTests
{
private const string Key = "iptv.base_url";
private IConfigElementRepository _configElementRepository = null!;
[SetUp]
public void SetUp() => _configElementRepository = Substitute.For<IConfigElementRepository>();
[Test]
public async Task Get_Returns_Empty_When_Unset()
{
_configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.None);
var handler = new GetIptvSettingsHandler(_configElementRepository);
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
result.BaseUrl.ShouldBe(string.Empty);
}
[Test]
public async Task Get_Returns_Stored_Value()
{
_configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("https://tv.example.com/etv"));
var handler = new GetIptvSettingsHandler(_configElementRepository);
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
result.BaseUrl.ShouldBe("https://tv.example.com/etv");
}
[Test]
public async Task Update_Upserts_Trimmed_Value_When_Valid()
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = " https://public.example.com/etv " }),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _configElementRepository.Received(1).Upsert(
Arg.Is<ConfigElementKey>(k => k.Key == Key),
"https://public.example.com/etv",
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public async Task Update_Clears_Setting_When_Blank(string blank)
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = blank }),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _configElementRepository.Received(1).Delete(
Arg.Is<ConfigElementKey>(k => k.Key == Key),
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Upsert(
Arg.Any<ConfigElementKey>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[TestCase("not a url")]
[TestCase("ftp://tv.example.com")]
[TestCase("http://user:pass@tv.example.com")]
[TestCase("http://tv.example.com?foo=bar")]
public async Task Update_Returns_Error_And_Persists_Nothing_When_Invalid(string invalid)
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = invalid }),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await _configElementRepository.DidNotReceive().Upsert(
Arg.Any<ConfigElementKey>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
}
}
@@ -1,49 +0,0 @@
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Maintenance;
[TestFixture]
[NonParallelizable]
public class ReleaseMemoryHandlerTests
{
[Test]
public async Task Should_Use_Aggressive_Collection_When_No_Workers_Are_Active()
{
FFmpegProcess.ProcessCount.ShouldBe(0);
IFFmpegSegmenterService segmenterService = Substitute.For<IFFmpegSegmenterService>();
segmenterService.Workers.Returns([]);
ILogger<ReleaseMemoryHandler> logger = Substitute.For<ILogger<ReleaseMemoryHandler>>();
var handler = new ReleaseMemoryHandler(segmenterService, logger);
await handler.Handle(new ReleaseMemory(false), CancellationToken.None);
ShouldHaveLogged(logger, "Starting aggressive garbage collection");
}
[Test]
public async Task Should_Use_Regular_Collection_When_A_Worker_Is_Active()
{
FFmpegProcess.ProcessCount.ShouldBe(0);
IFFmpegSegmenterService segmenterService = Substitute.For<IFFmpegSegmenterService>();
segmenterService.Workers.Returns([Substitute.For<IHlsSessionWorker>()]);
ILogger<ReleaseMemoryHandler> logger = Substitute.For<ILogger<ReleaseMemoryHandler>>();
var handler = new ReleaseMemoryHandler(segmenterService, logger);
await handler.Handle(new ReleaseMemory(false), CancellationToken.None);
ShouldHaveLogged(logger, "Starting garbage collection");
}
private static void ShouldHaveLogged(ILogger<ReleaseMemoryHandler> logger, string expectedMessage) =>
logger.ReceivedCalls()
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() == expectedMessage)
.ShouldBeTrue();
}
@@ -1,132 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Channel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Playouts;
[TestFixture]
public class ReshufflePlayoutHandlerTests
{
private InMemoryTvContext _db = null!;
private Channel<IBackgroundServiceRequest> _worker = null!;
private IMediator _mediator = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
_mediator = Substitute.For<IMediator>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private ReshufflePlayoutHandler CreateHandler() => new(_mediator, _worker.Writer, _db.Factory);
private async Task<int> SeedPlayout(PlayoutScheduleKind kind, int? seed = null)
{
await using TvContext context = _db.CreateContext();
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
if (seed.HasValue)
{
playout.Seed = seed.Value;
}
context.Playouts.Add(playout);
await context.SaveChangesAsync();
return playout.Id;
}
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task Handle_Should_Erase_History_Then_Enqueue_Reset_Build_For_Supported_Kind(
PlayoutScheduleKind kind)
{
int id = await SeedPlayout(kind);
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<ErasePlayoutHistory>(e => e.PlayoutId == id),
Arg.Any<CancellationToken>());
_worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
var build = request.ShouldBeOfType<BuildPlayout>();
build.PlayoutId.ShouldBe(id);
build.Mode.ShouldBe(PlayoutBuildMode.Reset);
_worker.Reader.TryRead(out _).ShouldBeFalse();
}
[TestCase(PlayoutScheduleKind.ExternalJson)]
[TestCase(PlayoutScheduleKind.None)]
public async Task Handle_Should_Not_Erase_History_Or_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
int id = await SeedPlayout(kind);
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
_worker.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task Handle_Should_Actually_Reseed_Block_Playout_Via_ErasePlayoutHistory()
{
// This is the C1 regression test: BuildPlayout(Reset) alone only reseeds Playout.Seed for
// Classic playouts (PlayoutBuilder). For Block/Sequential/Scripted it is a no-op reshuffle
// unless the handler routes through ErasePlayoutHistory first. Wire the substituted
// IMediator to actually invoke the real ErasePlayoutHistoryHandler against the in-memory DB
// so the reseed side effect is genuinely exercised, not merely asserted-as-called.
const int originalSeed = 12345;
int id = await SeedPlayout(PlayoutScheduleKind.Block, originalSeed);
// seed a PlayoutHistory row so we can also assert the deterministic "cleared" side effect
// (avoids relying solely on new Random().Next() != originalSeed, which is a ~1-in-2^31 flake)
await using (TvContext seedContext = _db.CreateContext())
{
seedContext.PlayoutHistory.Add(
new PlayoutHistory
{
PlayoutId = id,
Key = "test-collection",
When = DateTime.UtcNow,
Finish = DateTime.UtcNow
});
await seedContext.SaveChangesAsync();
}
_mediator.Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>())
.Returns(callInfo => new ErasePlayoutHistoryHandler(_db.Factory).Handle(
(ErasePlayoutHistory)callInfo[0],
(CancellationToken)callInfo[1]));
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
await using TvContext context = _db.CreateContext();
Playout playout = await context.Playouts.SingleAsync(p => p.Id == id);
playout.Seed.ShouldNotBe(originalSeed);
List<PlayoutHistory> remainingHistory = await context.PlayoutHistory
.Where(h => h.PlayoutId == id)
.ToListAsync();
remainingHistory.ShouldBeEmpty();
}
}
@@ -118,20 +118,6 @@ public class ApiControllerSecurityTests
}
}
[Test]
public void Local_Library_Detail_Should_Require_Authentication_While_Catalog_List_Remains_Opt_Out()
{
MethodInfo detailAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetById))
?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetById)}");
MethodInfo listAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetAll))
?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetAll)}");
EffectiveRequiresAuthentication(typeof(LocalLibrariesController), detailAction)
.ShouldBeTrue("local-library detail exposes server filesystem paths and must stay authenticated");
EffectiveRequiresAuthentication(typeof(LocalLibrariesController), listAction)
.ShouldBeFalse("the ordinary local-library catalog should retain the read-auth opt-out");
}
[Test]
public void ScannerController_Should_Be_Localhost_Only()
{
@@ -145,10 +131,6 @@ public class ApiControllerSecurityTests
private static bool IsGloballyProtected() => ApiAuthorizationFilterIsGlobal;
private static bool EffectiveRequiresAuthentication(Type controllerType, MethodInfo action) =>
controllerType.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any() ||
action.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any();
private static bool IsApiAuthorizationFilterRegisteredGlobally()
{
var settings = new Dictionary<string, string?>
@@ -15,6 +15,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Filters;
using LanguageExt;
using static LanguageExt.Prelude;
using MediatR;
@@ -273,6 +274,28 @@ public class ChannelControllerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetPlaybackSource_Should_Require_Authentication_And_Map_Query()
{
DateTimeOffset at = new(2026, 7, 14, 20, 0, 0, TimeSpan.Zero);
var model = new ChannelPlaybackSourceResponseModel(7, 7, at, at, null, null);
_mediator.Send(Arg.Any<GetChannelPlaybackSource>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelPlaybackSourceResponseModel>.Some(model));
IActionResult result = await _controller.GetPlaybackSource(7, at, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
await _mediator.Received(1).Send(
Arg.Is<GetChannelPlaybackSource>(q => q.ChannelId == 7 && q.At == at),
Arg.Any<CancellationToken>());
MethodInfo method = typeof(ChannelController).GetMethod(nameof(ChannelController.GetPlaybackSource))!;
method.GetCustomAttribute<RequiresAuthenticationAttribute>().ShouldNotBeNull();
ResponseCacheAttribute cache = method.GetCustomAttribute<ResponseCacheAttribute>()!;
cache.NoStore.ShouldBeTrue();
cache.Location.ShouldBe(ResponseCacheLocation.None);
}
[Test]
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
{
@@ -471,49 +494,6 @@ public class ChannelControllerTests
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
}
[Test]
public void GetAutoTuneChannelMembers_Route_Is_Literal_And_Precedes_Id()
{
MethodInfo members = typeof(ChannelController).GetMethod(nameof(ChannelController.GetAutoTuneChannelMembers))!;
members.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
.ShouldBe("/api/v1/channels/auto-tune/members");
}
[Test]
public async Task GetAutoTuneChannelMembers_Should_Map_Query_And_Return_Model()
{
var model = new PagedLibraryBrowseItemsResponseModel(1, []);
_mediator.Send(Arg.Any<GetAutoTuneChannelMembers>(), Arg.Any<CancellationToken>())
.Returns(model);
PagedLibraryBrowseItemsResponseModel result = await _controller.GetAutoTuneChannelMembers(
AutoTuneAxis.TvGenre, "Comedy", 2, 25, CancellationToken.None);
result.ShouldBe(model);
await _mediator.Received(1).Send(
Arg.Is<GetAutoTuneChannelMembers>(q =>
q.Axis == AutoTuneAxis.TvGenre && q.Value == "Comedy" && q.PageNum == 2 && q.PageSize == 25),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetAutoTuneChannelMembers_Should_Clamp_Paging()
{
_mediator.Send(Arg.Any<GetAutoTuneChannelMembers>(), Arg.Any<CancellationToken>())
.Returns(new PagedLibraryBrowseItemsResponseModel(0, []));
// Negative page floors to 0; zero/oversized page size normalizes to the default/max.
await _controller.GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Action", -3, 0, CancellationToken.None);
await _controller.GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Action", 0, 9999, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetAutoTuneChannelMembers>(q => q.PageNum == 0 && q.PageSize == 100),
Arg.Any<CancellationToken>());
await _mediator.Received(1).Send(
Arg.Is<GetAutoTuneChannelMembers>(q => q.PageNum == 0 && q.PageSize == 200),
Arg.Any<CancellationToken>());
}
private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) =>
new(
id,
@@ -527,7 +507,6 @@ public class ChannelControllerTests
null,
null,
null,
0,
0);
private static ChannelDetailResponseModel MakeDetailModel(int id) =>
@@ -1,3 +1,4 @@
using System.Text.Json;
using ErsatzTV.Serialization;
using ErsatzTV.Tests.Support;
using Microsoft.OpenApi;
@@ -71,6 +72,32 @@ public class OpenApiContractHonestyTests
}
}
[Test]
public async Task Serialized_Gated_Operations_Should_Name_The_ApiKey_Scheme()
{
string json = await _document.SerializeAsJsonAsync(
OpenApiSpecVersion.OpenApi3_1,
CancellationToken.None);
using JsonDocument serialized = JsonDocument.Parse(json);
foreach (JsonProperty path in serialized.RootElement.GetProperty("paths").EnumerateObject())
{
foreach (JsonProperty operation in path.Value.EnumerateObject())
{
if (operation.NameEquals("parameters"))
{
continue;
}
JsonElement security = operation.Value.GetProperty("security");
security.GetArrayLength().ShouldBeGreaterThan(0, $"{operation.Name} {path.Name} should declare security");
security[0].TryGetProperty(ApiSecurityOperationTransformer.SchemeName, out JsonElement scopes)
.ShouldBeTrue($"{operation.Name} {path.Name} should name the ApiKey scheme after serialization");
scopes.ValueKind.ShouldBe(JsonValueKind.Array);
}
}
}
[Test]
public void Troubleshoot_Playback_Actions_Should_Carry_Stable_Explicit_OperationIds()
{
@@ -70,7 +70,6 @@ public class PlayoutControllerTests
nameof(PlayoutController.EraseItemsAndHistory),
"POST",
"/api/v1/playouts/{id:int}/erase-items-and-history");
ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle");
ShouldHaveActionRoute(
nameof(PlayoutController.GetItemSchedulingContext),
"GET",
@@ -153,30 +152,6 @@ public class PlayoutControllerTests
result.Page.Single().IsLocked.ShouldBeTrue();
}
[Test]
public async Task GetById_Should_Surface_Seed()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));
IActionResult result = await _controller.GetById(9, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>()
.Value.ShouldBeOfType<PlayoutResponseModel>()
.Seed.ShouldBe(4242);
}
[Test]
public async Task GetAll_Should_Surface_Seed()
{
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { MakePlayout(9) with { Seed = 4242 } }));
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
result.Page[0].Seed.ShouldBe(4242);
}
// ----- Erase items / history -----
[Test]
@@ -262,62 +237,6 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
// ----- Reshuffle -----
[Test]
public async Task Reshuffle_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.ExternalJson)]
[TestCase(PlayoutScheduleKind.None)]
public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
await _mediator.Received(1).Send(
Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
// ----- Playout item scheduling context -----
[Test]
@@ -1422,7 +1341,6 @@ public class PlayoutControllerTests
new PlayoutBuildStatus(),
null,
null,
0,
0);
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
@@ -1443,8 +1361,7 @@ public class PlayoutControllerTests
vm.BuildStatus.Message),
vm.DecoId,
vm.DecoName,
isLocked,
vm.Seed);
isLocked);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
@@ -242,48 +242,6 @@ public class SettingsControllerTests
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetIptv_Should_Map_Vm_To_Response_Model()
{
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(new IptvSettingsViewModel { BaseUrl = "https://tv.example.com/etv" });
IptvSettingsResponseModel result = await _controller.GetIptv(CancellationToken.None);
result.ShouldBe(new IptvSettingsResponseModel("https://tv.example.com/etv"));
}
[Test]
public async Task UpdateIptv_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
{
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(new IptvSettingsViewModel { BaseUrl = "https://public.example.com" });
IActionResult result = await _controller.UpdateIptv(
new UpdateIptvSettingsRequest("https://public.example.com"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new IptvSettingsResponseModel("https://public.example.com"));
await _mediator.Received(1).Send(
Arg.Is<UpdateIptvSettings>(c => c.IptvSettings.BaseUrl == "https://public.example.com"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateIptv_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateIptv(
new UpdateIptvSettingsRequest("not a url"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetScanner_Should_Return_Library_Refresh_Interval()
{
-4
View File
@@ -11,10 +11,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers">
+47
View File
@@ -32,6 +32,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integrations", "integrations", "{4958D7D8-4791-2CCE-6FFA-082B65933577}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "jellyfin", "jellyfin", "{65793B68-0114-8A23-3D53-9EDEAEBFFD0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ChicoryTV", "integrations\jellyfin\Jellyfin.Plugin.ChicoryTV\Jellyfin.Plugin.ChicoryTV.csproj", "{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ChicoryTV.Tests", "integrations\jellyfin\Jellyfin.Plugin.ChicoryTV.Tests\Jellyfin.Plugin.ChicoryTV.Tests.csproj", "{10235034-68F6-44AC-8C54-4C6F12DAD534}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -315,6 +323,42 @@ Global
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x64.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x64.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x86.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x86.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|Any CPU.Build.0 = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x64.ActiveCfg = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x64.Build.0 = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x86.ActiveCfg = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x86.Build.0 = Release|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x86.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x64.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x64.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x86.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x86.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|Any CPU.Build.0 = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x64.ActiveCfg = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x64.Build.0 = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x86.ActiveCfg = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x86.Build.0 = Release|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -325,5 +369,8 @@ Global
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{2EF80455-953D-4696-831D-E8CBCA82B0EF} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{56F56E76-CEF4-4639-B7BB-03FD201BB019} = {325E6DA0-52B3-4431-98A2-72C36F403704}
{65793B68-0114-8A23-3D53-9EDEAEBFFD0F} = {4958D7D8-4791-2CCE-6FFA-082B65933577}
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8} = {65793B68-0114-8A23-3D53-9EDEAEBFFD0F}
{10235034-68F6-44AC-8C54-4C6F12DAD534} = {65793B68-0114-8A23-3D53-9EDEAEBFFD0F}
EndGlobalSection
EndGlobal
+23 -65
View File
@@ -7,11 +7,11 @@ using ErsatzTV.Application.Templates;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -49,6 +49,28 @@ public class ChannelController(
CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
[HttpGet("/api/v1/channels/{id:int}/playback-source")]
[Tags("Channels")]
[EndpointSummary("Resolve the scheduled playback source for a channel")]
[EndpointDescription(
"Returns the physical media item and in-file offset selected by the channel schedule at the requested " +
"time. This read-only endpoint never starts an ErsatzTV transcoder. at defaults to now.")]
[EndpointGroupName("general")]
[RequiresAuthentication]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[ProducesResponseType(typeof(ChannelPlaybackSourceResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPlaybackSource(
int id,
[FromQuery] DateTimeOffset? at,
CancellationToken cancellationToken)
{
Option<ChannelPlaybackSourceResponseModel> result = await mediator.Send(
new GetChannelPlaybackSource(id, at ?? DateTimeOffset.UtcNow),
cancellationToken);
return result.ToGetResult();
}
[HttpGet("/api/v1/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
[Tags("Channels")]
[EndpointSummary("Get available music video credits template names")]
@@ -215,70 +237,6 @@ public class ChannelController(
return result.ToDeletedResult();
}
[HttpPost("/api/v1/channels/auto-tune/preview", Name = "PreviewAutoTuneChannels")]
[Tags("Channels")]
[EndpointSummary("Preview auto-tuned channels")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<AutoTuneProposalResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> PreviewAutoTune(
[Required][FromBody] PreviewAutoTuneChannelsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, List<AutoTuneProposal>> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(
Left: error => error.ToErrorResult(),
Right: proposals => new OkObjectResult(proposals.Select(ProjectToResponseModel).ToList()));
}
[HttpPost("/api/v1/channels/auto-tune", Name = "CreateAutoTunedChannels")]
[Tags("Channels")]
[EndpointSummary("Create auto-tuned channels")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(AutoTuneResultResponseModel), StatusCodes.Status200OK)]
public async Task<IActionResult> CreateAutoTuned(
[Required][FromBody] CreateAutoTunedChannelsRequest request,
CancellationToken cancellationToken)
{
AutoTuneResult result = await mediator.Send(request.ToCommand(), cancellationToken);
return new OkObjectResult(ProjectToResponseModel(result));
}
[HttpGet("/api/v1/channels/auto-tune/members", Name = "GetAutoTuneChannelMembers")]
[Tags("Channels")]
[EndpointSummary("List a proposed auto-tune channel's distinct content-source members")]
[EndpointDescription(
"Given an auto-tune axis and value, returns the distinct content sources (parent shows for the " +
"TV axes, movies for the movie-genre axis) the server-generated SmartCollection query resolves " +
"to, with a per-source item count. Read-only; the server owns query generation.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLibraryBrowseItemsResponseModel> GetAutoTuneChannelMembers(
[FromQuery] AutoTuneAxis axis,
[FromQuery] string value,
[FromQuery] int pageNum,
[FromQuery] int pageSize,
CancellationToken cancellationToken)
{
pageNum = Math.Max(0, pageNum);
pageSize = pageSize <= 0 ? 100 : Math.Min(pageSize, 200);
return await mediator.Send(
new GetAutoTuneChannelMembers(axis, value, pageNum, pageSize),
cancellationToken);
}
private static AutoTuneProposalResponseModel ProjectToResponseModel(AutoTuneProposal p) =>
new(p.Axis.ToString(), p.Value, p.Name, p.Number, p.ItemCount, p.AlreadyExists);
private static AutoTuneResultResponseModel ProjectToResponseModel(AutoTuneResult r) =>
new(
r.Results.Select(o => new AutoTuneChannelResultModel(
o.Name, o.Status.ToString(), o.ChannelId, o.Reason)).ToList(),
r.CreatedCount,
r.SkippedCount,
r.FailedCount);
[HttpPost("/api/v1/channels/{id:int}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
@@ -7,7 +7,6 @@ using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -41,7 +40,6 @@ public class LocalLibrariesController(
[HttpGet("/api/v1/libraries/local/{id:int}", Name = "GetLocalLibrary")]
[Tags("Libraries")]
[EndpointSummary("Get a local library by id")]
[RequiresAuthentication]
[ProducesResponseType(typeof(LocalLibraryDetailResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
+2 -43
View File
@@ -683,45 +683,6 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
return NoContent();
}
[HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")]
[Tags("Playouts")]
[EndpointSummary("Reshuffle a playout")]
[EndpointDescription(
"Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it " +
"and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Reshuffle(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
{
return BaseError.New(
"[Reshuffle] is only valid for Classic, Block, Sequential, or Scripted playouts")
.ToErrorResult();
}
}
await mediator.Send(new ReshufflePlayout(id), cancellationToken);
return Accepted();
}
[HttpGet("/api/v1/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
[Tags("Playouts")]
[EndpointSummary("Decode a playout item's scheduling context")]
@@ -825,8 +786,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
ToBuildStatus(vm.BuildStatus),
vm.DecoId,
vm.DecoName,
isLocked,
vm.Seed);
isLocked);
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
new(
@@ -875,8 +835,7 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus),
vm.PlayoutMode,
isLocked,
vm.Seed);
isLocked);
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
@@ -1,30 +0,0 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record PreviewAutoTuneChannelsRequest(
List<AutoTuneAxis> Axes,
int MinItems,
int StartingNumber)
{
public PreviewAutoTuneChannels ToCommand() => new(Axes, MinItems, StartingNumber);
}
public record CreateAutoTunedChannelsRequest(
int TemplateId,
string Group,
List<AutoTunedChannelRequest> Channels)
{
public CreateAutoTunedChannels ToCommand() =>
new(TemplateId, Group, (Channels ?? []).Select(c => c.ToCommand()).ToList());
}
public record AutoTunedChannelRequest(
AutoTuneAxis Axis,
string Value,
string Name,
string Number)
{
public AutoTuneChannelSelection ToCommand() => new(Axis, Value, Name, Number);
}
@@ -1,13 +0,0 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
/// absolute M3U/XMLTV URLs; send an empty string to clear it and use the incoming request's origin.
/// </summary>
public record UpdateIptvSettingsRequest(string BaseUrl)
{
public UpdateIptvSettings ToCommand() =>
new(new IptvSettingsViewModel { BaseUrl = BaseUrl });
}
@@ -121,39 +121,6 @@ public class SettingsController(IMediator mediator) : ControllerBase
});
}
// IPTV settings
[HttpGet("/api/v1/settings/iptv", Name = "GetIptvSettings")]
[Tags("Settings")]
[EndpointSummary("Get IPTV output settings")]
[ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<IptvSettingsResponseModel> GetIptv(CancellationToken cancellationToken)
{
IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/v1/settings/iptv", Name = "UpdateIptvSettings")]
[Tags("Settings")]
[EndpointSummary("Update IPTV output settings")]
[ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateIptv(
[Required] [FromBody]
UpdateIptvSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// Scanner settings
[HttpGet("/api/v1/settings/scanner", Name = "GetScannerSettings")]
@@ -307,9 +274,6 @@ public class SettingsController(IMediator mediator) : ControllerBase
private static PlayoutSettingsResponseModel ProjectToResponseModel(PlayoutSettingsViewModel vm) =>
new(vm.DaysToBuild, vm.SkipMissingItems, vm.ScriptedScheduleTimeoutSeconds);
private static IptvSettingsResponseModel ProjectToResponseModel(IptvSettingsViewModel vm) =>
new(vm.BaseUrl);
private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) =>
new(vm.DaysToBuild, ToApiTimeZone(vm.TimeZone), ToApiBlockBehavior(vm.BlockBehavior));
+1
View File
@@ -11,6 +11,7 @@
<Configurations>Debug;Release;Debug No Sync</Configurations>
<Platforms>AnyCPU</Platforms>
<UserSecretsId>bf31217d-f4ec-4520-8cc3-138059044ede</UserSecretsId>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
<NeutralLanguage>en-US</NeutralLanguage>
@@ -36,7 +36,11 @@ public sealed class ApiSecurityOperationTransformer(IApiKeyProvider apiKeyProvid
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference(SchemeName)] = new List<string>()
// Microsoft.OpenApi 2.x needs the host document to serialize this as the
// component name. Without it the in-memory requirement looks populated,
// but the generated JSON contains `security: [ {} ]`, which means anonymous
// access in OpenAPI rather than the X-Api-Key requirement enforced at runtime.
[new OpenApiSecuritySchemeReference(SchemeName, context.Document, null)] = new List<string>()
});
operation.Responses ??= new OpenApiResponses();
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Coverage collector config (ersatztv#15). Passed to dotnet test via
"settings coverlet.runsettings" alongside collect:"XPlat Code Coverage".
The two EF Core migration folders are ~2.59M lines of generated snapshot code
(vs ~200k lines of authored code); instrumenting them balloons coverlet memory
and OOM killed the shared Build and test job (exit 137). Excluding generated
migration code bounds memory and makes the reported percentage reflect authored
code. Keep this comment free of double hyphens (invalid in XML comments).
-->
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<ExcludeByFile>**/Migrations/*.cs</ExcludeByFile>
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
<Exclude>[*]*.Migrations.*</Exclude>
<SkipAutoProps>true</SkipAutoProps>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
@@ -1,900 +0,0 @@
// Auto-Tune — generate a whole channel lineup from library metadata (#69).
// The "automatic-first" creation mode alongside the manual Channel Builder.
// One screen, three steps: Configure axes/defaults -> Preview proposed channels
// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
// Additive & non-destructive: never edits or deletes an existing channel; number
// or name collisions are skipped, never overwritten. Each generated channel is
// backed by a live SmartCollection query so it keeps tracking the library.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
// ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
// nameOf() and order are display-only here; the real server owns query + name.
const AXES = [
{ id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
{ id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
{ id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
];
const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
// ---- Mock library metadata (EF distinct+count in the real app) ------------
const LIBRARY = {
TvShow: [
{ value: "The Office", count: 201 },
{ value: "Friends", count: 236 },
{ value: "Breaking Bad", count: 62 },
{ value: "Parks and Recreation", count: 125 },
{ value: "The Twilight Zone", count: 156 },
{ value: "Firefly", count: 3 }, // below default minItems -> filtered
],
TvGenre: [
{ value: "Comedy", count: 640 },
{ value: "Drama", count: 512 },
{ value: "Sci-Fi", count: 208 },
{ value: "Crime", count: 174 },
],
MovieGenre: [
{ value: "Action", count: 42 },
{ value: "Sci-Fi", count: 28 },
{ value: "Horror", count: 35 },
{ value: "Comedy", count: 51 },
{ value: "Noir", count: 3 }, // below default minItems -> filtered
],
};
// Coexistence demo: some names already exist (deduped, unchecked by default)
// and some numbers are already taken (allocation skips them).
const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
const TAKEN_NUMBERS = new Set(["500", "503"]);
const TEMPLATES = ["Standard", "Movie night", "Music videos"];
// ---- Preview computation (advisory numbers, re-validated at create) -------
function buildProposals(axisIds, minItems, startingNumber) {
const out = [];
let next = Math.max(1, startingNumber | 0);
const takenThisRun = new Set(TAKEN_NUMBERS);
const alloc = () => {
while (takenThisRun.has(String(next))) next++;
const n = String(next);
takenThisRun.add(n);
next++;
return n;
};
// Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
AXES.forEach((ax) => {
if (!axisIds.has(ax.id)) return;
LIBRARY[ax.id]
.filter((row) => row.count >= minItems)
.slice()
.sort((a, b) => a.value.localeCompare(b.value))
.forEach((row) => {
const name = ax.nameOf(row.value);
const exists = EXISTING_NAMES.has(name);
out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
});
});
return out;
}
// ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
// explicit initials + color (ChannelLogo only derives them from name). --
const BUG_PALETTE = [
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
];
const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
function Bug({ name, initials, ci, size = 32 }) {
const idx = ci != null ? ci : bugHash(name);
const [fg, bg] = BUG_PALETTE[idx];
return (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
</span>
);
}
// ---- Example content sources (advisory sample of the smart-collection query)
const GENRE_SHOWS = {
Comedy: ["The Office", "Friends", "Parks and Recreation"],
Drama: ["Breaking Bad", "The Twilight Zone"],
"Sci-Fi": ["The Twilight Zone", "Firefly"],
Crime: ["Breaking Bad"],
};
const MOVIE_TITLES = {
Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
"Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
Horror: ["Hollow", "The Vigil", "Saltmarsh"],
Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
Noir: ["Rain on 5th", "The Long Con"],
};
const isGenreAxis = (axis) => axis !== "TvShow";
function baseSources(p) {
if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
return []; // TvShow channels track a single show — no per-source editor
}
function effectiveSources(p, ov) {
const ex = ov.exclude || [];
return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
}
// Advisory item-count estimate as sources are added/removed (per-source share).
function estItems(p, ov) {
const base = baseSources(p);
if (!base.length) return p.itemCount;
const share = p.itemCount / base.length;
return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
}
// Sources with their rotation weight (episodes played per rotation).
function sourceList(p, ov) {
return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
}
// Compact rotation-weight stepper ( N +).
function Weight({ w, onChange }) {
const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
return (
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
</div>
);
}
// ---- Example schedule (rundown → EPG blocks), advisory only ---------------
// Effective order for a channel: the axis default, unless overridden per channel.
function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
function buildSchedule(p, ov) {
const mins = RUNTIME[p.axis] || 30;
const shuffled = isShuffled(p, ov);
let seq;
if (p.axis === "TvShow") {
const n = Math.min(8, Math.max(3, p.itemCount));
let eps = Array.from({ length: n }, (_, i) => i + 1);
if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
} else {
const list = sourceList(p, ov).filter((s) => s.weight > 0);
if (!list.length) return [];
if (shuffled) {
// Weighted round-robin interleave — honors the per-source rotation ratio.
const total = list.reduce((a, s) => a + s.weight, 0);
const st = list.map((s) => ({ ...s, acc: 0 }));
const count = Math.min(9, Math.max(5, total * 2));
seq = Array.from({ length: count }, () => {
let pick = null;
st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
pick.acc -= total;
return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
});
} else {
// Sequential — each source plays its rotation count in turn.
seq = [];
list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
seq = seq.slice(0, 9);
}
}
let t = 20 * 60;
return seq.map((b) => {
const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
t += mins;
return { ...b, start, mins };
});
}
function MiniEpg({ blocks }) {
if (!blocks.length) return (
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches nothing to schedule.</div>
);
const PPM = 2.0;
return (
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
{blocks.map((b, i) => (
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
</div>
))}
</div>
</div>
);
}
function ConfigRow({ label, value, first }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
</div>
);
}
// ---- Full channel settings (mirrors the manual Channel Builder) -----------
const CH_TEMPLATES = [
{ id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
{ id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
{ id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
];
const ADV_GROUPS = [
{ group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
{ group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
{ group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
{ group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
];
// Friendly toggle row (label + description + Switch), with an override tag.
function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
return (
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
</div>
<Switch checked={checked} onChange={onChange} />
</label>
);
}
// Channel Template picker (collapsed row + dropdown + spec chips).
function TemplatePicker({ value, onPick }) {
const [open, setOpen] = React.useState(false);
const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
return (
<div>
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
</div>
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
</button>
{open && (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{CH_TEMPLATES.map((x) => {
const on = x.id === value;
return (
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
</div>
</button>
);
})}
</div>
)}
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
{t.sets.map((s) => (
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
))}
</div>
</div>
);
}
// Advanced (~24-field) override disclosure: View defaults / Override.
function AdvancedSettings({ ov, patch, templateName }) {
const [mode, setMode] = React.useState("closed"); // closed | view | override
const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
const vals = { ...defaults, ...(ov.adv || {}) };
const override = mode === "override";
const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
const overridden = Object.keys(ov.adv || {}).length;
const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
return (
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
<span style={{ flex: 1 }} />
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
</div>
{mode !== "closed" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
</div>
{ADV_GROUPS.map((g) => (
<div key={g.group}>
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k, v]) => override ? (
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
) : (
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
// Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
function LogoDrop({ name, src, onSet }) {
const [over, setOver] = React.useState(false);
const fileRef = React.useRef(null);
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
return (
<div onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
onClick={() => fileRef.current && fileRef.current.click()}
style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
</div>
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
</div>
);
}
// ---- Channel detail slide-over: full per-channel settings -----------------
function DetailPanel({ p, ov, patch, onClose }) {
const [addText, setAddText] = React.useState("");
const name = ov.customName != null ? ov.customName : p.name;
const bug = ov.bug || {};
const ax = AXIS[p.axis];
const number = ov.number != null ? ov.number : p.number;
const shuffled = isShuffled(p, ov);
const always = ov.always != null ? ov.always : true;
const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
const avatar = (size) => bug.src
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
const genre = isGenreAxis(p.axis);
const base = baseSources(p);
const ex = ov.exclude || [];
const inc = ov.include || [];
const kept = base.filter((s) => !ex.includes(s));
const items = estItems(p, ov);
const schedule = buildSchedule(p, ov);
const ratios = ov.ratios || {};
const wOf = (s) => ratios[s] || 1;
const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
const list = [...kept, ...inc];
const multi = list.length > 1;
const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
const addInc = () => {
const v = addText.trim();
if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
setAddText("");
};
return (
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
{avatar(34)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
</div>
</div>
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Channel identity</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
</label>
</div>
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
{avatar(48)}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
</label>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
{BUG_PALETTE.map((pair, i) => {
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
return (
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
</button>
);
})}
</div>
</div>
</div>
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={eyebrow}>Playback</div>
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Channel Template</div>
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Query &amp; size</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
</div>
</section>
{genre && (
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Content sources</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged {p.value}. Exclude a title even though it matches, add one that isnt tagged, or set how often each plays.</span>
</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
{list.length === 0 && (
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources add one below.</div>
)}
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
</div>
))}
</div>
{multi && (
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
</span>
)}
<div style={{ display: "flex", gap: 8 }}>
<div style={{ flex: 1 }}>
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
</div>
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
</div>
{ex.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
{ex.map((s) => (
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
</button>
))}
</div>
)}
</section>
)}
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Example schedule</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 the built playout may differ.</span>
</div>
<MiniEpg blocks={schedule} />
</section>
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
</div>
</aside>
</div>
);
}
// =========================================================================
function AutoTune() {
const [step, setStep] = React.useState("configure"); // configure | preview | create
const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
const [minItems, setMinItems] = React.useState("5");
const [startingNumber, setStartingNumber] = React.useState("500");
const [group, setGroup] = React.useState("Auto-Tuned");
const [template, setTemplate] = React.useState("Standard");
const [proposals, setProposals] = React.useState([]);
const [selected, setSelected] = React.useState(() => new Set());
const [results, setResults] = React.useState(null);
const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
const [detail, setDetail] = React.useState(null); // proposal name open in the panel
const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
const patchOv = (name) => (patch) => setOverrides((o) => {
const cur = o[name] || {};
const delta = typeof patch === "function" ? patch(cur) : patch;
return { ...o, [name]: { ...cur, ...delta } };
});
const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
const toggleAxis = (id) =>
setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const runPreview = () => {
const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
setProposals(p);
// Default selection: everything that isn't an already-existing name.
setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
setStep("preview");
};
const runCreate = () => {
// Demo the three outcome states across the selected set.
const chosen = proposals.filter((p) => selected.has(p.name));
const res = chosen.map((p, i) => {
const nm = dispName(p);
if (i === chosen.length - 1 && chosen.length > 2)
return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
if (p.name === "Sci-Fi Movies")
return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
});
setResults(res);
setStep("create");
};
const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
<Toolbar
step={step}
axisCount={axisIds.size}
selectedCount={selected.size}
onPreview={runPreview}
onCreate={runCreate}
onBack={() => setStep("configure")}
onRestart={restart}
/>
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{step === "configure" && (
<Configure
axisIds={axisIds} toggleAxis={toggleAxis}
minItems={minItems} setMinItems={setMinItems}
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
group={group} setGroup={setGroup}
template={template} setTemplate={setTemplate}
/>
)}
{step === "preview" && (
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
openDetail={setDetail} dispName={dispName} />
)}
{step === "create" && <Results results={results} group={group} />}
</main>
{detail != null && (() => {
const p = proposals.find((x) => x.name === detail);
if (!p) return null;
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
})()}
</div>
);
}
// ---- Toolbar with step rail + contextual primary action -------------------
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Sparkles" s={19} /></span>
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
</div>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<StepRail step={step} />
</div>
{step === "configure" && (
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
</Tooltip>
)}
{step === "preview" && (
<React.Fragment>
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
</Button>
</Tooltip>
</React.Fragment>
)}
{step === "create" && (
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
)}
</div>
);
}
const STEPS = [
{ id: "configure", label: "Configure" },
{ id: "preview", label: "Preview" },
{ id: "create", label: "Create" },
];
function StepRail({ step }) {
const idx = STEPS.findIndex((s) => s.id === step);
return (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{STEPS.map((s, i) => {
const done = i < idx, active = i === idx;
const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
return (
<React.Fragment key={s.id}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
<span style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
</div>
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
</React.Fragment>
);
})}
</div>
);
}
// ---- Step 1: Configure ----------------------------------------------------
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
preview the proposed channels, then create the ones you want. Existing channels are never touched.
</p>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Generate from</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
{AXES.map((ax) => {
const on = axisIds.has(ax.id);
return (
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
className="ctv-press"
style={{
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
}}>
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
</span>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
</div>
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</div>
</button>
);
})}
</div>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Defaults</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
</Field>
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
</Field>
<Field label="Channel group" hint="Every generated channel lands in this group.">
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
</Field>
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
</Field>
</div>
</section>
</div>
);
}
function Field({ label, hint, children }) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
{children}
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
</label>
);
}
// ---- Step 2: Preview ------------------------------------------------------
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
const selectable = proposals.filter((p) => !p.alreadyExists);
const existingCount = proposals.length - selectable.length;
if (!proposals.length) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
<Ico n="SearchX" s={26} />
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched try lowering the minimum items.</div>
</div>
);
}
const setAll = (rows, on) => setSelected((s) => {
const n = new Set(s);
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
return n;
});
return (
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
</span>
{existingCount > 0 && (
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist deselected</Tag>
)}
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
</div>
{groups.map(({ ax, rows }) => {
const groupSel = rows.filter((r) => !r.alreadyExists);
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
const someOn = groupSel.some((r) => selected.has(r.name));
return (
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
<Ico n={ax.icon} s={15} />
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
<Badge tone="neutral">{rows.length}</Badge>
<span style={{ flex: 1 }} />
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</span>
</div>
<div>
{rows.map((r, i) => {
const on = selected.has(r.name);
const ov = overrides[r.name] || {};
const name = dispName(r);
const isExp = expanded.has(r.name);
return (
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from {r.value}</div>
</button>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
</span>
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span>&nbsp;items</Badge>
{r.alreadyExists ? (
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
) : (
<React.Fragment>
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
</IconButton>
</React.Fragment>
)}
</div>
{isExp && !r.alreadyExists && (
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={eyebrow}>Example schedule</span>
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
</div>
<MiniEpg blocks={buildSchedule(r, ov)} />
</div>
)}
</div>
);
})}
</div>
</section>
);
})}
</div>
);
}
// ---- Step 3: Results ------------------------------------------------------
const RESULT_META = {
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
};
function Results({ results, group }) {
const count = (s) => results.filter((r) => r.status === s).length;
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
</div>
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
{results.map((r, i) => {
const m = RESULT_META[r.status];
return (
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
</div>
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
<Badge tone={m.tone}>{r.status}</Badge>
</div>
);
})}
</section>
</div>
);
}
window.CTVAutoTune = AutoTune;
})();
@@ -1,167 +0,0 @@
# Handoff: ChicoryTV — Auto-Tune (generate channels from library metadata, #69)
## Overview
**Auto-Tune** is the *automatic-first* channel-creation mode — the counterpart to the manual
**Channel Builder** ("New Channel"). Instead of building one channel by hand, it enumerates your
library's metadata along one or more **axes** (TV Shows, TV Genres, Movie Genres), **previews** the
whole set of proposed channels so you can pick which to keep, then **bulk-creates** the selected
ones. It is **additive and non-destructive**: it never edits or deletes an existing channel; name
and number collisions are *skipped*, never overwritten. Each generated channel is backed by a live
**SmartCollection** query, so a "Comedy" channel keeps picking up new comedies as the library grows.
Concept borrowed from **PseudoTV Live's** signature Auto-Tuning, deliberately fixing its two
weaknesses: PseudoTV is all-or-nothing per category with no preview, and it wipes+rebuilds the whole
lineup on every run. Ours adds a preview/select step and is non-destructive.
The **backend already shipped** (PR1, ersatztv#379) — two endpoints under the frozen `/api/v1`. This
handoff is **PR2: the SPA screen** that drives them.
> **Scope note (2026-07-16).** The prototype (`AutoTune.jsx`) was iterated to add a per-channel
> **DetailPanel** slide-over (opened from a "Configure" button on each Preview row — full per-channel
> editor: identity/image, shuffle/always-playing, template + Advanced overrides, weighted content
> sources, exclude/add-untagged, example schedule). That panel needs backend the PR1 endpoints don't
> have, so **it is DEFERRED to a follow-up arc — ersatztv#383** (children #384 enumerate members,
> #385 per-channel overrides + rotation weights in create, #386 the SPA panel). **PR2 (this handoff)
> implements the 3-step wizard only — Configure → Preview → Create — on the existing PR1 endpoints;
> no per-row Configure button.** The DetailPanel sections below the wizard spec are recorded for the
> #383 arc, not PR2.
## About the design files
The files in this bundle are **design references built in HTML/React** (a prototype on the ChicoryTV
UI kit) — **not production code**. Recreate the design in the target codebase (the ChicoryTV React
SPA, `web/`) using its real components (`web/src/components/`) and CSS-custom-property tokens. The
prototype (`AutoTune.jsx`) is the source of truth for **layout, spacing, motion, and interaction**;
its mock data and mock outcome logic are illustrative only — the real screen calls the API.
## Fidelity
**High-fidelity.** Colors, typography, spacing, radii, and interactions are resolved and use tokens
throughout, so it is fully theme-aware (verified in the warm + cool accent themes). All values below
are exact.
---
## The screen — a 3-step wizard
One full-height column: a fixed **toolbar** (`12px 20px` padding, `1px solid var(--border-hairline)`
bottom border) over a scrolling `<main>`. The toolbar is constant across steps; its content and
primary action change per step.
**Toolbar (all steps):**
- Left: a 34×34 accent **icon tile** (`var(--radius-sm)`, `background: var(--ctv-accent-soft)`,
`color: var(--ctv-accent)`) with the `Sparkles` icon; then title **"Auto-Tune"** (`--text-md`,
semibold) + subtitle **"Generate channels from your library"** (`--text-xs`, `--text-secondary`).
- Center: a **step rail** — three steps (`Configure · Preview · Create`) joined by 26px hairline
connectors. Each step is a 20px round chip + label. The **active** step: chip
`background var(--ctv-accent)` / `color var(--text-on-accent)`, label accent + semibold. A
**completed** step: chip `var(--ctv-accent-soft)` bg + accent `Check` icon, label
`--text-secondary`. A **future** step: chip `var(--ctv-surface-2)` + `--text-disabled`.
- Right: the contextual primary action (below).
### Step 1 — Configure
Centered column, `max-width: 860px`, `padding: 26px 24px 40px`.
1. **Intro paragraph** (`--text-sm`, `--text-secondary`, `max-width: 620px`): what Auto-Tune does +
the non-destructive promise.
2. **"Generate from"** section (eyebrow label) — a **3-column grid** of selectable **axis cards**
(one per axis). Each card is a `<button>` (`ctv-press` for the tactile scale-on-press):
- Selected: `1px solid var(--ctv-accent)` border, `var(--ctv-accent-soft)` bg, `var(--shadow-sm)`;
a filled accent `CheckCircle2` top-right; the icon tile is `var(--ctv-accent)` /
`var(--text-on-accent)`.
- Unselected: `1px solid var(--border-control)`, `var(--surface-card)` bg; a `Circle` outline
top-right (`--text-disabled`); icon tile `var(--ctv-surface-2)` / `--text-secondary`.
- Content: axis icon (Tv / Clapperboard / Film), **title**, one-line **tagline**, and a bottom
**order** chip (`ListOrdered` "Episode order" for TV Shows; `Shuffle` "Shuffled" for the genres).
3. **"Defaults"** section — a 2-column grid of four labelled fields, each with a helper line
(`Field` component: label `--text-xs` medium, hint `--text-2xs` `--text-disabled`):
- **Minimum items** (`Input type=number`, `Hash` leading icon) — skip channels below this count.
- **Starting channel number** (`Input type=number`, `Tv` icon) — numbers count up from here.
- **Channel group** (`Input`, `FolderTree` icon) — the group every generated channel lands in.
- **Channel template** (`Select`) — the batch's streaming/playout/filler defaults.
**Primary action:** `Preview channels` (primary, `Eye` icon). **Disabled** with a tooltip until ≥1
axis is selected.
### Step 2 — Preview
Centered column, `max-width: 920px`.
- **Summary bar:** `N of M selected` (mono N), an info **Tag** `"{k} already exist — deselected"`
when any proposal's name collides, a spacer, then ghost **Select all** / **Clear** buttons (they
only touch selectable — non-existing — rows).
- **One section per axis** that produced rows (`var(--surface-card)`, hairline border,
`var(--radius-md)`), in axis order (TV Shows → TV Genres → Movie Genres). Section header
(`var(--ctv-bg-sunken)`): a **group checkbox** (tri-state: checked / indeterminate when partial),
the axis icon + title, a neutral **Badge** with the row count, and a right-aligned order eyebrow.
- **Rows** (hairline-separated): per-row **Checkbox**, the allocated **number** (mono,
`min-width 42`), the channel **name** (semibold, ellipsis) over a `from "{value}"` sub-line, a
neutral **Badge** `{itemCount} items`, and — for an already-existing name — an `Exists` **Tag**.
Existing rows render at `opacity: 0.55` with a **disabled, unchecked** checkbox (dedup: you can't
re-create a channel that already exists by that name).
- **Empty state** (no proposals matched): centered `SearchX` + "try lowering the minimum items".
**Primary action:** `Create {N} channels` (primary, `Check` icon; label pluralizes; disabled until
≥1 selected) preceded by a ghost `Back` (`ArrowLeft`) that returns to Configure.
### Step 3 — Create (results)
Centered column, `max-width: 860px`.
- **Three `Stat` tiles** across the top: **Created / Skipped / Failed** counts (icons
`CheckCircle2` / `MinusCircle` / `XCircle`).
- A **"Added to group {group}"** line (`FolderTree` icon).
- A **results list** (card, hairline rows): per channel a status **glyph** in the status color
(`CheckCircle2` live-green / `MinusCircle` secondary / `XCircle` danger), the **name**, an optional
**reason** sub-line (skip/fail explanation), the new **`#{channelId}`** (mono) when created, and a
status **Badge** (`positive` / `neutral` / `danger`).
**Primary action:** `Start over` (primary, `RotateCcw`) — resets the wizard to Configure.
---
## API mapping (the real screen)
Both endpoints already exist (PR1). The client sends **only `axis` + `value`** back — never a Lucene
query; the server regenerates it (query authorship is server-side only).
| UI element | Endpoint / field |
|---|---|
| `Preview channels` | `POST /api/v1/channels/auto-tune/preview` |
| — axis cards | request `axes: AutoTuneAxis[]` (`"TvShow" \| "TvGenre" \| "MovieGenre"`) |
| — Minimum items | request `minItems: number` |
| — Starting channel number | request `startingNumber: number` |
| Preview rows | response `AutoTuneProposalResponseModel[]`: `{ axis, value, name, number (string), itemCount, alreadyExists }` |
| — number chip | `number` (string — channel numbers can be `"500.1"`; render as-is) |
| — `{itemCount} items` badge | `itemCount` |
| — greyed + `Exists` | `alreadyExists === true` (deselect + disable) |
| `Create {N} channels` | `POST /api/v1/channels/auto-tune` |
| — Channel template field | request `templateId: number` (from `GET /api/v1/channel-templates` / `…/default`) |
| — Channel group field | request `group: string` |
| — selected rows | request `channels: { axis, value, name, number }[]` (echo the selected proposals) |
| Results tiles + list | response `AutoTuneResultResponseModel`: `{ results: { name, status ("Created"\|"Skipped"\|"Failed"), channelId, reason }[], createdCount, skippedCount, failedCount }` |
Notes:
- **Numbers are advisory.** The preview allocates them (skipping taken numbers); the create handler
**re-validates** at create time — a number taken in between yields a per-channel `Skipped`, not a
batch failure. So a `Skipped` outcome with "number … already taken" is normal, not an error.
- **Default selection = every proposal whose `alreadyExists` is false.**
- **Grouping/ordering** is fixed: axis order (TvShow, TvGenre, MovieGenre) then by value; mirror the
server's ordering rather than re-sorting client-side.
- **Template default:** preselect `GET /api/v1/channel-templates/default` (fall back to the first
template) so the field is never empty; the batch requires a `templateId`.
## Real-SPA implementation notes (target = `web/`)
- New screen `web/src/screens/AutoTuneScreen.tsx`; new route id `autoTune`, path `/app/auto-tune`,
label **"Auto-Tune"**, icon `Sparkles`, placed **right after `builder`** in the first sidebar nav
group (the two channel-creation modes sit together). No `primaryAction` label — the wizard's
action lives in-body (per `spa-conventions.md` §10: the "+" banner is for single unambiguous
*create* list screens; a multi-step wizard drives its own buttons), so the shell shows no banner
button for this screen.
- New API module `web/src/api/autoTune.ts` (re-export the generated DTOs; `previewAutoTune(body)` +
`createAutoTunedChannels(body)` over the shared `request` helper; a `messageFrom…Error` narrower),
re-exported from `web/src/api/index.ts`.
- Reuse `getChannelTemplates` / `getDefaultChannelTemplate` from `web/src/api/channelTemplates.ts`
for the template `Select`.
- Follow `spa-conventions.md` §3 for the two async calls (discriminated-union state, `seqRef` +
`activeRef`, no synchronous set-state in an effect body). The wizard is transient (a create flow,
not an editor of persisted data), so it does **not** register the §8 unsaved-changes guard.
- Map prototype primitives → real components: `Button`, `Input`, `Select`, `Checkbox`, `Badge`,
`Tag`, `Stat`, `Tooltip`, `Spinner` from `web/src/components/`. The axis cards and step rail are
small screen-local components built from `ctv-*` utility classes + tokens (no new shared primitive).
- Docs to update in the same PR: `docs/domain-model.md` (add `/app/auto-tune` to the channel routes),
`docs/blazor-route-parity.md` (net-new SPA screen, no Blazor ancestor), `docs/spa-conventions.md`
(if the wizard/step-rail pattern is worth recording), and `design-system/` committed alongside.
@@ -1,241 +0,0 @@
// API Key screen — machine key management and local admin password change.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Card, Input, Spinner } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
// Mock data
const MACHINE_KEY = "etv_0b4f8c2a1e9d5f3b7a6c4e2d0f1a8b9c5d3e2f1a0b4c6d8e9f0a1b2c3d4e5f6g";
const mockAuthMethod = "local"; // 'local' or 'oidc' — determines if password card shows
function CardFrame({ title, subtitle, children }) {
return (
<div style={{
backgroundColor: "var(--surface-card)",
border: "1px solid var(--border-hairline)",
borderRadius: "var(--radius-md)",
padding: "var(--pad-card)",
display: "flex",
flexDirection: "column",
gap: 12
}}>
{title && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{title}</h2>
{subtitle && (
<p style={{ margin: 0, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</p>
)}
</div>
)}
{children}
</div>
);
}
function MachineKeyCard() {
const [revealed, setRevealed] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const copy = () => {
navigator.clipboard?.writeText(MACHINE_KEY).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<CardFrame
title="Machine API Key"
subtitle="This key authenticates MCP and external REST clients. The browser no longer uses it — you sign in with your account instead."
>
{/* Success callout */}
<div style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 14px",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--ctv-ok-soft)",
border: "1px solid var(--status-ok)",
color: "var(--text-primary)"
}}>
<Ico n="ShieldCheck" s={15} color="var(--status-ok)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
Give this key to an MCP server or external REST client to let it call this server's API.
</span>
</div>
{/* Machine key field */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-secondary)" }}>
Machine key
</label>
<div style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--ctv-bg-sunken)",
border: "1px solid var(--border-control)",
fontFamily: "var(--font-mono)"
}}>
<Ico n="KeyRound" s={14} color="var(--text-disabled)" />
<code style={{
flex: 1,
overflowX: "auto",
whiteSpace: "nowrap",
font: "var(--text-sm)/1.3 var(--font-mono)",
color: "var(--text-primary)",
margin: 0,
background: "none"
}}>
{revealed ? MACHINE_KEY : "••••••••••••••••••••••••"}
</code>
</div>
</div>
{/* Action buttons */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
<Button
size="sm"
variant="ghost"
startIcon={revealed ? <Ico n="EyeOff" s={14} /> : <Ico n="Eye" s={14} />}
onClick={() => setRevealed(!revealed)}
>
{revealed ? "Hide" : "Reveal"}
</Button>
<Button
size="sm"
variant="secondary"
startIcon={copied ? <Ico n="Check" s={14} /> : <Ico n="Copy" s={14} />}
onClick={copy}
>
{copied ? "Copied" : "Copy"}
</Button>
</div>
</CardFrame>
);
}
function LocalPasswordCard() {
const [currentPassword, setCurrentPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
const [justSaved, setJustSaved] = React.useState(false);
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && !saving;
const submit = () => {
if (!canSubmit) return;
setSaving(true);
setError(null);
setJustSaved(false);
// Simulate API call
setTimeout(() => {
setCurrentPassword("");
setNewPassword("");
setJustSaved(true);
setSaving(false);
}, 800);
};
return (
<CardFrame
title="Local admin password"
subtitle="Change the password for your local admin account."
>
{error && (
<div style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 14px",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--ctv-warn-soft)",
border: "1px solid var(--status-warn)",
color: "var(--text-primary)"
}}>
<Ico n="TriangleAlert" s={15} color="var(--status-warn)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)" }}>
{error}
</span>
</div>
)}
{/* Form fields */}
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: error ? 12 : 0 }}>
<Input
label="Current password"
type="password"
value={currentPassword}
onChange={(e) => {
setCurrentPassword(e.target.value);
setJustSaved(false);
}}
leadingIcon={<Ico n="Lock" s={14} />}
placeholder="Enter your current password"
/>
<Input
label="New password"
type="password"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setJustSaved(false);
}}
leadingIcon={<Ico n="Lock" s={14} />}
placeholder="Enter your new password"
/>
</div>
{/* Action bar */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginTop: 16 }}>
{justSaved && (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)",
color: "var(--status-ok)"
}}>
<Ico n="Check" s={14} />
Password changed.
</span>
)}
{!justSaved && <span />}
<Button
size="sm"
variant="primary"
startIcon={<Ico n="Check" s={14} />}
onClick={submit}
disabled={!canSubmit}
loading={saving}
>
{saving ? "Changing…" : "Change password"}
</Button>
</div>
</CardFrame>
);
}
function ApiKey() {
return (
<div style={{
display: "flex",
flexDirection: "column",
height: "100%",
gap: 16,
padding: "20px",
overflow: "auto"
}}>
<MachineKeyCard />
{mockAuthMethod === "local" && <LocalPasswordCard />}
</div>
);
}
window.CTVApiKey = ApiKey;
})();
@@ -1,900 +0,0 @@
// Auto-Tune — generate a whole channel lineup from library metadata (#69).
// The "automatic-first" creation mode alongside the manual Channel Builder.
// One screen, three steps: Configure axes/defaults -> Preview proposed channels
// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
// Additive & non-destructive: never edits or deletes an existing channel; number
// or name collisions are skipped, never overwritten. Each generated channel is
// backed by a live SmartCollection query so it keeps tracking the library.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
// ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
// nameOf() and order are display-only here; the real server owns query + name.
const AXES = [
{ id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
{ id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
{ id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
];
const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
// ---- Mock library metadata (EF distinct+count in the real app) ------------
const LIBRARY = {
TvShow: [
{ value: "The Office", count: 201 },
{ value: "Friends", count: 236 },
{ value: "Breaking Bad", count: 62 },
{ value: "Parks and Recreation", count: 125 },
{ value: "The Twilight Zone", count: 156 },
{ value: "Firefly", count: 3 }, // below default minItems -> filtered
],
TvGenre: [
{ value: "Comedy", count: 640 },
{ value: "Drama", count: 512 },
{ value: "Sci-Fi", count: 208 },
{ value: "Crime", count: 174 },
],
MovieGenre: [
{ value: "Action", count: 42 },
{ value: "Sci-Fi", count: 28 },
{ value: "Horror", count: 35 },
{ value: "Comedy", count: 51 },
{ value: "Noir", count: 3 }, // below default minItems -> filtered
],
};
// Coexistence demo: some names already exist (deduped, unchecked by default)
// and some numbers are already taken (allocation skips them).
const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
const TAKEN_NUMBERS = new Set(["500", "503"]);
const TEMPLATES = ["Standard", "Movie night", "Music videos"];
// ---- Preview computation (advisory numbers, re-validated at create) -------
function buildProposals(axisIds, minItems, startingNumber) {
const out = [];
let next = Math.max(1, startingNumber | 0);
const takenThisRun = new Set(TAKEN_NUMBERS);
const alloc = () => {
while (takenThisRun.has(String(next))) next++;
const n = String(next);
takenThisRun.add(n);
next++;
return n;
};
// Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
AXES.forEach((ax) => {
if (!axisIds.has(ax.id)) return;
LIBRARY[ax.id]
.filter((row) => row.count >= minItems)
.slice()
.sort((a, b) => a.value.localeCompare(b.value))
.forEach((row) => {
const name = ax.nameOf(row.value);
const exists = EXISTING_NAMES.has(name);
out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
});
});
return out;
}
// ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
// explicit initials + color (ChannelLogo only derives them from name). --
const BUG_PALETTE = [
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
];
const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
function Bug({ name, initials, ci, size = 32 }) {
const idx = ci != null ? ci : bugHash(name);
const [fg, bg] = BUG_PALETTE[idx];
return (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
</span>
);
}
// ---- Example content sources (advisory sample of the smart-collection query)
const GENRE_SHOWS = {
Comedy: ["The Office", "Friends", "Parks and Recreation"],
Drama: ["Breaking Bad", "The Twilight Zone"],
"Sci-Fi": ["The Twilight Zone", "Firefly"],
Crime: ["Breaking Bad"],
};
const MOVIE_TITLES = {
Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
"Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
Horror: ["Hollow", "The Vigil", "Saltmarsh"],
Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
Noir: ["Rain on 5th", "The Long Con"],
};
const isGenreAxis = (axis) => axis !== "TvShow";
function baseSources(p) {
if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
return []; // TvShow channels track a single show — no per-source editor
}
function effectiveSources(p, ov) {
const ex = ov.exclude || [];
return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
}
// Advisory item-count estimate as sources are added/removed (per-source share).
function estItems(p, ov) {
const base = baseSources(p);
if (!base.length) return p.itemCount;
const share = p.itemCount / base.length;
return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
}
// Sources with their rotation weight (episodes played per rotation).
function sourceList(p, ov) {
return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
}
// Compact rotation-weight stepper ( N +).
function Weight({ w, onChange }) {
const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
return (
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
</div>
);
}
// ---- Example schedule (rundown → EPG blocks), advisory only ---------------
// Effective order for a channel: the axis default, unless overridden per channel.
function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
function buildSchedule(p, ov) {
const mins = RUNTIME[p.axis] || 30;
const shuffled = isShuffled(p, ov);
let seq;
if (p.axis === "TvShow") {
const n = Math.min(8, Math.max(3, p.itemCount));
let eps = Array.from({ length: n }, (_, i) => i + 1);
if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
} else {
const list = sourceList(p, ov).filter((s) => s.weight > 0);
if (!list.length) return [];
if (shuffled) {
// Weighted round-robin interleave — honors the per-source rotation ratio.
const total = list.reduce((a, s) => a + s.weight, 0);
const st = list.map((s) => ({ ...s, acc: 0 }));
const count = Math.min(9, Math.max(5, total * 2));
seq = Array.from({ length: count }, () => {
let pick = null;
st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
pick.acc -= total;
return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
});
} else {
// Sequential — each source plays its rotation count in turn.
seq = [];
list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
seq = seq.slice(0, 9);
}
}
let t = 20 * 60;
return seq.map((b) => {
const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
t += mins;
return { ...b, start, mins };
});
}
function MiniEpg({ blocks }) {
if (!blocks.length) return (
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches nothing to schedule.</div>
);
const PPM = 2.0;
return (
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
{blocks.map((b, i) => (
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
</div>
))}
</div>
</div>
);
}
function ConfigRow({ label, value, first }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
</div>
);
}
// ---- Full channel settings (mirrors the manual Channel Builder) -----------
const CH_TEMPLATES = [
{ id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
{ id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
{ id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
];
const ADV_GROUPS = [
{ group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
{ group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
{ group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
{ group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
];
// Friendly toggle row (label + description + Switch), with an override tag.
function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
return (
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
</div>
<Switch checked={checked} onChange={onChange} />
</label>
);
}
// Channel Template picker (collapsed row + dropdown + spec chips).
function TemplatePicker({ value, onPick }) {
const [open, setOpen] = React.useState(false);
const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
return (
<div>
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
</div>
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
</button>
{open && (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{CH_TEMPLATES.map((x) => {
const on = x.id === value;
return (
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
</div>
</button>
);
})}
</div>
)}
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
{t.sets.map((s) => (
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
))}
</div>
</div>
);
}
// Advanced (~24-field) override disclosure: View defaults / Override.
function AdvancedSettings({ ov, patch, templateName }) {
const [mode, setMode] = React.useState("closed"); // closed | view | override
const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
const vals = { ...defaults, ...(ov.adv || {}) };
const override = mode === "override";
const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
const overridden = Object.keys(ov.adv || {}).length;
const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
return (
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
<span style={{ flex: 1 }} />
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
</div>
{mode !== "closed" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
</div>
{ADV_GROUPS.map((g) => (
<div key={g.group}>
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k, v]) => override ? (
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
) : (
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
// Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
function LogoDrop({ name, src, onSet }) {
const [over, setOver] = React.useState(false);
const fileRef = React.useRef(null);
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
return (
<div onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
onClick={() => fileRef.current && fileRef.current.click()}
style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
</div>
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
</div>
);
}
// ---- Channel detail slide-over: full per-channel settings -----------------
function DetailPanel({ p, ov, patch, onClose }) {
const [addText, setAddText] = React.useState("");
const name = ov.customName != null ? ov.customName : p.name;
const bug = ov.bug || {};
const ax = AXIS[p.axis];
const number = ov.number != null ? ov.number : p.number;
const shuffled = isShuffled(p, ov);
const always = ov.always != null ? ov.always : true;
const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
const avatar = (size) => bug.src
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
const genre = isGenreAxis(p.axis);
const base = baseSources(p);
const ex = ov.exclude || [];
const inc = ov.include || [];
const kept = base.filter((s) => !ex.includes(s));
const items = estItems(p, ov);
const schedule = buildSchedule(p, ov);
const ratios = ov.ratios || {};
const wOf = (s) => ratios[s] || 1;
const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
const list = [...kept, ...inc];
const multi = list.length > 1;
const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
const addInc = () => {
const v = addText.trim();
if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
setAddText("");
};
return (
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
{avatar(34)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
</div>
</div>
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Channel identity</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
</label>
</div>
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
{avatar(48)}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
</label>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
{BUG_PALETTE.map((pair, i) => {
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
return (
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
</button>
);
})}
</div>
</div>
</div>
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={eyebrow}>Playback</div>
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Channel Template</div>
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Query &amp; size</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
</div>
</section>
{genre && (
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Content sources</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged {p.value}. Exclude a title even though it matches, add one that isnt tagged, or set how often each plays.</span>
</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
{list.length === 0 && (
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources add one below.</div>
)}
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
</div>
))}
</div>
{multi && (
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
</span>
)}
<div style={{ display: "flex", gap: 8 }}>
<div style={{ flex: 1 }}>
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
</div>
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
</div>
{ex.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
{ex.map((s) => (
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
</button>
))}
</div>
)}
</section>
)}
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Example schedule</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 the built playout may differ.</span>
</div>
<MiniEpg blocks={schedule} />
</section>
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
</div>
</aside>
</div>
);
}
// =========================================================================
function AutoTune() {
const [step, setStep] = React.useState("configure"); // configure | preview | create
const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
const [minItems, setMinItems] = React.useState("5");
const [startingNumber, setStartingNumber] = React.useState("500");
const [group, setGroup] = React.useState("Auto-Tuned");
const [template, setTemplate] = React.useState("Standard");
const [proposals, setProposals] = React.useState([]);
const [selected, setSelected] = React.useState(() => new Set());
const [results, setResults] = React.useState(null);
const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
const [detail, setDetail] = React.useState(null); // proposal name open in the panel
const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
const patchOv = (name) => (patch) => setOverrides((o) => {
const cur = o[name] || {};
const delta = typeof patch === "function" ? patch(cur) : patch;
return { ...o, [name]: { ...cur, ...delta } };
});
const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
const toggleAxis = (id) =>
setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const runPreview = () => {
const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
setProposals(p);
// Default selection: everything that isn't an already-existing name.
setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
setStep("preview");
};
const runCreate = () => {
// Demo the three outcome states across the selected set.
const chosen = proposals.filter((p) => selected.has(p.name));
const res = chosen.map((p, i) => {
const nm = dispName(p);
if (i === chosen.length - 1 && chosen.length > 2)
return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
if (p.name === "Sci-Fi Movies")
return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
});
setResults(res);
setStep("create");
};
const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
<Toolbar
step={step}
axisCount={axisIds.size}
selectedCount={selected.size}
onPreview={runPreview}
onCreate={runCreate}
onBack={() => setStep("configure")}
onRestart={restart}
/>
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{step === "configure" && (
<Configure
axisIds={axisIds} toggleAxis={toggleAxis}
minItems={minItems} setMinItems={setMinItems}
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
group={group} setGroup={setGroup}
template={template} setTemplate={setTemplate}
/>
)}
{step === "preview" && (
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
openDetail={setDetail} dispName={dispName} />
)}
{step === "create" && <Results results={results} group={group} />}
</main>
{detail != null && (() => {
const p = proposals.find((x) => x.name === detail);
if (!p) return null;
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
})()}
</div>
);
}
// ---- Toolbar with step rail + contextual primary action -------------------
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Sparkles" s={19} /></span>
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
</div>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<StepRail step={step} />
</div>
{step === "configure" && (
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
</Tooltip>
)}
{step === "preview" && (
<React.Fragment>
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
</Button>
</Tooltip>
</React.Fragment>
)}
{step === "create" && (
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
)}
</div>
);
}
const STEPS = [
{ id: "configure", label: "Configure" },
{ id: "preview", label: "Preview" },
{ id: "create", label: "Create" },
];
function StepRail({ step }) {
const idx = STEPS.findIndex((s) => s.id === step);
return (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{STEPS.map((s, i) => {
const done = i < idx, active = i === idx;
const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
return (
<React.Fragment key={s.id}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
<span style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
</div>
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
</React.Fragment>
);
})}
</div>
);
}
// ---- Step 1: Configure ----------------------------------------------------
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
preview the proposed channels, then create the ones you want. Existing channels are never touched.
</p>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Generate from</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
{AXES.map((ax) => {
const on = axisIds.has(ax.id);
return (
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
className="ctv-press"
style={{
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
}}>
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
</span>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
</div>
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</div>
</button>
);
})}
</div>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Defaults</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
</Field>
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
</Field>
<Field label="Channel group" hint="Every generated channel lands in this group.">
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
</Field>
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
</Field>
</div>
</section>
</div>
);
}
function Field({ label, hint, children }) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
{children}
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
</label>
);
}
// ---- Step 2: Preview ------------------------------------------------------
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
const selectable = proposals.filter((p) => !p.alreadyExists);
const existingCount = proposals.length - selectable.length;
if (!proposals.length) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
<Ico n="SearchX" s={26} />
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched try lowering the minimum items.</div>
</div>
);
}
const setAll = (rows, on) => setSelected((s) => {
const n = new Set(s);
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
return n;
});
return (
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
</span>
{existingCount > 0 && (
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist deselected</Tag>
)}
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
</div>
{groups.map(({ ax, rows }) => {
const groupSel = rows.filter((r) => !r.alreadyExists);
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
const someOn = groupSel.some((r) => selected.has(r.name));
return (
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
<Ico n={ax.icon} s={15} />
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
<Badge tone="neutral">{rows.length}</Badge>
<span style={{ flex: 1 }} />
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</span>
</div>
<div>
{rows.map((r, i) => {
const on = selected.has(r.name);
const ov = overrides[r.name] || {};
const name = dispName(r);
const isExp = expanded.has(r.name);
return (
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from {r.value}</div>
</button>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
</span>
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span>&nbsp;items</Badge>
{r.alreadyExists ? (
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
) : (
<React.Fragment>
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
</IconButton>
</React.Fragment>
)}
</div>
{isExp && !r.alreadyExists && (
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={eyebrow}>Example schedule</span>
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
</div>
<MiniEpg blocks={buildSchedule(r, ov)} />
</div>
)}
</div>
);
})}
</div>
</section>
);
})}
</div>
);
}
// ---- Step 3: Results ------------------------------------------------------
const RESULT_META = {
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
};
function Results({ results, group }) {
const count = (s) => results.filter((r) => r.status === s).length;
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
</div>
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
{results.map((r, i) => {
const m = RESULT_META[r.status];
return (
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
</div>
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
<Badge tone={m.tone}>{r.status}</Badge>
</div>
);
})}
</section>
</div>
);
}
window.CTVAutoTune = AutoTune;
})();

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