Compare commits
172
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef581403c | ||
|
|
990d32f31a | ||
|
|
851dca2596 | ||
|
|
aa4a8fb849 | ||
|
|
243bec708d | ||
|
|
4596603020 | ||
|
|
54ed75624a | ||
|
|
b5b6e7f636 | ||
|
|
4bdad4bf52 | ||
|
|
147166b053 | ||
|
|
6bd1d954bd | ||
|
|
64decd492e | ||
|
|
eb4de0cc2b | ||
|
|
7cc12881de | ||
|
|
6f4497e1ce | ||
|
|
e3645a2840 | ||
|
|
fd70e6eb4f | ||
|
|
c3beee1629 | ||
|
|
f04412a95b | ||
|
|
8e1e15e4d7 | ||
|
|
75139bbf31 | ||
|
|
0ad02db651 | ||
|
|
39873811da | ||
|
|
97bc346539 | ||
|
|
abeef63f05 | ||
|
|
9798877631 | ||
|
|
32747a067e | ||
|
|
352aa70634 | ||
|
|
e87285c33d | ||
|
|
e7bac06d1b | ||
|
|
5dd0b147e8 | ||
|
|
dbe72dbf03 | ||
|
|
54919e4730 | ||
|
|
e8ad79623f | ||
|
|
71706f3849 | ||
|
|
0f1951340e | ||
|
|
f7fa57c816 | ||
|
|
d8c0b3e752 | ||
|
|
cb5d1af65f | ||
|
|
67e53b1304 | ||
|
|
0081bed4b1 | ||
|
|
890745d1d4 | ||
|
|
6f2898d7a9 | ||
|
|
2f2bcca681 | ||
|
|
c8e79f49f4 | ||
|
|
0952078c2b | ||
|
|
b7d9e0ecb9 | ||
|
|
bcff7c686a | ||
|
|
2fee3f0b94 | ||
|
|
8664220aa5 | ||
|
|
fa789bc06f | ||
|
|
22e89fb9a4 | ||
|
|
e678e6653e | ||
|
|
7e5d20be98 | ||
|
|
d3c89d87aa | ||
|
|
d221a06522 | ||
|
|
6cf99718ee | ||
|
|
be1070de51 | ||
|
|
65c0e09179 | ||
|
|
5fba6187ce | ||
|
|
aca70b8398 | ||
|
|
7e6861a928 | ||
|
|
cc5ec712e0 | ||
|
|
a654ae554a | ||
|
|
9cb51f6a73 | ||
|
|
fb5f609cef | ||
|
|
a1d3adda9c | ||
|
|
c2fb62dc88 | ||
|
|
13bfcebd04 | ||
|
|
8d805ecbd8 | ||
|
|
b4ae0bde18 | ||
|
|
b66a3400ae | ||
|
|
30a8a5752c | ||
|
|
d487ce3601 | ||
|
|
575f652760 | ||
|
|
000a987a48 | ||
|
|
e91891a453 | ||
|
|
c301db10c4 | ||
|
|
5618ad12ea | ||
|
|
4e2183d9a3 | ||
|
|
8b9a7ed541 | ||
|
|
7a7c611267 | ||
|
|
0c063c23fb | ||
|
|
41291b3686 | ||
|
|
bb3e245b3c | ||
|
|
60fecd18b7 | ||
|
|
767f96802e | ||
|
|
6a05363e2a | ||
|
|
e99e72ee71 | ||
|
|
f4ba219000 | ||
|
|
beab219a90 | ||
|
|
7efe38dd04 | ||
|
|
9ea2750cbf | ||
|
|
75d5c385b2 | ||
|
|
2f1ede6030 | ||
|
|
0b814abf41 | ||
|
|
9385bc8acd | ||
|
|
425b4b65f9 | ||
|
|
5a7a4ed7e0 | ||
|
|
892f4e354b | ||
|
|
7080f63ebe | ||
|
|
b7d58bbf32 | ||
|
|
c80839b338 | ||
|
|
b139921691 | ||
|
|
7757814766 | ||
|
|
d6652dbe13 | ||
|
|
fe0a273aad | ||
|
|
9c6b524aed | ||
|
|
ed3acb931c | ||
|
|
6ad2f83ea6 | ||
|
|
2901b5293a | ||
|
|
d759a9fc83 | ||
|
|
7b77a37f0f | ||
|
|
f54c9ae195 | ||
|
|
5c20a98468 | ||
|
|
04ca7ae981 | ||
|
|
5c73c7a4bd | ||
|
|
80a9824a4e | ||
|
|
1a3c8e277f | ||
|
|
ae4408c6b2 | ||
|
|
1681ae4e60 | ||
|
|
d49d078264 | ||
|
|
869253cd83 | ||
|
|
ce30bbbb32 | ||
|
|
60a0c50578 | ||
|
|
f8ae4d62ab | ||
|
|
b5596f55f3 | ||
|
|
6208d66864 | ||
|
|
732322dc84 | ||
|
|
fd97284a85 | ||
|
|
6fa6dd76a0 | ||
|
|
c90a11d2b4 | ||
|
|
6bf8e191cb | ||
|
|
b45dcc7190 | ||
|
|
6838979780 | ||
|
|
54610fe0af | ||
|
|
6154aaebb2 | ||
|
|
6000356739 | ||
|
|
26330cf04c | ||
|
|
22d4023263 | ||
|
|
7b21cd98ff | ||
|
|
4c0f80cfe1 | ||
|
|
8c76986239 | ||
|
|
e848fb30b9 | ||
|
|
11fb60469a | ||
|
|
f1de436ca7 | ||
|
|
e781bd03be | ||
|
|
e4aa28b127 | ||
|
|
2595a134f8 | ||
|
|
b892911400 | ||
|
|
41f5701722 | ||
|
|
928784ba48 | ||
|
|
44d9e47e1c | ||
|
|
1ce5743bc1 | ||
|
|
04d3eb2dbc | ||
|
|
c05fdd3be1 | ||
|
|
86fe69d7fb | ||
|
|
9713bae498 | ||
|
|
dc130f2526 | ||
|
|
0ef053a209 | ||
|
|
12e5c3d26f | ||
|
|
92bf8b083d | ||
|
|
d9eb307d71 | ||
|
|
dfed9a393b | ||
|
|
5e5da9dbd1 | ||
|
|
c21d227132 | ||
|
|
5db0836d41 | ||
|
|
c0d3dab190 | ||
|
|
b4ac46fce1 | ||
|
|
b432892d35 | ||
|
|
f355a4a96b | ||
|
|
b52c938888 |
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / Agent — ask when an agent is dispatched without an explicit `model`.
|
||||
#
|
||||
# The kickoff prompt (docs/handoffs/chicorytv-issue-queue.md) says to route by capability: cheap/fast
|
||||
# for bounded recon, mid tier for a mechanical slice against a documented contract, orchestrator tier
|
||||
# for judgment-heavy work. That rule lived only in prose, and on 2026-07-25 an orchestrator dispatched
|
||||
# two implementers with `model` omitted — both silently inherited the Opus orchestrator tier. Nothing
|
||||
# in the session report revealed it; the operator had to ask.
|
||||
#
|
||||
# WHY a hook: omitting `model` is the SILENT path. Every other constraint in that kickoff has a hook,
|
||||
# a CI job or a script behind it, and those were all followed in the same session — the one rule with
|
||||
# no forcing function was the one that got defaulted. A check that runs beats a rule you must remember
|
||||
# (the same reasoning as pretooluse-bom-guard.sh).
|
||||
#
|
||||
# SCOPE — gate EVERY dispatch that names no model, not just implementer-looking ones. The first cut
|
||||
# tried to be clever: it fired only when the prompt text matched implementer signals (`git commit`,
|
||||
# `worktree`, `fixes #`…). Review of that version (#583) confirmed the heuristic both over- and
|
||||
# under-fired — a read-only recon brief mentioning "worktree" nagged, while "author the change and
|
||||
# open a PR", "land this on the branch" and "make the changes and commit them" all sailed through
|
||||
# silently, i.e. it missed the exact case it existed to catch. Prompt prose is not a reliable signal
|
||||
# for authority, and a gate with an unreliable catch rate is worse than an honest one.
|
||||
#
|
||||
# Two further reasons the broad form is correct here:
|
||||
# - The HARD CONSTRAINT itself says "every dispatched agent". A narrower hook contradicted the rule
|
||||
# it was built to enforce.
|
||||
# - Routing matters MOST for the cheap cases. The old exemption list ("read-only, so routing barely
|
||||
# matters") had it backwards: bounded recon is precisely what should be explicitly routed DOWN to
|
||||
# a fast tier, and that review also showed the premise was false — Explore, Plan and
|
||||
# claude-code-guide all carry Bash, so none of them provably "cannot commit".
|
||||
#
|
||||
# The prompt costs nothing to avoid: name a tier and this never fires. That is the habit being built.
|
||||
#
|
||||
# Exempt: `fork` only — a fork ALWAYS inherits the parent model and the tool IGNORES a `model`
|
||||
# override, so asking would demand something unachievable.
|
||||
#
|
||||
# "ask", never "deny": routing is a judgment call with no derivable right answer, unlike the
|
||||
# merge-consent gate (H6/H10) which derives a verifiable state. This gate exists to make an invisible
|
||||
# default visible, not to impose a tier.
|
||||
#
|
||||
# Fail-open by design: any parse trouble -> allow (exit 0, no output).
|
||||
set -uo pipefail
|
||||
|
||||
input=$(cat)
|
||||
|
||||
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null || true)
|
||||
[ "$tool" = "Agent" ] || exit 0
|
||||
|
||||
# An explicit choice was made — nothing to surface. This is the path to prefer.
|
||||
model=$(printf '%s' "$input" | jq -r '.tool_input.model // ""' 2>/dev/null || true)
|
||||
[ -z "$model" ] || exit 0
|
||||
|
||||
subagent=$(printf '%s' "$input" | jq -r '.tool_input.subagent_type // ""' 2>/dev/null || true)
|
||||
|
||||
# A fork's model is fixed to the parent's by the tool; a prompt here could not be acted on.
|
||||
[ "$subagent" = "fork" ] && exit 0
|
||||
|
||||
label="${subagent:-general-purpose}"
|
||||
reason="Dispatching an agent (subagent_type: ${label}) with no explicit \`model\`.
|
||||
|
||||
It will silently inherit this session's model — which may be right, but it is a default, not a choice.
|
||||
Name the tier (and say so in the dispatch message), per the kickoff routing rule
|
||||
\`process.per-agent-model-routing\`:
|
||||
|
||||
- bounded recon / inventory / log triage -> cheapest fast tier (haiku)
|
||||
- mechanical slice against a documented contract -> mid tier (sonnet)
|
||||
- judgment-heavy: design, compiler/parser, security,
|
||||
migrations, review arbitration -> orchestrator tier (opus)
|
||||
|
||||
Independent review should also prefer a DIFFERENT model family than the implementer — a cold
|
||||
same-family review is worth less than a cross-family one.
|
||||
|
||||
Pass \`model\` on the Agent call and this never fires. Approve as-is only if inheriting the
|
||||
orchestrator tier is the deliberate call."
|
||||
|
||||
jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'
|
||||
exit 0
|
||||
@@ -39,6 +39,11 @@
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-agent-ram.sh\"",
|
||||
"timeout": 10
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-agent-model.sh\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: closing-an-issue
|
||||
description: The ersatztv task-completion protocol — the mandatory steps and the `## Closing record` comment template for closing a Gitea issue. Use when finishing a task that closes an issue, or when writing a closing comment. The `/done` command runs this automatically.
|
||||
---
|
||||
|
||||
# Task Completion Protocol
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done.
|
||||
Use `/done <issue>` to run through this automatically.
|
||||
|
||||
Merge consent is a separate, hook-enforced concern — see the `## Done-when` convention in the
|
||||
root `CLAUDE.md`, which stays always-loaded.
|
||||
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
## `## Closing record` template
|
||||
|
||||
Step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for
|
||||
retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval
|
||||
contract this feeds.
|
||||
|
||||
```markdown
|
||||
## Closing record
|
||||
**Outcome:** <what shipped / what didn't; PR link>
|
||||
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
|
||||
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
|
||||
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
|
||||
**Verification:** <tests run, live-E2E, CI status>
|
||||
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
|
||||
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
|
||||
```
|
||||
@@ -5,19 +5,55 @@ description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Je
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
|
||||
Web UI: internal only (`http://localhost:8409` via SSH)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
|
||||
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
|
||||
Host: **jazz (192.168.1.29)**. Prod container `ersatztv` port **8409**; test `ersatztv-test` port
|
||||
**8410** (tracks `:latest` via Komodo auto-update, daily 03:00 — a same-day validation needs the
|
||||
manual pull below).
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` (owned by root — use `sudo sqlite3`)
|
||||
Image: **our fork**, `192.168.1.95:3000/timothy/ersatztv` (`:prod` / `:latest`). Upstream
|
||||
`ghcr.io/ersatztv/ersatztv` was archived at v26.3.0 and is NOT what runs here.
|
||||
|
||||
## Architecture
|
||||
|
||||
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
|
||||
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
|
||||
- **POST endpoints**: library scan, playout reset, show scan
|
||||
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
|
||||
**This section described upstream v26.3.0 and was wrong for the fork — corrected 2026-07-21.**
|
||||
|
||||
## REST API
|
||||
- The **Blazor UI is gone** (#91 phase b). The only UI is the ChicoryTV React SPA at `/app`; legacy
|
||||
routes 302 there.
|
||||
- There **is** a full versioned REST API under **`/api/v1`**, write paths included — channels,
|
||||
collections, schedules, playouts and media sources have CRUD. **Do not hand-edit SQLite for
|
||||
something the API can do.** The DB-scripting recipes below survive only for gaps with no endpoint.
|
||||
- Controllers stay thin and delegate to MediatR handlers; the SPA talks to `/api/v1` only.
|
||||
- Authoritative endpoint list: `docs/endpoint-index.md` (generated) + `docs/api-conventions.md`.
|
||||
Prefer those over any list in this file — a hand-maintained copy drifts.
|
||||
|
||||
## REST API access (auth-gated — read before curling)
|
||||
|
||||
Calls need **`X-Api-Key`** (machine clients) or a browser session. An unauthenticated call returns a
|
||||
401 JSON body that is easy to mistake for real data — see the silent-401 trap in Gotchas.
|
||||
|
||||
The key file is **root-owned `0600`**, so `cat` as `timothy` fails *silently* and yields an empty
|
||||
header. Read it with `sudo`, inline, so the value is never printed:
|
||||
|
||||
```bash
|
||||
# prod (8409); test is identical with .../ersatztv-test/api.key and port 8410
|
||||
ssh timothy@192.168.1.29 'K=$(sudo -n cat /home/timothy/downloadswarm/ersatztv/api.key); \
|
||||
curl -s -H "X-Api-Key: $K" http://localhost:8409/api/v1/channels'
|
||||
```
|
||||
|
||||
Settings live under `/api/v1/settings/*` — `settings/ffmpeg` (`workAheadSegmenterLimit`,
|
||||
`qsvExtraHardwareFrames`) and `settings/logging` (`streamingMinimumLogLevel`). Note the order: it is
|
||||
`settings/ffmpeg`, **not** `ffmpeg/settings`.
|
||||
|
||||
Refresh test to the newest `:latest` without waiting for 03:00 — scope it to the service, since a
|
||||
bare `up -d` would recreate everything else in the compose project:
|
||||
|
||||
```bash
|
||||
D=/etc/komodo/stacks/ersatztv/docker/jazz/stacks/ersatztv
|
||||
docker compose -f $D/compose.yaml pull ersatztv-test
|
||||
docker compose -f $D/compose.yaml up -d --no-deps ersatztv-test
|
||||
```
|
||||
|
||||
The unversioned `/api/*` endpoints below predate the `/api/v1` surface — verify one against
|
||||
`docs/endpoint-index.md` before relying on it.
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
@@ -148,13 +184,25 @@ After creating: `POST /api/channels/{number}/playout/reset`
|
||||
## Gotchas
|
||||
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- **The api.key file is root-owned too, and an unsudo'd read fails SILENTLY.** `cat` returns nothing,
|
||||
the header goes out empty, and the 401 body parses as a dict — so a naive script reports "0
|
||||
channels" rather than an auth error. If a query returns a suspiciously empty result, check auth
|
||||
before believing it. (Cost a wrong reading on 2026-07-21.)
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- No REST API for channel/collection/schedule CRUD — DB scripting only
|
||||
- ~~No REST API for channel/collection/schedule CRUD~~ — **false since the fork's `/api/v1`**; use the
|
||||
API, not DB scripting, wherever an endpoint exists
|
||||
- **A container's OCI labels lie about what is running** — they are inherited from the linuxserver
|
||||
base image (they claimed `2026-06-27` on an image built minutes earlier). To prove which build is
|
||||
live, compare `docker inspect <c> --format '{{.Image}}'` to the registry's `Docker-Content-Digest`
|
||||
for that tag
|
||||
- **Container log lines carry a LOCAL-time bracket (`[18:48:13 DBG]`) while `docker logs -t` emits
|
||||
UTC**, so `--since` windows silently mis-slice. For before/after measurements capture by line
|
||||
offset instead (`wc -l` before, `tail -n +N` after)
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
|
||||
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
|
||||
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
|
||||
- `/api/health` predates the Blazor removal; verify the API with an authenticated `/api/v1/channels` instead
|
||||
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
|
||||
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
|
||||
@@ -97,7 +97,22 @@ Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/Virt
|
||||
|
||||
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
|
||||
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
|
||||
- Music videos are typed as "Movie" in Jellyfin
|
||||
- **Music videos are typed `MusicVideo`, NOT `Movie`** (corrected 2026-07-21, ersatztv#177). The old
|
||||
"typed as Movie" note described a deliberate DB reclassification workaround that existed only because
|
||||
ErsatzTV could not consume `MusicVideo` items — ersatztv#42 shipped that sync, so the workaround's
|
||||
premise is gone. Verified live: the `Music Videos` library (`/data/music`, collection type
|
||||
`musicvideos`) holds 1437 items typed `MusicVideo` and **zero** typed `Movie`. Query with
|
||||
`includeItemTypes=MusicVideo`. (Reclassification to `Movie` may still apply to concert/standup content
|
||||
in the `movies`/`mixed` libraries — that is a different set; see the server-management jellyfin skill.)
|
||||
- **`Album` is not an `ItemFields` value.** It is a plain `BaseItemDto` property serialized whenever set,
|
||||
so it comes back regardless of the `fields=` query param — do NOT add it to `fields` (verified: 111 of
|
||||
1437 music videos returned `Album` with `fields=Path` alone). Contrast `Genres`/`People`/`Chapters`,
|
||||
which ARE `ItemFields` and must be requested. Check the enum before extending `fields`.
|
||||
- **`IndexNumber` is the track number; `ParentIndexNumber` is the disc/season axis.** Frequency misleads
|
||||
here — on the live music video library `ParentIndexNumber` is populated on 66 items vs 4 for
|
||||
`IndexNumber`, but where both exist `ParentIndexNumber` is `1` while `IndexNumber` holds the real
|
||||
ordinal, and where only `ParentIndexNumber` exists it is a collection grouping tracking the album
|
||||
(`Glastonbury: 2022` -> 230). `AlbumId` is always null on these items.
|
||||
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
|
||||
- Items return 404 on stream if source volume is unmounted
|
||||
- Jellyfin preserves item IDs across restarts unless files are renamed
|
||||
|
||||
@@ -6,6 +6,10 @@ name: Build ErsatzTV Image
|
||||
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
|
||||
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
|
||||
#
|
||||
# The PR-only git-diff gates (ci-image-pin, docs-reminder, decisions-guard) live in the sibling
|
||||
# .gitea/workflows/pr-checks.yml (`on: pull_request`). They were split out of this file so they
|
||||
# are not dispatched-and-killed on a tag/main push (ersatztv#535 — see that file's header).
|
||||
#
|
||||
# Runner + registry provisioned in server-management#172. The Gitea registry is
|
||||
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
|
||||
# inherit the host daemon's insecure-registries setting).
|
||||
@@ -25,7 +29,7 @@ name: Build ErsatzTV Image
|
||||
# cannot read the workflow `env` context. **Bump all five together**; see docs/ci-cd.md ->
|
||||
# "CI toolchain image" for the two-step procedure.
|
||||
#
|
||||
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
# CI image pin: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
#
|
||||
# DOCS-ONLY SKIP (ersatztv#416): a change that touches only docs/** or *.md has nothing for the
|
||||
# heavy jobs to validate. `test`, `migrations`, `functional-e2e` and `build` each run
|
||||
@@ -103,7 +107,7 @@ jobs:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -258,7 +262,7 @@ jobs:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -405,7 +409,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
|
||||
functional-e2e:
|
||||
name: Functional E2E (curl contracts)
|
||||
name: Functional E2E (curl + UI contracts)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
|
||||
# flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
|
||||
@@ -417,7 +421,7 @@ jobs:
|
||||
# v* tag builds.
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -494,6 +498,27 @@ jobs:
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
|
||||
|
||||
# ersatztv#445: the UI-interactive flows the curl harness structurally CANNOT express —
|
||||
# client-side form validation, AuthGate's rendered states, the session cookie authenticating the
|
||||
# SPA's own /api XHRs, and sign-out through the UserMenu.
|
||||
#
|
||||
# Why in THIS job rather than its own: the dominant cost here is `npm ci` + the Release build,
|
||||
# which are already done above. A separate job would duplicate both to add ~5s of browser work.
|
||||
# The browser itself is baked into the toolchain image (docker/ci/Dockerfile —
|
||||
# chromium-headless-shell), so this step installs nothing.
|
||||
#
|
||||
# It boots its OWN fresh instance on a DIFFERENT port: the first spec asserts the one-shot Setup
|
||||
# gate, which the curl harness's auth section has already claimed on its own config dir, and a
|
||||
# separate port keeps this independent of the previous step's teardown timing.
|
||||
- name: Run UI-E2E Playwright flows (headless)
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8410
|
||||
# e2e-ui.sh owns the whole lifecycle: fresh config dir, boot, run specs, always kill the
|
||||
# server. Its exit status is Playwright's.
|
||||
scripts/e2e-ui.sh
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# Moved back off `small` (server-management#639). This is the one HEAVY job that
|
||||
@@ -642,119 +667,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
|
||||
# cache-save issues seen on the relocated runner (server-management#570).
|
||||
docs-reminder:
|
||||
name: Docs update reminder
|
||||
runs-on: small # seconds-long git diff; keep it off the build runners
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Warn when a screen/route change skips the parity doc
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
|
||||
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
||||
screen_or_route=no
|
||||
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
||||
screen_or_route=yes
|
||||
fi
|
||||
parity=no
|
||||
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
|
||||
parity=yes
|
||||
fi
|
||||
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
|
||||
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
|
||||
else
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
||||
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
||||
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
|
||||
# vanishing from the active set without an archive copy) and that the generated active catalog
|
||||
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
|
||||
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
|
||||
decisions-guard:
|
||||
name: decisions lifecycle
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Validate decision lifecycle
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
||||
- name: Active catalog in sync
|
||||
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
||||
- name: Kickoff guard
|
||||
run: bash scripts/check-kickoff-guard.sh
|
||||
|
||||
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
|
||||
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
|
||||
# surface (ErsatzTV/Controllers/Api/** or ErsatzTV.Core/Api/**), the generated
|
||||
@@ -784,7 +696,7 @@ jobs:
|
||||
# 48 GiB at capacity 4 + a bumblebee overflow slot), which fixes the queue at the source.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
@@ -879,7 +791,7 @@ jobs:
|
||||
# move to a lighter lane is a server-management capacity call (#604).
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:4263cf7
|
||||
image: 192.168.1.95:3000/timothy/ersatztv-ci:32747a0
|
||||
credentials:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
name: PR Gates
|
||||
|
||||
# Fast, git-only PR gates split out of docker-build.yml into a dedicated `on: pull_request`
|
||||
# workflow (ersatztv#535) so they are NEVER created on a tag/main push.
|
||||
#
|
||||
# WHY THIS FILE EXISTS. These three checks are pure `checkout + git diff` gates: they carry no
|
||||
# `container:`, run on the `small` lane (git-only, 1 GiB; server-management#639), and are PR-only.
|
||||
# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags —
|
||||
# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because
|
||||
# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the
|
||||
# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase
|
||||
# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened
|
||||
# the tag's overall commit status even though the release built, scanned, and deployed fine
|
||||
# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical
|
||||
# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens
|
||||
# in the dispatch window before any step or `if:`-skip runs.
|
||||
#
|
||||
# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow
|
||||
# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole
|
||||
# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders
|
||||
# (they also encode "these steps need a PR base_ref"; harmless given the trigger).
|
||||
#
|
||||
# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of
|
||||
# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/
|
||||
# api-docs/format) that remain there. None of these three are required checks — branch protection
|
||||
# requires only `Build & test (.NET)` and `EF migration integrity` — so relocating them (which
|
||||
# changes their status-context prefix from "Build ErsatzTV Image / …" to "PR Gates / …") does not
|
||||
# affect merges. See docs/ci-cd.md -> "PR gates workflow".
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but
|
||||
# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel.
|
||||
concurrency:
|
||||
group: ersatztv-pr-gates-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml 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[@]}" -eq 0 ]; then
|
||||
echo "::error::No ersatztv-ci pin found in docker-build.yml at all. Every container: job must pin ersatztv-ci:<7-char-sha>; if the grep pattern stopped matching, fix it here too (docs/ci-cd.md -> 'CI toolchain image')."
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
# LENGTH is a separate invariant from CORRECTNESS, and only this check covers it
|
||||
# (ersatztv#594). The resolve + staleness checks below compare RESOLVED shas, so a
|
||||
# 8/9/10-char abbreviation of the right commit sails through them green — while
|
||||
# matching NO tag in the registry, because ci-image.yml tags with
|
||||
# `git rev-parse --short HEAD` under `fetch-depth: 1`, which always yields exactly 7.
|
||||
# The failure would otherwise surface far downstream as all five `container:` jobs
|
||||
# dying at image-pull with `manifest unknown`, which reads like a registry outage.
|
||||
# This is an easy mistake to make: the natural local command prints 8 chars.
|
||||
#
|
||||
# Deliberately a literal 7, not a derived `git rev-parse --short=7`: in this full
|
||||
# clone git may widen an ambiguous abbreviation past 7, which would demand a pin
|
||||
# ci-image.yml can never publish — the exact clone-depth asymmetry noted above.
|
||||
# `${expected:0:7}` is plain string truncation, so it is safe to suggest.
|
||||
#
|
||||
# ESCAPE HATCH, if you are ever stuck: this makes 7 mandatory, so if `${expected:0:7}` ever
|
||||
# became an AMBIGUOUS prefix (two objects sharing it), the resolve check below would fail
|
||||
# and a longer pin — previously the workaround — is now rejected here first. There is no
|
||||
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
|
||||
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
|
||||
# empirical property of today's shallow clone, not an enforced invariant. Making the
|
||||
# publisher emit `--short=7` is tracked as ersatztv#597. It is not blocked, just out of
|
||||
# scope here: editing ci-image.yml re-points `expected` (above) at that commit, so it needs
|
||||
# the branch's own publish-then-pin two-step (docs/ci-cd.md -> 'CI toolchain image') —
|
||||
# ci-image.yml's push trigger has no branches: filter, so a feature branch does publish.
|
||||
if [ "${#pins[0]}" -ne 7 ]; then
|
||||
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
|
||||
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
|
||||
# cache-save issues seen on the relocated runner (server-management#570).
|
||||
docs-reminder:
|
||||
name: Docs update reminder
|
||||
runs-on: small # seconds-long git diff; keep it off the build runners
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Warn when a screen/route change skips the parity doc
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
|
||||
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
||||
screen_or_route=no
|
||||
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
||||
screen_or_route=yes
|
||||
fi
|
||||
parity=no
|
||||
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
|
||||
parity=yes
|
||||
fi
|
||||
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
|
||||
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
|
||||
else
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
||||
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
||||
# supersedes/superseded-by links, no rationale-prose rewrite without [decisions-edit], no record
|
||||
# vanishing from the active set without an archive copy) and that the generated active catalog
|
||||
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
|
||||
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
|
||||
decisions-guard:
|
||||
name: decisions lifecycle
|
||||
runs-on: small
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Validate decision lifecycle
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=200 origin "$base_ref" || true
|
||||
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
||||
- name: Active catalog in sync
|
||||
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
||||
- name: Kickoff guard
|
||||
run: bash scripts/check-kickoff-guard.sh
|
||||
+17
-1
@@ -46,7 +46,19 @@ msbuild.wrn
|
||||
.vs/
|
||||
|
||||
*.sqlite3*
|
||||
core
|
||||
# Core dumps. MUST stay anchored/qualified (ersatztv#485): a bare `core` matches any path
|
||||
# component named `core`, and on a case-insensitive filesystem (macOS default) that includes
|
||||
# every `*/Core/` source directory — silently excluding NEW files under e.g.
|
||||
# ErsatzTV.Scanner/Core/ from `git add -A`. Tracked files are unaffected, so the symptom is a
|
||||
# clean local build and a CI checkout that fails to compile.
|
||||
#
|
||||
# Both patterns are anchored to the repo root ON PURPOSE — an unanchored `core.[0-9]*` would
|
||||
# re-introduce exactly the silent-exclusion class this fixes. Tradeoff, accepted: a dump written
|
||||
# into a SUBdirectory is no longer ignored (the old bare `core` did catch those). In practice the
|
||||
# processes that could drop one, run from the repo root or from `bin/` — and `[Bb]in/` already covers
|
||||
# the latter. An un-ignored dump is visible noise; a wrongly-ignored source file is not.
|
||||
/core
|
||||
/core.[0-9]*
|
||||
|
||||
scripts/generate-api-sdk/swagger.json
|
||||
scripts/download-test-content.sh
|
||||
@@ -61,6 +73,10 @@ web/node_modules
|
||||
# E2E / screenshot scratch (from Playwright/live-E2E runs) — never committed
|
||||
/*.png
|
||||
.playwright-mcp/
|
||||
# UI-E2E run artifacts: traces/screenshots Playwright writes on failure (outputDir in
|
||||
# web/playwright.config.ts), plus the report dir it would use if a reporter is ever added (#445).
|
||||
web/e2e/.output/
|
||||
web/playwright-report/
|
||||
|
||||
# Per-session worktree-ownership marker (H7, ersatztv#303) — local, never committed
|
||||
.claude-worktree-owner
|
||||
|
||||
@@ -4,25 +4,9 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10
|
||||
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root `/` and every legacy route now 302 to `/app`, either via an explicit redirect in `ErsatzTV/LegacyUiRedirects.cs` or the Startup catch-all fallback (any unmatched non-`/api`/`/artwork`/`/docs`/`/openapi` path → `/app`). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE.
|
||||
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
|
||||
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
|
||||
- **Functional C#**: Language Ext (Option, Either monads throughout)
|
||||
|
||||
### Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, DI setup |
|
||||
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
|
||||
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
|
||||
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
|
||||
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
|
||||
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
|
||||
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
|
||||
| `ErsatzTV.Scanner/` | Media library scanning |
|
||||
|
||||
### Key Files
|
||||
|
||||
@@ -43,12 +27,6 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build ErsatzTV.sln
|
||||
|
||||
# Run locally (needs FFmpeg in PATH)
|
||||
dotnet run --project ErsatzTV
|
||||
|
||||
# Docker build
|
||||
docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
```
|
||||
@@ -88,26 +66,9 @@ Every task that closes a Gitea issue MUST complete ALL of these before it is con
|
||||
|
||||
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
|
||||
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
**`## Closing record` template** (step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval contract this feeds):
|
||||
|
||||
```markdown
|
||||
## Closing record
|
||||
**Outcome:** <what shipped / what didn't; PR link>
|
||||
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
|
||||
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
|
||||
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
|
||||
**Verification:** <tests run, live-E2E, CI status>
|
||||
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
|
||||
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
|
||||
```
|
||||
**The 7 mandatory completion steps and the `## Closing record` comment template** live in the
|
||||
`closing-an-issue` skill (`.claude/skills/closing-an-issue/SKILL.md`) — invoke it (or `/done`)
|
||||
when finishing a task that closes an issue.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
@@ -43,7 +43,8 @@ public record CreateChannelFromLineupAdvancedOptions(
|
||||
ChannelIdleBehavior? IdleBehavior = null,
|
||||
bool? ShuffleScheduleItems = null,
|
||||
bool? RandomStartPoint = null,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior = null,
|
||||
IReadOnlyList<CreateChannelFromLineupClearField> Clear = null);
|
||||
|
||||
public record CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType MediaType,
|
||||
|
||||
@@ -190,11 +190,21 @@ public class CreateChannelFromLineupHandler(
|
||||
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
|
||||
}
|
||||
|
||||
// "clear to none" (#135): a field named in advanced.Clear is forced to none even when the
|
||||
// template sets one; both setting and clearing the same field is contradictory.
|
||||
Either<BaseError, Unit> clearValidation = ValidateClear(advanced);
|
||||
foreach (BaseError error in clearValidation.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
ResolvedClearableOptions resolved = ResolveClearable(advanced, template);
|
||||
|
||||
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
|
||||
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
|
||||
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
|
||||
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
|
||||
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
|
||||
int? fallbackFillerId = resolved.FallbackFillerId;
|
||||
int? preRollFillerId = resolved.PreRollFillerId;
|
||||
int? midRollFillerId = resolved.MidRollFillerId;
|
||||
int? postRollFillerId = resolved.PostRollFillerId;
|
||||
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
|
||||
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
|
||||
|
||||
@@ -207,8 +217,8 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
|
||||
dbContext,
|
||||
advanced,
|
||||
template,
|
||||
ffmpegProfileId,
|
||||
resolved,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in referenceValidation.LeftToSeq())
|
||||
{
|
||||
@@ -272,6 +282,7 @@ public class CreateChannelFromLineupHandler(
|
||||
request,
|
||||
template,
|
||||
advanced,
|
||||
resolved,
|
||||
name,
|
||||
number,
|
||||
group,
|
||||
@@ -291,6 +302,7 @@ public class CreateChannelFromLineupHandler(
|
||||
playbackOrder,
|
||||
advanced,
|
||||
template,
|
||||
resolved,
|
||||
fallbackFillerId,
|
||||
preRollFillerId,
|
||||
midRollFillerId,
|
||||
@@ -383,6 +395,7 @@ public class CreateChannelFromLineupHandler(
|
||||
CreateChannelFromLineup request,
|
||||
ChannelTemplate template,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ResolvedClearableOptions resolved,
|
||||
string name,
|
||||
string number,
|
||||
string group,
|
||||
@@ -421,16 +434,14 @@ public class CreateChannelFromLineupHandler(
|
||||
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
|
||||
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
|
||||
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
|
||||
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
|
||||
WatermarkId = resolved.WatermarkId,
|
||||
FallbackFillerId = fallbackFillerId,
|
||||
Artwork = artwork,
|
||||
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
|
||||
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
|
||||
PreferredAudioLanguageCode =
|
||||
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
|
||||
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
|
||||
PreferredSubtitleLanguageCode =
|
||||
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
|
||||
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle = resolved.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
|
||||
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate =
|
||||
@@ -439,7 +450,8 @@ public class CreateChannelFromLineupHandler(
|
||||
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
|
||||
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
|
||||
IsEnabled = request.IsEnabled,
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg,
|
||||
Origin = ChannelOrigin.AutoTuned
|
||||
};
|
||||
}
|
||||
|
||||
@@ -462,6 +474,7 @@ public class CreateChannelFromLineupHandler(
|
||||
PlaybackOrder playbackOrder,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template,
|
||||
ResolvedClearableOptions resolved,
|
||||
int? fallbackFillerId,
|
||||
int? preRollFillerId,
|
||||
int? midRollFillerId,
|
||||
@@ -478,11 +491,9 @@ public class CreateChannelFromLineupHandler(
|
||||
MidRollFillerId = midRollFillerId,
|
||||
PostRollFillerId = postRollFillerId,
|
||||
FallbackFillerId = fallbackFillerId,
|
||||
PreferredAudioLanguageCode =
|
||||
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
|
||||
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
|
||||
PreferredSubtitleLanguageCode =
|
||||
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
|
||||
PreferredAudioLanguageCode = resolved.PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle = resolved.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = resolved.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
|
||||
};
|
||||
|
||||
@@ -526,20 +537,21 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
private static async Task<Either<BaseError, Unit>> ValidateReferences(
|
||||
TvContext dbContext,
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template,
|
||||
int ffmpegProfileId,
|
||||
ResolvedClearableOptions resolved,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
|
||||
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
|
||||
{
|
||||
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
|
||||
}
|
||||
|
||||
// Validate the post-clear effective ids: a cleared reference resolves to null and skips the
|
||||
// existence check (there is nothing to point at).
|
||||
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
|
||||
dbContext,
|
||||
advanced.WatermarkId ?? template.WatermarkId,
|
||||
advanced.FallbackFillerId ?? template.FallbackFillerId,
|
||||
resolved.WatermarkId,
|
||||
resolved.FallbackFillerId,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in channelReferences.LeftToSeq())
|
||||
{
|
||||
@@ -548,9 +560,9 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
|
||||
dbContext,
|
||||
advanced.PreRollFillerId ?? template.PreRollFillerId,
|
||||
advanced.MidRollFillerId ?? template.MidRollFillerId,
|
||||
advanced.PostRollFillerId ?? template.PostRollFillerId,
|
||||
resolved.PreRollFillerId,
|
||||
resolved.MidRollFillerId,
|
||||
resolved.PostRollFillerId,
|
||||
cancellationToken);
|
||||
foreach (BaseError error in itemFillers.LeftToSeq())
|
||||
{
|
||||
@@ -560,6 +572,80 @@ public class CreateChannelFromLineupHandler(
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// A field named in advanced.Clear must not also carry a set value: that request is contradictory.
|
||||
// A null/empty set value alongside a clear is fine (redundant, not conflicting). (#135)
|
||||
private static Either<BaseError, Unit> ValidateClear(CreateChannelFromLineupAdvancedOptions advanced)
|
||||
{
|
||||
if (advanced.Clear is null || advanced.Clear.Count == 0)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
var cleared = advanced.Clear.ToHashSet();
|
||||
|
||||
(CreateChannelFromLineupClearField Field, bool HasSetValue)[] checks =
|
||||
[
|
||||
(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId.HasValue),
|
||||
(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId.HasValue),
|
||||
(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId.HasValue),
|
||||
(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId.HasValue),
|
||||
(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId.HasValue),
|
||||
(CreateChannelFromLineupClearField.PreferredAudioLanguage,
|
||||
!string.IsNullOrEmpty(advanced.PreferredAudioLanguageCode)),
|
||||
(CreateChannelFromLineupClearField.PreferredAudioTitle,
|
||||
!string.IsNullOrEmpty(advanced.PreferredAudioTitle)),
|
||||
(CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
|
||||
!string.IsNullOrEmpty(advanced.PreferredSubtitleLanguageCode))
|
||||
];
|
||||
|
||||
foreach ((CreateChannelFromLineupClearField field, bool hasSetValue) in checks)
|
||||
{
|
||||
if (cleared.Contains(field) && hasSetValue)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Advanced option '{field}' cannot be both set and cleared in the same request");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// Compute the effective value of every clearable field once: cleared -> none, else the advanced
|
||||
// override coalesced with the template value (the historical omitted=inherit contract). (#135)
|
||||
private static ResolvedClearableOptions ResolveClearable(
|
||||
CreateChannelFromLineupAdvancedOptions advanced,
|
||||
ChannelTemplate template)
|
||||
{
|
||||
System.Collections.Generic.HashSet<CreateChannelFromLineupClearField> cleared = advanced.Clear is null
|
||||
? []
|
||||
: advanced.Clear.ToHashSet();
|
||||
|
||||
int? Id(CreateChannelFromLineupClearField field, int? adv, int? tmpl) =>
|
||||
cleared.Contains(field) ? null : adv ?? tmpl;
|
||||
|
||||
string Str(CreateChannelFromLineupClearField field, string adv, string tmpl) =>
|
||||
cleared.Contains(field) ? string.Empty : adv ?? tmpl ?? string.Empty;
|
||||
|
||||
return new ResolvedClearableOptions(
|
||||
Id(CreateChannelFromLineupClearField.Watermark, advanced.WatermarkId, template.WatermarkId),
|
||||
Id(CreateChannelFromLineupClearField.FallbackFiller, advanced.FallbackFillerId, template.FallbackFillerId),
|
||||
Id(CreateChannelFromLineupClearField.PreRollFiller, advanced.PreRollFillerId, template.PreRollFillerId),
|
||||
Id(CreateChannelFromLineupClearField.MidRollFiller, advanced.MidRollFillerId, template.MidRollFillerId),
|
||||
Id(CreateChannelFromLineupClearField.PostRollFiller, advanced.PostRollFillerId, template.PostRollFillerId),
|
||||
Str(
|
||||
CreateChannelFromLineupClearField.PreferredAudioLanguage,
|
||||
advanced.PreferredAudioLanguageCode,
|
||||
template.PreferredAudioLanguageCode),
|
||||
Str(
|
||||
CreateChannelFromLineupClearField.PreferredAudioTitle,
|
||||
advanced.PreferredAudioTitle,
|
||||
template.PreferredAudioTitle),
|
||||
Str(
|
||||
CreateChannelFromLineupClearField.PreferredSubtitleLanguage,
|
||||
advanced.PreferredSubtitleLanguageCode,
|
||||
template.PreferredSubtitleLanguageCode));
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
|
||||
TvContext dbContext,
|
||||
int? watermarkId,
|
||||
@@ -803,4 +889,16 @@ public class CreateChannelFromLineupHandler(
|
||||
Playlist Playlist,
|
||||
ProgramSchedule ProgramSchedule,
|
||||
Playout Playout);
|
||||
|
||||
// Effective values for the clearable advanced fields after applying advanced.Clear + template
|
||||
// coalescing (#135). Strings coalesce to string.Empty (never null); ids stay nullable.
|
||||
private sealed record ResolvedClearableOptions(
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
int? PreRollFillerId,
|
||||
int? MidRollFillerId,
|
||||
int? PostRollFillerId,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
string PreferredSubtitleLanguageCode);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,8 @@ public class CreateChannelHandler(
|
||||
TranscodeMode = request.TranscodeMode,
|
||||
IdleBehavior = request.IdleBehavior,
|
||||
IsEnabled = request.IsEnabled,
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg
|
||||
ShowInEpg = request.IsEnabled && request.ShowInEpg,
|
||||
Origin = ChannelOrigin.UserCreated
|
||||
};
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -32,4 +32,5 @@ public record UpdateChannel(
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
bool ShowInEpg,
|
||||
List<int> GraphicsElementIds) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -34,6 +34,7 @@ public class UpdateChannelHandler(
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.Include(c => c.Playouts)
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
|
||||
|
||||
return await maybeChannel.Match(
|
||||
@@ -173,6 +174,14 @@ public class UpdateChannelHandler(
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
|
||||
c.ChannelGraphicsElements ??= [];
|
||||
var desired = update.GraphicsElementIds?.Distinct().ToList() ?? [];
|
||||
c.ChannelGraphicsElements.RemoveAll(cge => !desired.Contains(cge.GraphicsElementId));
|
||||
foreach (int id in desired.Where(id => c.ChannelGraphicsElements.All(cge => cge.GraphicsElementId != id)))
|
||||
{
|
||||
c.ChannelGraphicsElements.Add(new ChannelGraphicsElement { ChannelId = c.Id, GraphicsElementId = id });
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
@@ -29,6 +29,106 @@ internal static class Mapper
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static ChannelHealthResponseModel GetHealth(
|
||||
Channel channel,
|
||||
int playoutCount,
|
||||
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
|
||||
{
|
||||
if (playoutCount == 0)
|
||||
{
|
||||
return new ChannelHealthResponseModel(
|
||||
ChannelHealthStatus.Problems,
|
||||
[ChannelFault.NoPlayout],
|
||||
0,
|
||||
0);
|
||||
}
|
||||
|
||||
var faults = new System.Collections.Generic.HashSet<string>();
|
||||
var brokenSourceItemCount = 0;
|
||||
var sawAssessable = false;
|
||||
|
||||
foreach ((Playout playout, ChannelPlayoutMode ownerMode) in ContributingPlayoutsWithOwnerMode(channel))
|
||||
{
|
||||
bool isOnDemand = ownerMode == ChannelPlayoutMode.OnDemand;
|
||||
|
||||
upcoming.TryGetValue(playout.Id, out PlayoutUpcoming u);
|
||||
brokenSourceItemCount += u.BrokenUpcoming;
|
||||
|
||||
bool built = playout.BuildStatus is not null && playout.BuildStatus.LastBuild != default;
|
||||
|
||||
// Presence signals — always live.
|
||||
if (built && playout.BuildStatus.Success == false)
|
||||
{
|
||||
faults.Add(ChannelFault.BuildFailed);
|
||||
}
|
||||
|
||||
if (u.BrokenUpcoming > 0)
|
||||
{
|
||||
faults.Add(ChannelFault.BrokenSource);
|
||||
}
|
||||
|
||||
// Absence signals — suppressed for on-demand (drains between tune-ins).
|
||||
if (!isOnDemand)
|
||||
{
|
||||
if (!built)
|
||||
{
|
||||
faults.Add(ChannelFault.NeverBuilt);
|
||||
}
|
||||
else if (u.TotalUpcoming == 0)
|
||||
{
|
||||
faults.Add(ChannelFault.EmptyUpcoming);
|
||||
}
|
||||
else
|
||||
{
|
||||
sawAssessable = true;
|
||||
}
|
||||
}
|
||||
else if (built && u.TotalUpcoming > 0)
|
||||
{
|
||||
sawAssessable = true;
|
||||
}
|
||||
}
|
||||
|
||||
string status = faults.Count > 0
|
||||
? ChannelHealthStatus.Problems
|
||||
: sawAssessable
|
||||
? ChannelHealthStatus.Healthy
|
||||
: ChannelHealthStatus.Unknown;
|
||||
|
||||
return new ChannelHealthResponseModel(
|
||||
status,
|
||||
faults.ToArray(),
|
||||
playoutCount,
|
||||
brokenSourceItemCount);
|
||||
}
|
||||
|
||||
internal static IEnumerable<Playout> ContributingPlayouts(Channel channel) =>
|
||||
ContributingPlayoutsWithOwnerMode(channel).Select(x => x.Playout);
|
||||
|
||||
// Mirror channels are forced Continuous (UpdateChannelHandler), but a mirror of an on-demand SOURCE relays
|
||||
// playouts that legitimately drain between tune-ins. Absence-signal suppression must key off the mode of the
|
||||
// channel that OWNS each playout, not the mirror's own (always-Continuous) mode — so pair each playout with
|
||||
// its owner's mode here, once, rather than re-deriving it at each call site.
|
||||
private static IEnumerable<(Playout Playout, ChannelPlayoutMode OwnerMode)> ContributingPlayoutsWithOwnerMode(
|
||||
Channel channel)
|
||||
{
|
||||
if (channel.Playouts is not null)
|
||||
{
|
||||
foreach (Playout p in channel.Playouts)
|
||||
{
|
||||
yield return (p, channel.PlayoutMode);
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts is not null)
|
||||
{
|
||||
foreach (Playout p in channel.MirrorSourceChannel.Playouts)
|
||||
{
|
||||
yield return (p, channel.MirrorSourceChannel.PlayoutMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
@@ -61,7 +161,10 @@ internal static class Mapper
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(Channel channel, int playoutCount)
|
||||
internal static ChannelDetailResponseModel ProjectToDetailResponseModel(
|
||||
Channel channel,
|
||||
int playoutCount,
|
||||
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming)
|
||||
{
|
||||
ArtworkContentTypeModel logo = GetLogo(channel);
|
||||
return new ChannelDetailResponseModel(
|
||||
@@ -93,10 +196,15 @@ internal static class Mapper
|
||||
channel.TranscodeMode,
|
||||
channel.IdleBehavior,
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
channel.ShowInEpg,
|
||||
channel.ChannelGraphicsElements?.Map(x => x.GraphicsElementId).ToArray() ?? [],
|
||||
GetHealth(channel, playoutCount, upcoming));
|
||||
}
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) =>
|
||||
internal static ChannelResponseModel ProjectToResponseModel(
|
||||
Channel channel,
|
||||
int playoutCount,
|
||||
IReadOnlyDictionary<int, PlayoutUpcoming> upcoming) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
@@ -110,7 +218,10 @@ internal static class Mapper
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg,
|
||||
playoutCount,
|
||||
GetLogoUrl(channel));
|
||||
GetLogoUrl(channel),
|
||||
GetPreview(channel.StreamingMode, channel.Number, channel.IsEnabled, playoutCount),
|
||||
channel.Origin,
|
||||
GetHealth(channel, playoutCount, upcoming));
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
@@ -174,4 +285,59 @@ internal static class Mapper
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(channel))
|
||||
};
|
||||
|
||||
#nullable enable
|
||||
internal static ChannelPreviewResponseModel GetPreview(
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
bool isEnabled,
|
||||
int playoutCount)
|
||||
{
|
||||
// Precedence among the two Unavailable causes (checked in this order; the first match wins):
|
||||
// 1. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
|
||||
// preview must not even try.
|
||||
// 2. no playout — the channel could theoretically play once scheduled, but a manifest
|
||||
// request against it blocks indefinitely today; catch it before that happens.
|
||||
//
|
||||
// IPTV JWT auth (ConditionalIptvAuthorizeFilter, active only when JWT:IssuerSigningKey is set) is no
|
||||
// longer an Unavailable cause: the SPA mints a short-lived token via GET /api/v1/auth/iptv-token and
|
||||
// appends it as ?access_token= to the manifest URL below (issue #552). The token is global and the
|
||||
// ManifestUrl is identical with or without JWT, so this projection is JWT-agnostic.
|
||||
if (!isEnabled)
|
||||
{
|
||||
return new ChannelPreviewResponseModel(
|
||||
ChannelPreviewAvailability.Unavailable,
|
||||
null,
|
||||
"Channel is disabled");
|
||||
}
|
||||
|
||||
if (playoutCount == 0)
|
||||
{
|
||||
return new ChannelPreviewResponseModel(
|
||||
ChannelPreviewAvailability.Unavailable,
|
||||
null,
|
||||
"Channel has no playout");
|
||||
}
|
||||
|
||||
return streamingMode switch
|
||||
{
|
||||
StreamingMode.HttpLiveStreamingSegmenter or StreamingMode.HttpLiveStreamingDirect =>
|
||||
new ChannelPreviewResponseModel(
|
||||
ChannelPreviewAvailability.Available,
|
||||
$"/iptv/channel/{channelNumber}.m3u8",
|
||||
null),
|
||||
|
||||
// A browser cannot play video/mp2t. Forcing ?mode=segmenter yields a playable stream,
|
||||
// but one that does not exercise the channel's configured Transport Stream pipeline —
|
||||
// the SPA labels this result accordingly.
|
||||
StreamingMode.TransportStream or StreamingMode.TransportStreamHybrid =>
|
||||
new ChannelPreviewResponseModel(
|
||||
ChannelPreviewAvailability.ForcedHlsOnly,
|
||||
$"/iptv/channel/{channelNumber}.m3u8?mode=segmenter",
|
||||
null),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(streamingMode))
|
||||
};
|
||||
}
|
||||
#nullable restore
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList();
|
||||
List<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten().ToList();
|
||||
var playoutIds = channels
|
||||
.SelectMany(c => ContributingPlayouts(c).Select(p => p.Id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Dictionary<int, PlayoutUpcoming> upcoming =
|
||||
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
|
||||
return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c), upcoming)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
@@ -7,9 +8,20 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class GetChannelByIdForApiHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelByIdForApi, Option<ChannelDetailResponseModel>>
|
||||
{
|
||||
public Task<Option<ChannelDetailResponseModel>> Handle(
|
||||
public async Task<Option<ChannelDetailResponseModel>> Handle(
|
||||
GetChannelByIdForApi request,
|
||||
CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel)));
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeChannel = await channelRepository.GetChannel(request.Id);
|
||||
|
||||
foreach (Channel channel in maybeChannel)
|
||||
{
|
||||
var playoutIds = ContributingPlayouts(channel).Select(p => p.Id).Distinct().ToList();
|
||||
Dictionary<int, PlayoutUpcoming> upcoming =
|
||||
await channelRepository.GetPlayoutUpcomingHealth(playoutIds, DateTime.UtcNow, cancellationToken);
|
||||
return ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel), upcoming);
|
||||
}
|
||||
|
||||
return Option<ChannelDetailResponseModel>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,10 +60,11 @@ public partial class GetChannelGuideHandler(
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
// The token value is HTTP-request-derived and interpolated raw into the pre-built XMLTV
|
||||
// cache fragments, so it must be XML-escaped like {RequestBase} above — a token containing
|
||||
// '&', '<', '>', or '"' would otherwise malform the whole guide. Opaque tokens are a no-op.
|
||||
accessTokenUri += $"&access_token={SecurityElement.Escape(request.AccessToken)}";
|
||||
// The token lands in a URL query value inside an XMLTV attribute, so it needs BOTH layers:
|
||||
// percent-encode first (#421 — a token with '&' would otherwise split the query and truncate
|
||||
// the token once the consumer URL-decodes the attribute; mirrors the M3U fix), then XML-escape
|
||||
// the result so it can't malform the guide (#376). Both are no-ops for an opaque base64url token.
|
||||
accessTokenUri += $"&access_token={SecurityElement.Escape(Uri.EscapeDataString(request.AccessToken))}";
|
||||
}
|
||||
|
||||
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Graphics;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Graphics.Mapper;
|
||||
@@ -18,10 +19,14 @@ public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> db
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return graphicsElements
|
||||
.Map(ProjectToViewModel)
|
||||
.OrderBy(e => e.Name == e.FileName)
|
||||
.ThenBy(e => e.Name)
|
||||
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
|
||||
.Select(e => new
|
||||
{
|
||||
Vm = ProjectToViewModel(e),
|
||||
BuiltIn = Path.GetFileName(e.Path) == GraphicsElementDefaults.OnNowNextFileName
|
||||
})
|
||||
.OrderBy(x => x.Vm.Name == x.Vm.FileName)
|
||||
.ThenBy(x => x.Vm.Name)
|
||||
.Select(x => new GraphicsElementResponseModel(x.Vm.Id, x.Vm.Name, x.BuiltIn))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
CancellationToken cancellationToken) =>
|
||||
PlaylistGroupMustExist(dbContext, request, cancellationToken)
|
||||
.BindT(PlaylistGroupMustNotBeSystem)
|
||||
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup));
|
||||
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup))
|
||||
.BindT(playlistGroup => NameMustBeUnique(dbContext, request, playlistGroup));
|
||||
|
||||
private static Task<Validation<BaseError, PlaylistGroup>> PlaylistGroupMustExist(
|
||||
TvContext dbContext,
|
||||
@@ -57,4 +58,23 @@ public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
private static Validation<BaseError, string> ValidateName(RenamePlaylistGroup request) =>
|
||||
request.NotEmpty(x => x.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||
|
||||
// Issue #458: PlaylistGroup.Name carries a global unique index, but CreatePlaylistGroupHandler
|
||||
// has no explicit duplicate guard (it relies on the DB constraint). Add one on rename so a
|
||||
// collision surfaces as a clean 422 rather than a raw DbUpdateException. Excludes the group
|
||||
// itself so a no-op rename to its own name still succeeds.
|
||||
private static async Task<Validation<BaseError, PlaylistGroup>> NameMustBeUnique(
|
||||
TvContext dbContext,
|
||||
RenamePlaylistGroup request,
|
||||
PlaylistGroup playlistGroup)
|
||||
{
|
||||
Option<PlaylistGroup> maybeExisting = await dbContext.PlaylistGroups
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(pg => pg.Id != request.PlaylistGroupId && pg.Name == request.Name)
|
||||
.Map(Optional);
|
||||
|
||||
return maybeExisting.IsSome
|
||||
? BaseError.New($"A playlist group named \"{request.Name}\" already exists")
|
||||
: Success<BaseError, PlaylistGroup>(playlistGroup);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +75,33 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
|
||||
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
|
||||
.BindT(playlist => ValidateName(request).Map(_ => playlist));
|
||||
.BindT(playlist => ValidateName(request).Map(_ => playlist))
|
||||
.BindT(playlist => PlaylistNameMustBeUnique(dbContext, playlist, request));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
|
||||
request.NotEmpty(x => x.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||
|
||||
// Issue #458: mirror CreatePlaylistHandler's duplicate-name guard on rename. Uniqueness is scoped
|
||||
// to the loaded playlist's group (rename cannot move groups) and excludes the playlist itself, so
|
||||
// a no-op rename to its own name still succeeds. Backstopped by the (PlaylistGroupId, Name) unique
|
||||
// index; this pre-check turns the common collision into a clean 422 instead of a DbUpdateException.
|
||||
private static async Task<Validation<BaseError, Playlist>> PlaylistNameMustBeUnique(
|
||||
TvContext dbContext,
|
||||
Playlist playlist,
|
||||
ReplacePlaylistItems request)
|
||||
{
|
||||
Option<Playlist> maybeExisting = await dbContext.Playlists
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p =>
|
||||
p.Id != request.PlaylistId && p.PlaylistGroupId == playlist.PlaylistGroupId && p.Name == request.Name)
|
||||
.Map(Optional);
|
||||
|
||||
return maybeExisting.IsSome
|
||||
? BaseError.New($"A playlist named \"{request.Name}\" already exists in that playlist group")
|
||||
: Success<BaseError, Playlist>(playlist);
|
||||
}
|
||||
|
||||
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
|
||||
ReplacePlaylistItems request,
|
||||
Playlist playlist) =>
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class TimeShiftOnDemandPlayoutHandler(IPlayoutTimeShifter playoutTimeShifter)
|
||||
public class TimeShiftOnDemandPlayoutHandler(
|
||||
IPlayoutTimeShifter playoutTimeShifter,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<TimeShiftOnDemandPlayout, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await playoutTimeShifter.TimeShift(request.PlayoutId, request.Now, request.Force, cancellationToken);
|
||||
List<string> staleGuideChannels = await playoutTimeShifter.TimeShift(
|
||||
request.PlayoutId,
|
||||
request.Now,
|
||||
request.Force,
|
||||
cancellationToken);
|
||||
|
||||
// the time shift rewrote stored PlayoutItem timestamps but not the cached XMLTV
|
||||
// fragment; rebuild the guide for the shifted channel (and any mirrors of it) so a
|
||||
// client tuning in doesn't see a stale timeline. this is a post-commit side effect
|
||||
// (TimeShift already saved) so it runs on CancellationToken.None — a session token that
|
||||
// cancels between the DB commit and this enqueue must not leave the guide stale
|
||||
// (decisions.md api.postcommit-cancellation-none)
|
||||
foreach (string channelNumber in staleGuideChannels)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -58,6 +58,7 @@ public class
|
||||
playout.ScheduleKind,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Id,
|
||||
playout.Channel.PlayoutMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.ScheduleFile,
|
||||
|
||||
@@ -50,6 +50,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
playout.ScheduleKind,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Id,
|
||||
playout.Channel.PlayoutMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.ScheduleFile,
|
||||
|
||||
@@ -53,6 +53,7 @@ public class
|
||||
playout.ScheduleKind,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Id,
|
||||
playout.Channel.PlayoutMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.ScheduleFile,
|
||||
|
||||
@@ -58,6 +58,7 @@ public class
|
||||
playout.ScheduleKind,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Id,
|
||||
playout.Channel.PlayoutMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.ScheduleFile,
|
||||
|
||||
@@ -11,6 +11,7 @@ internal static class Mapper
|
||||
playout.ScheduleKind,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Id,
|
||||
playout.Channel.PlayoutMode,
|
||||
playout.ProgramScheduleId == null ? string.Empty : playout.ProgramSchedule.Name,
|
||||
playout.ScheduleFile,
|
||||
|
||||
@@ -7,6 +7,7 @@ public record PlayoutNameViewModel(
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
int ChannelId,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
string ScheduleName,
|
||||
string ScheduleFile,
|
||||
|
||||
@@ -24,6 +24,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
p.ScheduleKind,
|
||||
p.Channel.Name,
|
||||
p.Channel.Number,
|
||||
p.Channel.Id,
|
||||
p.Channel.PlayoutMode,
|
||||
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
|
||||
p.ScheduleFile,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
@@ -9,4 +9,5 @@ public record CreateProgramSchedule(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -40,7 +40,8 @@ public class CreateProgramScheduleHandler(IDbContextFactory<TvContext> dbContext
|
||||
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
|
||||
ShuffleScheduleItems = request.ShuffleScheduleItems,
|
||||
RandomStartPoint = request.RandomStartPoint,
|
||||
FixedStartTimeBehavior = request.FixedStartTimeBehavior
|
||||
FixedStartTimeBehavior = request.FixedStartTimeBehavior,
|
||||
PadToNearestMinute = request.PadToNearestMinute is int m && m > 0 ? m : null
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
@@ -10,4 +10,5 @@ public record UpdateProgramSchedule(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
|
||||
@@ -40,12 +40,15 @@ public class UpdateProgramScheduleHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// we need to refresh playouts if the playback order or keep multi-episodes has been modified
|
||||
int? normalizedPad = request.PadToNearestMinute is int upm && upm > 0 ? upm : null;
|
||||
|
||||
bool needToRefreshPlayout =
|
||||
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
|
||||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
|
||||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
|
||||
programSchedule.RandomStartPoint != request.RandomStartPoint ||
|
||||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior;
|
||||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior ||
|
||||
programSchedule.PadToNearestMinute != normalizedPad;
|
||||
|
||||
programSchedule.Name = request.Name;
|
||||
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
|
||||
@@ -54,6 +57,7 @@ public class UpdateProgramScheduleHandler(
|
||||
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
|
||||
programSchedule.RandomStartPoint = request.RandomStartPoint;
|
||||
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
|
||||
programSchedule.PadToNearestMinute = normalizedPad;
|
||||
|
||||
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253).
|
||||
// Force-write past a concurrent Version bump (e.g. a parallel schedule-items replace) instead of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class Mapper
|
||||
programSchedule.ShuffleScheduleItems,
|
||||
programSchedule.RandomStartPoint,
|
||||
programSchedule.FixedStartTimeBehavior,
|
||||
programSchedule.PadToNearestMinute,
|
||||
programSchedule.Version);
|
||||
|
||||
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -10,4 +10,5 @@ public record ProgramScheduleViewModel(
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute,
|
||||
int Version);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
@@ -20,6 +20,7 @@ public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContex
|
||||
ps.ShuffleScheduleItems,
|
||||
ps.RandomStartPoint,
|
||||
ps.FixedStartTimeBehavior,
|
||||
ps.PadToNearestMinute,
|
||||
ps.Version))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries;
|
||||
|
||||
public record GetSearchFieldValues(string Name, string Query, int Limit)
|
||||
: IRequest<Option<SearchFieldValuesResponseModel>>;
|
||||
@@ -0,0 +1,120 @@
|
||||
using ErsatzTV.Core.Api.Search;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries;
|
||||
|
||||
public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetSearchFieldValues, Option<SearchFieldValuesResponseModel>>
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 50;
|
||||
|
||||
public async Task<Option<SearchFieldValuesResponseModel>> Handle(
|
||||
GetSearchFieldValues request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchFieldResponseModel field = SearchFieldCatalog.Fields
|
||||
.FirstOrDefault(f => f.Name == request.Name);
|
||||
|
||||
if (field is null || field.Type != "text")
|
||||
{
|
||||
return Option<SearchFieldValuesResponseModel>.None;
|
||||
}
|
||||
|
||||
int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit);
|
||||
string qLower = (request.Query ?? string.Empty).ToLower();
|
||||
|
||||
// in-memory special cases (no DB query needed)
|
||||
switch (request.Name)
|
||||
{
|
||||
case "state":
|
||||
return new SearchFieldValuesResponseModel(
|
||||
FilterSortTake(Enum.GetNames<MediaItemState>(), qLower, limit));
|
||||
case "video_dynamic_range":
|
||||
return new SearchFieldValuesResponseModel(
|
||||
FilterSortTake(["hdr", "sdr"], qLower, limit));
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
if (request.Name == "content_rating")
|
||||
{
|
||||
return new SearchFieldValuesResponseModel(
|
||||
await GetContentRatingValues(dbContext, qLower, limit, cancellationToken));
|
||||
}
|
||||
|
||||
IQueryable<string> source = GetSource(dbContext, request.Name);
|
||||
if (source is null)
|
||||
{
|
||||
return Option<SearchFieldValuesResponseModel>.None;
|
||||
}
|
||||
|
||||
List<string> values = await source
|
||||
.Where(v => v != null && v.ToLower().StartsWith(qLower))
|
||||
.Distinct()
|
||||
.OrderBy(v => v)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new SearchFieldValuesResponseModel(values);
|
||||
}
|
||||
|
||||
private static IQueryable<string> GetSource(TvContext dbContext, string name) => name switch
|
||||
{
|
||||
"genre" or "show_genre" => dbContext.Set<Genre>().Select(g => g.Name),
|
||||
"studio" => dbContext.Set<Studio>().Select(s => s.Name),
|
||||
"director" => dbContext.Set<Director>().Select(d => d.Name),
|
||||
"writer" => dbContext.Set<Writer>().Select(w => w.Name),
|
||||
"actor" => dbContext.Actors.Select(a => a.Name),
|
||||
// entity artists only; free-text music-video/song artist credits are not included (known limitation)
|
||||
"artist" => dbContext.ArtistMetadata.Select(m => m.Title),
|
||||
"tag" => dbContext.Set<Tag>()
|
||||
.Where(t => t.ExternalTypeId != Tag.NfoCountryTypeId && t.ExternalTypeId != Tag.PlexNetworkTypeId)
|
||||
.Select(t => t.Name),
|
||||
"network" => dbContext.Set<Tag>()
|
||||
.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId)
|
||||
.Select(t => t.Name),
|
||||
"collection" => dbContext.Collections.Select(c => c.Name),
|
||||
"video_codec" => dbContext.MediaStreams
|
||||
.Where(s => s.MediaStreamKind == MediaStreamKind.Video && s.Codec != null)
|
||||
.Select(s => s.Codec),
|
||||
"album" => dbContext.MusicVideoMetadata
|
||||
.Where(m => m.Album != null)
|
||||
.Select(m => m.Album)
|
||||
.Concat(dbContext.SongMetadata.Where(m => m.Album != null).Select(m => m.Album)),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static async Task<List<string>> GetContentRatingValues(
|
||||
TvContext dbContext,
|
||||
string qLower,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<string> raw = await dbContext.MovieMetadata
|
||||
.Where(m => m.ContentRating != null)
|
||||
.Select(m => m.ContentRating)
|
||||
.Concat(dbContext.ShowMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
|
||||
.Concat(dbContext.OtherVideoMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
|
||||
.Concat(dbContext.RemoteStreamMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
IEnumerable<string> split = raw
|
||||
.SelectMany(cr => cr.Split('/'))
|
||||
.Select(cr => cr.Trim())
|
||||
.Where(cr => !string.IsNullOrEmpty(cr))
|
||||
.Distinct();
|
||||
|
||||
return FilterSortTake(split, qLower, limit);
|
||||
}
|
||||
|
||||
private static List<string> FilterSortTake(IEnumerable<string> values, string qLower, int limit) =>
|
||||
values
|
||||
.Where(v => v.ToLower().StartsWith(qLower))
|
||||
.OrderBy(v => v)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
@@ -26,7 +26,9 @@ namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
private static int _workAheadCount;
|
||||
// process-wide, shared by every session — the work-ahead limit is a global resource budget
|
||||
private static readonly WorkAheadSlots _workAheadSlots = new();
|
||||
|
||||
private readonly OutputFormatKind _outputFormatKind;
|
||||
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
|
||||
private readonly Dictionary<long, int> _discontinuityMap = [];
|
||||
@@ -243,10 +245,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
|
||||
// claim the slot here rather than checking here and claiming inside Transcode: the check
|
||||
// and the claim have to be one atomic step or every simultaneous tune-in wins (#536)
|
||||
bool initialWorkAhead = _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
|
||||
_state = initialWorkAhead ? HlsSessionState.SeekAndWorkAhead : HlsSessionState.SeekAndRealtime;
|
||||
|
||||
if (!await Transcode(!initialWorkAhead, cancellationToken))
|
||||
if (!await Transcode(initialWorkAhead, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -269,8 +273,8 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
// only use realtime encoding when we're at least 30 seconds ahead
|
||||
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
|
||||
bool subsequentWorkAhead =
|
||||
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(cancellationToken);
|
||||
if (!await Transcode(!subsequentWorkAhead, cancellationToken))
|
||||
!realtime && _workAheadSlots.TryAcquire(await GetWorkAheadLimit(cancellationToken));
|
||||
if (!await Transcode(subsequentWorkAhead, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -456,15 +460,23 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> Transcode(bool realtime, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Runs one transcode. The caller is the one that races for a work-ahead slot, so ownership is
|
||||
/// passed IN: <paramref name="ownsWorkAheadSlot" /> means the caller already claimed a slot from
|
||||
/// <see cref="_workAheadSlots" />, and this method releases it in its <c>finally</c> — acquire
|
||||
/// and release stay one-for-one (#536).
|
||||
/// </summary>
|
||||
private async Task<bool> Transcode(bool ownsWorkAheadSlot, CancellationToken cancellationToken)
|
||||
{
|
||||
// a session works ahead exactly when it holds a slot; everything else runs realtime (throttled)
|
||||
bool realtime = !ownsWorkAheadSlot;
|
||||
|
||||
try
|
||||
{
|
||||
bool wasSeekAndWorkAhead = _state is HlsSessionState.SeekAndWorkAhead;
|
||||
|
||||
if (!realtime)
|
||||
{
|
||||
Interlocked.Increment(ref _workAheadCount);
|
||||
_logger.LogDebug("HLS segmenter will work ahead for channel {Channel}", _channelNumber);
|
||||
|
||||
HlsSessionState nextState = _state switch
|
||||
@@ -747,9 +759,15 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
// do nothing
|
||||
}
|
||||
|
||||
if (!realtime)
|
||||
if (ownsWorkAheadSlot && !_workAheadSlots.Release())
|
||||
{
|
||||
Interlocked.Decrement(ref _workAheadCount);
|
||||
// Release() reports false only when the pool was already empty, i.e. this slot was
|
||||
// released more than once. Nothing reaches here in a correct program, but if a
|
||||
// future second release site breaks the ownership contract this is the one in-band
|
||||
// signal that the unthrottled-transcode budget is inflated (ersatztv#536/#539 §3).
|
||||
_logger.LogWarning(
|
||||
"Released a work-ahead slot that was not held for channel {Channel} - the unthrottled-transcode budget may be inflated",
|
||||
_channelNumber);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -60,6 +60,8 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
|
||||
.ThenInclude(p => p.Resolution)
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.Include(c => c.ChannelGraphicsElements)
|
||||
.ThenInclude(x => x.GraphicsElement)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
|
||||
|
||||
foreach (var channel in maybeChannel)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// The process-wide pool of work-ahead slots shared by every HLS session (ersatztv#536).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>workAheadSegmenterLimit</c> is a resource guarantee, not a tuning knob: it bounds how many
|
||||
/// transcodes may run unthrottled (no <c>-readrate</c>) at once, and the QSV hardware-frame pool
|
||||
/// sizing from ersatztv#529 assumes that bound holds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Acquisition must therefore be atomic. The previous shape — <c>Volatile.Read(count) < limit</c>
|
||||
/// in the caller, <c>Interlocked.Increment</c> later inside the transcode — is a check-then-act
|
||||
/// TOCTOU separated by at least one <c>await</c> (the limit is a DB-backed config read), so N
|
||||
/// simultaneous tune-ins all observed <c>0 < limit</c> and all ran unthrottled. Same class as
|
||||
/// ersatztv#231 / #250.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class WorkAheadSlots
|
||||
{
|
||||
private int _count;
|
||||
private int _unbalancedReleases;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of slots currently held. For diagnostics and tests only — never branch on
|
||||
/// this to decide whether to work ahead; that is exactly the race <see cref="TryAcquire" /> exists to close.
|
||||
/// </summary>
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims one slot if fewer than <paramref name="limit" /> are held.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> when a slot was claimed; the caller then owns it and MUST
|
||||
/// <see cref="Release" /> it exactly once.</returns>
|
||||
public bool TryAcquire(int limit)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int current = Volatile.Read(ref _count);
|
||||
if (current >= limit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// only the thread whose compare-exchange observes the value it read wins the slot, so
|
||||
// the count can never transiently exceed the limit and two racers can never both claim
|
||||
if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of releases that were not matched by a successful acquire. Non-zero always
|
||||
/// means the ownership contract was broken somewhere (no false positives), so the value is a
|
||||
/// reliable "something is wrong" signal — but it can UNDER-count and zero does not prove
|
||||
/// correctness. It only increments when a release finds the pool already empty; an over-release
|
||||
/// that happens while the count is positive — e.g. one cancelling out a coexisting leak —
|
||||
/// decrements a real-looking slot and is never recorded, so the two bugs hide each other. This
|
||||
/// is inherent to a single counter; exact accounting would need per-owner tokens (ersatztv#539 §2).
|
||||
/// </summary>
|
||||
public int UnbalancedReleases => Volatile.Read(ref _unbalancedReleases);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a slot claimed by <see cref="TryAcquire" />. Only ever called by the owner of that slot.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> when a held slot was returned; <c>false</c> when the pool was already empty, i.e.
|
||||
/// the release was unbalanced (also counted in <see cref="UnbalancedReleases" />). Callers should
|
||||
/// log the <c>false</c> case: it is the only in-band signal that the budget contract was broken.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Ownership is a discipline, not a token — the same call-once contract as `EntityLocker` (#231).
|
||||
/// The one failure this defends against is an unbalanced release inflating the budget: this pool
|
||||
/// is process-wide and lives for the life of the app, so a leaked slot would silently and
|
||||
/// permanently admit one extra unthrottled transcode, re-opening the #529 QSV pool exhaustion.
|
||||
/// It clamps at zero rather than throwing — the single caller releases from a `finally`, where a
|
||||
/// throw would swallow the real exception. Unlike a decrement-first-then-clamp shape, this never
|
||||
/// publishes a negative count even transiently, so a concurrent <see cref="TryAcquire" /> can
|
||||
/// never read the pool as having phantom room and over-admit (ersatztv#539 §1); and it records
|
||||
/// the unbalanced release synchronously here, rather than blaming a later, innocent release.
|
||||
/// </remarks>
|
||||
public bool Release()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int current = Volatile.Read(ref _count);
|
||||
if (current <= 0)
|
||||
{
|
||||
Interlocked.Increment(ref _unbalancedReleases);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _count, current - 1, current) == current)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,11 +187,14 @@ public class ChannelGuideGoldenTests
|
||||
xml.ShouldNotContain("a&b");
|
||||
}
|
||||
|
||||
// The access-token value is HTTP-request-derived (?access_token=) and interpolated raw into the
|
||||
// {AccessTokenUri} placeholder, so a token containing XML-special chars must be escaped too —
|
||||
// otherwise it malforms the whole guide, exactly like the {RequestBase} case above. (Finding #376.)
|
||||
// The access-token value is HTTP-request-derived (?access_token=) and interpolated into the
|
||||
// {AccessTokenUri} placeholder, which sits in a URL query value inside an XML attribute. It is
|
||||
// percent-encoded FIRST (#421 — URL-correct: a token '&' becomes %26 so it can't split the query and
|
||||
// truncate the token once a consumer URL-decodes the attribute) and XML-escaped SECOND (#376 — so the
|
||||
// guide stays well-formed). For this token every char percent-encodes to an XML-safe %XX, so the emitted
|
||||
// value is the percent-encoded form with no '&'/'<' introduced by the token.
|
||||
[Test]
|
||||
public async Task Guide_xml_escapes_access_token()
|
||||
public async Task Guide_encodes_and_xml_escapes_access_token()
|
||||
{
|
||||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
@@ -223,9 +226,12 @@ public class ChannelGuideGoldenTests
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// Every XML-special char in the token must be escaped; the raw token must never reach the output.
|
||||
xml.ShouldContain("access_token=tok&<>"");
|
||||
xml.ShouldNotContain("access_token=tok&<");
|
||||
// Percent-encoded, therefore already XML-safe: '&'->%26, '<'->%3C, '>'->%3E, '"'->%22.
|
||||
xml.ShouldContain("access_token=tok%26%3C%3E%22");
|
||||
// The token must not introduce a bare '&' NOR an '&' — either would truncate the query on decode.
|
||||
xml.ShouldNotContain("access_token=tok&");
|
||||
// And the raw special chars must never reach the output.
|
||||
xml.ShouldNotContain("tok&<>\"");
|
||||
}
|
||||
|
||||
// --- harness ---
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
// #421: a token containing M3U-structural characters (a double-quote or ampersand) must not be able to
|
||||
// break out of the quoted url-tvg="..."/tvg-logo="..." attributes or the query string it is placed into.
|
||||
[TestFixture]
|
||||
public class ChannelPlaylistAccessTokenTests
|
||||
{
|
||||
[Test]
|
||||
public void Access_token_with_structural_chars_is_percent_encoded()
|
||||
{
|
||||
const string nastyToken = "aa\"bb&cc dd";
|
||||
|
||||
var playlist = new ChannelPlaylist(
|
||||
"https",
|
||||
"tv.example.com",
|
||||
baseUrl: string.Empty,
|
||||
[
|
||||
new Channel(new Guid("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Number = "1",
|
||||
Name = "News",
|
||||
Group = "ErsatzTV",
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
|
||||
Artwork = [],
|
||||
FFmpegProfile = new FFmpegProfile
|
||||
{
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
}
|
||||
}
|
||||
],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: nastyToken);
|
||||
|
||||
string m3u = playlist.ToM3U();
|
||||
|
||||
// The raw token characters must never appear in the token value...
|
||||
m3u.ShouldNotContain("access_token=aa\"");
|
||||
m3u.ShouldNotContain("access_token=aa\"bb&cc");
|
||||
|
||||
// ...they are percent-encoded instead (" -> %22, & -> %26, space -> %20).
|
||||
m3u.ShouldContain("access_token=aa%22bb%26cc%20dd");
|
||||
|
||||
// No line's url-tvg attribute value contains a bare double-quote that could terminate it early.
|
||||
foreach (string line in m3u.Split('\n'))
|
||||
{
|
||||
if (line.StartsWith("#EXTM3U", StringComparison.Ordinal))
|
||||
{
|
||||
// url-tvg="<url>" x-tvg-url="<url>" — exactly the attribute-delimiting quotes, no stray ones.
|
||||
line.Count(c => c == '"').ShouldBe(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Normal_jwt_token_is_unchanged()
|
||||
{
|
||||
// A base64url JWT is entirely RFC 3986 unreserved, so encoding is a no-op (goldens stay stable).
|
||||
const string jwt = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEyM30.abc-DEF_123";
|
||||
|
||||
var playlist = new ChannelPlaylist(
|
||||
"https",
|
||||
"tv.example.com",
|
||||
baseUrl: string.Empty,
|
||||
[
|
||||
new Channel(new Guid("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Number = "1",
|
||||
Name = "News",
|
||||
Group = "ErsatzTV",
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
|
||||
Artwork = [],
|
||||
FFmpegProfile = new FFmpegProfile
|
||||
{
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
}
|
||||
}
|
||||
],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: jwt);
|
||||
|
||||
playlist.ToM3U().ShouldContain($"access_token={jwt}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
// Direct unit coverage for the enumerator-construction helper extracted from the Scripted
|
||||
// (SchedulingEngine.EnumeratorForContent) and Sequential/YAML (EnumeratorCache.GetEnumeratorForContent)
|
||||
// engines (#395). These pin the two behaviors the issue flags as traps:
|
||||
// - Shuffle must build the *block* enumerator, NOT Classic's ShuffledMediaCollectionEnumerator (a
|
||||
// different algorithm keyed on the same PlaybackOrder), and
|
||||
// - every order the two engines don't support returns None, so each caller logs its own #70 warning
|
||||
// instead of silently scheduling nothing.
|
||||
// The Scripted engine has no golden (its external-process/HTTP transport is integration-only, #563), so
|
||||
// this direct helper test is the in-process regression net for the shared construction it drives.
|
||||
[TestFixture]
|
||||
public class ContentEnumeratorBuilderTests
|
||||
{
|
||||
[Test]
|
||||
public void Chronological_Builds_Chronological_Enumerator()
|
||||
{
|
||||
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
|
||||
[FakeMovie(1), FakeMovie(2)],
|
||||
new CollectionEnumeratorState(),
|
||||
PlaybackOrder.Chronological,
|
||||
multiPart: false);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
foreach (IMediaCollectionEnumerator enumerator in result)
|
||||
{
|
||||
enumerator.ShouldBeOfType<ChronologicalMediaCollectionEnumerator>();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Shuffle_Builds_Block_Shuffle_Enumerator_Not_Classic()
|
||||
{
|
||||
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
|
||||
[FakeMovie(1), FakeMovie(2)],
|
||||
new CollectionEnumeratorState(),
|
||||
PlaybackOrder.Shuffle,
|
||||
multiPart: false);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
foreach (IMediaCollectionEnumerator enumerator in result)
|
||||
{
|
||||
// The documented trap: Shuffle here is the block algorithm, never Classic's shuffle.
|
||||
enumerator.ShouldBeOfType<BlockPlayoutShuffledMediaCollectionEnumerator>();
|
||||
enumerator.ShouldNotBeOfType<ShuffledMediaCollectionEnumerator>();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Shuffle_MultiPart_Also_Builds_Block_Shuffle_Enumerator()
|
||||
{
|
||||
// "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together; multiPart routes the
|
||||
// items through it before the block enumerator. Grouping mechanics are covered by
|
||||
// MultiPartEpisodeGrouper's / ShuffleSourceBuilder's own tests; here we pin that the flag path still
|
||||
// produces the block enumerator (and doesn't throw on the grouped list).
|
||||
List<MediaItem> parts =
|
||||
[
|
||||
NamedEpisode("Episode 1 (1)", 1),
|
||||
NamedEpisode("Episode 2 (2)", 2)
|
||||
];
|
||||
|
||||
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
|
||||
parts,
|
||||
new CollectionEnumeratorState(),
|
||||
PlaybackOrder.Shuffle,
|
||||
multiPart: true);
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
foreach (IMediaCollectionEnumerator enumerator in result)
|
||||
{
|
||||
enumerator.ShouldBeOfType<BlockPlayoutShuffledMediaCollectionEnumerator>();
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase(PlaybackOrder.None)]
|
||||
[TestCase(PlaybackOrder.Random)]
|
||||
[TestCase(PlaybackOrder.ShuffleInOrder)]
|
||||
[TestCase(PlaybackOrder.MultiEpisodeShuffle)]
|
||||
[TestCase(PlaybackOrder.SeasonEpisode)]
|
||||
[TestCase(PlaybackOrder.RandomRotation)]
|
||||
[TestCase(PlaybackOrder.Marathon)]
|
||||
[TestCase(PlaybackOrder.WeightedShuffle)]
|
||||
public void Unsupported_Order_Returns_None(PlaybackOrder order)
|
||||
{
|
||||
// #70: these two engines support only Chronological + Shuffle; every other order returns None so the
|
||||
// caller logs a "not supported" warning instead of silently scheduling nothing.
|
||||
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
|
||||
[FakeMovie(1)],
|
||||
new CollectionEnumeratorState(),
|
||||
order,
|
||||
multiPart: false);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
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) }
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Deterministic Sequential (YAML) schedule fixture for
|
||||
# PlayoutBuildGoldenTests.Sequential_yaml (ersatztv#381).
|
||||
#
|
||||
# Two `count` instructions over ONE chronological collection. The content enumerator is cached by key,
|
||||
# so it continues across the two instructions: items 1-2 come from the first `count`, items 3-4 from the
|
||||
# second. `order: chronological` + literal integer counts keep the build free of shuffle-seed, RNG, and
|
||||
# wall-clock/local-time dependence, so the snapshot of raw UTC Start/Finish is machine-timezone-independent
|
||||
# (no Assume guard needed, unlike the Block golden). Do not introduce `wait_until` / `pad_to_next` /
|
||||
# `pad_until` (local-time-of-day) or a `shuffle` order without revisiting that determinism claim.
|
||||
content:
|
||||
- collection: Sequential Test Collection
|
||||
key: movies
|
||||
order: chronological
|
||||
playout:
|
||||
- count: 2
|
||||
content: movies
|
||||
- count: 2
|
||||
content: movies
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
001 | 2026-01-15 00:22:00 - 2026-01-15 00:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
002 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
003 | 2026-01-15 01:07:00 - 2026-01-15 01:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
004 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
005 | 2026-01-15 02:07:00 - 2026-01-15 02:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
006 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
007 | 2026-01-15 02:37:00 - 2026-01-15 02:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
008 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
009 | 2026-01-15 03:22:00 - 2026-01-15 03:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
010 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
011 | 2026-01-15 04:22:00 - 2026-01-15 04:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
012 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
013 | 2026-01-15 04:52:00 - 2026-01-15 05:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
014 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
015 | 2026-01-15 05:37:00 - 2026-01-15 05:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
016 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
017 | 2026-01-15 06:37:00 - 2026-01-15 06:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
018 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
019 | 2026-01-15 07:07:00 - 2026-01-15 07:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
020 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
021 | 2026-01-15 07:52:00 - 2026-01-15 08:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
022 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
023 | 2026-01-15 08:52:00 - 2026-01-15 09:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
024 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
025 | 2026-01-15 09:22:00 - 2026-01-15 09:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
026 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
027 | 2026-01-15 10:07:00 - 2026-01-15 10:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
028 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
029 | 2026-01-15 11:07:00 - 2026-01-15 11:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
030 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
031 | 2026-01-15 11:37:00 - 2026-01-15 11:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
032 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
033 | 2026-01-15 12:22:00 - 2026-01-15 12:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
034 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
035 | 2026-01-15 13:22:00 - 2026-01-15 13:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
036 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
037 | 2026-01-15 13:52:00 - 2026-01-15 14:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
038 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
039 | 2026-01-15 14:37:00 - 2026-01-15 14:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
040 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
041 | 2026-01-15 15:37:00 - 2026-01-15 15:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
042 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
043 | 2026-01-15 16:07:00 - 2026-01-15 16:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
044 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
045 | 2026-01-15 16:52:00 - 2026-01-15 17:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
046 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
047 | 2026-01-15 17:52:00 - 2026-01-15 18:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
048 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
049 | 2026-01-15 18:22:00 - 2026-01-15 18:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
050 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
051 | 2026-01-15 19:07:00 - 2026-01-15 19:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
052 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
053 | 2026-01-15 20:07:00 - 2026-01-15 20:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
054 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
055 | 2026-01-15 20:37:00 - 2026-01-15 20:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
056 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
057 | 2026-01-15 21:22:00 - 2026-01-15 21:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
058 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
059 | 2026-01-15 22:22:00 - 2026-01-15 22:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
060 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
061 | 2026-01-15 22:52:00 - 2026-01-15 23:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
062 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
063 | 2026-01-15 23:37:00 - 2026-01-15 23:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
064 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
065 | 2026-01-16 00:37:00 - 2026-01-16 00:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
066 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
067 | 2026-01-16 01:07:00 - 2026-01-16 01:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
068 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
069 | 2026-01-16 01:52:00 - 2026-01-16 02:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
070 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
071 | 2026-01-16 02:52:00 - 2026-01-16 03:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
072 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
073 | 2026-01-16 03:22:00 - 2026-01-16 03:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
074 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
075 | 2026-01-16 04:07:00 - 2026-01-16 04:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
076 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
077 | 2026-01-16 05:07:00 - 2026-01-16 05:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
078 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
079 | 2026-01-16 05:37:00 - 2026-01-16 05:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
080 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
081 | 2026-01-16 06:22:00 - 2026-01-16 06:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
082 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
083 | 2026-01-16 07:22:00 - 2026-01-16 07:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
084 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
085 | 2026-01-16 07:52:00 - 2026-01-16 08:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
086 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
087 | 2026-01-16 08:37:00 - 2026-01-16 08:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
088 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
089 | 2026-01-16 09:37:00 - 2026-01-16 09:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
090 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
091 | 2026-01-16 10:07:00 - 2026-01-16 10:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
092 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
093 | 2026-01-16 10:52:00 - 2026-01-16 11:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
094 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
095 | 2026-01-16 11:52:00 - 2026-01-16 12:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
096 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
097 | 2026-01-16 12:22:00 - 2026-01-16 12:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
098 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
099 | 2026-01-16 13:07:00 - 2026-01-16 13:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
100 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
101 | 2026-01-16 14:07:00 - 2026-01-16 14:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
102 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
103 | 2026-01-16 14:37:00 - 2026-01-16 14:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
104 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
105 | 2026-01-16 15:22:00 - 2026-01-16 15:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
106 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
107 | 2026-01-16 16:22:00 - 2026-01-16 16:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
108 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
109 | 2026-01-16 16:52:00 - 2026-01-16 17:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
110 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
111 | 2026-01-16 17:37:00 - 2026-01-16 17:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
112 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
113 | 2026-01-16 18:37:00 - 2026-01-16 18:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
114 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
115 | 2026-01-16 19:07:00 - 2026-01-16 19:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
116 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
117 | 2026-01-16 19:52:00 - 2026-01-16 20:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
118 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
119 | 2026-01-16 20:52:00 - 2026-01-16 21:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
120 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
121 | 2026-01-16 21:22:00 - 2026-01-16 21:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
122 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
123 | 2026-01-16 22:07:00 - 2026-01-16 22:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
124 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
125 | 2026-01-16 23:07:00 - 2026-01-16 23:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
126 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
127 | 2026-01-16 23:37:00 - 2026-01-16 23:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
128 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
129 | 2026-01-17 00:22:00 - 2026-01-17 00:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
130 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
131 | 2026-01-17 01:22:00 - 2026-01-17 01:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
132 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
133 | 2026-01-17 01:52:00 - 2026-01-17 02:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
134 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
135 | 2026-01-17 02:37:00 - 2026-01-17 02:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
136 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
137 | 2026-01-17 03:37:00 - 2026-01-17 03:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
138 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
139 | 2026-01-17 04:07:00 - 2026-01-17 04:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
140 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
141 | 2026-01-17 04:52:00 - 2026-01-17 05:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
142 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
143 | 2026-01-17 05:52:00 - 2026-01-17 06:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Schedule Padded Movie Offline 01
|
||||
001 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Schedule Padded Movie Offline 02
|
||||
002 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Schedule Padded Movie Offline 03
|
||||
003 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Schedule Padded Movie Offline 01
|
||||
004 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Schedule Padded Movie Offline 02
|
||||
005 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Schedule Padded Movie Offline 03
|
||||
006 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Schedule Padded Movie Offline 01
|
||||
007 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Schedule Padded Movie Offline 02
|
||||
008 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Schedule Padded Movie Offline 03
|
||||
009 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Schedule Padded Movie Offline 01
|
||||
010 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Schedule Padded Movie Offline 02
|
||||
011 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Schedule Padded Movie Offline 03
|
||||
012 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Schedule Padded Movie Offline 01
|
||||
013 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Schedule Padded Movie Offline 02
|
||||
014 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Schedule Padded Movie Offline 03
|
||||
015 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Schedule Padded Movie Offline 01
|
||||
016 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Schedule Padded Movie Offline 02
|
||||
017 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Schedule Padded Movie Offline 03
|
||||
018 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Schedule Padded Movie Offline 01
|
||||
019 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Schedule Padded Movie Offline 02
|
||||
020 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Schedule Padded Movie Offline 03
|
||||
021 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Schedule Padded Movie Offline 01
|
||||
022 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Schedule Padded Movie Offline 02
|
||||
023 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Schedule Padded Movie Offline 03
|
||||
024 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Schedule Padded Movie Offline 01
|
||||
025 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Schedule Padded Movie Offline 02
|
||||
026 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Schedule Padded Movie Offline 03
|
||||
027 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Schedule Padded Movie Offline 01
|
||||
028 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Schedule Padded Movie Offline 02
|
||||
029 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Schedule Padded Movie Offline 03
|
||||
030 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Schedule Padded Movie Offline 01
|
||||
031 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Schedule Padded Movie Offline 02
|
||||
032 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Schedule Padded Movie Offline 03
|
||||
033 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Schedule Padded Movie Offline 01
|
||||
034 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Schedule Padded Movie Offline 02
|
||||
035 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Schedule Padded Movie Offline 03
|
||||
036 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Schedule Padded Movie Offline 01
|
||||
037 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Schedule Padded Movie Offline 02
|
||||
038 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Schedule Padded Movie Offline 03
|
||||
039 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Schedule Padded Movie Offline 01
|
||||
040 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Schedule Padded Movie Offline 02
|
||||
041 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Schedule Padded Movie Offline 03
|
||||
042 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Schedule Padded Movie Offline 01
|
||||
043 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Schedule Padded Movie Offline 02
|
||||
044 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Schedule Padded Movie Offline 03
|
||||
045 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Schedule Padded Movie Offline 01
|
||||
046 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Schedule Padded Movie Offline 02
|
||||
047 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Schedule Padded Movie Offline 03
|
||||
048 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Schedule Padded Movie Offline 01
|
||||
049 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Schedule Padded Movie Offline 02
|
||||
050 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Schedule Padded Movie Offline 03
|
||||
051 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Schedule Padded Movie Offline 01
|
||||
052 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Schedule Padded Movie Offline 02
|
||||
053 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Schedule Padded Movie Offline 03
|
||||
054 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Schedule Padded Movie Offline 01
|
||||
055 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Schedule Padded Movie Offline 02
|
||||
056 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Schedule Padded Movie Offline 03
|
||||
057 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Schedule Padded Movie Offline 01
|
||||
058 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Schedule Padded Movie Offline 02
|
||||
059 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Schedule Padded Movie Offline 03
|
||||
060 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Schedule Padded Movie Offline 01
|
||||
061 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Schedule Padded Movie Offline 02
|
||||
062 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Schedule Padded Movie Offline 03
|
||||
063 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Schedule Padded Movie Offline 01
|
||||
064 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Schedule Padded Movie Offline 02
|
||||
065 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Schedule Padded Movie Offline 03
|
||||
066 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Schedule Padded Movie Offline 01
|
||||
067 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Schedule Padded Movie Offline 02
|
||||
068 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Schedule Padded Movie Offline 03
|
||||
069 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Schedule Padded Movie Offline 01
|
||||
070 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Schedule Padded Movie Offline 02
|
||||
071 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Schedule Padded Movie Offline 03
|
||||
@@ -0,0 +1,4 @@
|
||||
000 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Sequential Movie 01
|
||||
001 | 2026-01-15 06:30:00 - 2026-01-15 07:15:00 | None | Sequential Movie 02
|
||||
002 | 2026-01-15 07:15:00 - 2026-01-15 08:15:00 | None | Sequential Movie 03
|
||||
003 | 2026-01-15 08:15:00 - 2026-01-15 08:45:00 | None | Sequential Movie 04
|
||||
@@ -6,10 +6,12 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
||||
using ErsatzTV.Core.Scheduling.YamlScheduling;
|
||||
using ErsatzTV.Infrastructure;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
@@ -117,6 +119,138 @@ public class PlayoutBuildGoldenTests
|
||||
await CompareGolden("classic-clock-padded.txt", items, titles);
|
||||
}
|
||||
|
||||
// Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to
|
||||
// the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances
|
||||
// the build clock to the boundary even when nothing fills the gap.
|
||||
[Test]
|
||||
public async Task Classic_schedule_clock_padded_offline()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: false);
|
||||
|
||||
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
|
||||
content.Count.ShouldBeGreaterThan(2);
|
||||
|
||||
// No filler of any kind is emitted (offline gaps only).
|
||||
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
|
||||
|
||||
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-schedule-clock-padded-offline.txt", items, titles);
|
||||
}
|
||||
|
||||
// Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to
|
||||
// the :15 boundary (no offline gap).
|
||||
[Test]
|
||||
public async Task Classic_schedule_clock_padded_fallback()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: true);
|
||||
|
||||
items.ShouldContain(i => i.FillerKind == FillerKind.Fallback);
|
||||
|
||||
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
|
||||
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-schedule-clock-padded-fallback.txt", items, titles);
|
||||
}
|
||||
|
||||
// #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad).
|
||||
[Test]
|
||||
public async Task Classic_item_pad_wins_over_schedule_pad()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout(schedulePadMinutes: 30);
|
||||
await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden
|
||||
}
|
||||
|
||||
// #392: the schedule-level pad + OFFLINE advance is shared machinery — AddFiller records the offline
|
||||
// target and every scheduler honors it. The offline goldens only exercise PlayoutModeSchedulerOne, so
|
||||
// these invariant tests cover Flood / Duration / Multiple across a 2-day window (two midnight crossings)
|
||||
// to prove the offline advance AND its day-seam anchor survival are not One-specific. No golden files:
|
||||
// the invariants (boundary alignment of every content item, zero filler emitted, and resumption on a
|
||||
// boundary on both later days) fully pin the behavior and are the exact thing the day-seam clamp fix
|
||||
// must preserve. If the day-boundary anchor clamp wrongly discarded an offline advance, the first
|
||||
// content item after a midnight would land mid-interval and fail here.
|
||||
[TestCase("Flood")]
|
||||
[TestCase("Duration")]
|
||||
[TestCase("Multiple")]
|
||||
public async Task Schedule_clock_padded_offline_multimode(string mode)
|
||||
{
|
||||
List<PlayoutItem> items = await BuildSchedulePaddedModePlayout(mode);
|
||||
|
||||
List<PlayoutItem> content = items
|
||||
.Where(i => i.FillerKind == FillerKind.None)
|
||||
.OrderBy(i => i.Start)
|
||||
.ToList();
|
||||
|
||||
// Sanity: a 2-day window over sub-hour content must yield many items spanning >1 day.
|
||||
content.Count.ShouldBeGreaterThan(10);
|
||||
content.Select(i => i.Start.Date).Distinct().Count().ShouldBeGreaterThan(2);
|
||||
|
||||
// Offline variant: NO filler of any kind is emitted (gaps up to the boundary are left offline).
|
||||
items.ShouldNotContain(i => i.FillerKind != FillerKind.None, $"[{mode}] offline pad must emit no filler");
|
||||
|
||||
// Every content item begins on a :15 boundary. The very first item is the raw anchor at the pinned
|
||||
// Start (06:00, itself a :15 boundary); every later item — including the first of day 2 and day 3 —
|
||||
// is on a boundary only because the preceding item's offline pad advanced the clock to it AND the
|
||||
// day-boundary anchor clamp preserved that advance across each midnight seam.
|
||||
foreach (PlayoutItem item in content)
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(
|
||||
0,
|
||||
$"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0, $"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not second-aligned");
|
||||
}
|
||||
|
||||
// Explicit day-seam assertion: the first content item on each day after the first still lands on a
|
||||
// boundary (this is precisely what regressed before the clamp fix, once per simulated midnight).
|
||||
List<PlayoutItem> firstOfEachDay = content
|
||||
.GroupBy(i => i.Start.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => g.OrderBy(i => i.Start).First())
|
||||
.ToList();
|
||||
foreach (PlayoutItem dayStart in firstOfEachDay.Skip(1))
|
||||
{
|
||||
(dayStart.Start.Minute % 15).ShouldBe(
|
||||
0,
|
||||
$"[{mode}] first content item of {dayStart.Start:yyyy-MM-dd} at {dayStart.Start:HH:mm:ss} resumed mid-interval");
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the whole-branch-review defect: Fill-With-Group schedule items (FillWithGroupMode
|
||||
// .FillWithOrderedGroups / FillWithShuffledGroups) are scheduled via a FAKE ProgramScheduleItem that
|
||||
// PlayoutBuilder synthesizes with DeepCopy() (Newtonsoft serialization). ProgramScheduleItem
|
||||
// .ProgramSchedule is [JsonIgnore]'d there, so without reassigning it on the copy, the schedule-level
|
||||
// PadToNearestMinute silently no-ops for fill-with-group items only — the normal (non-group) path
|
||||
// reads the schedule nav that Build() populates centrally and was never broken. No golden: this is an
|
||||
// invariant-only regression test (boundary alignment + zero filler), the same assertions
|
||||
// Classic_schedule_clock_padded_offline uses for the non-group path.
|
||||
[Test]
|
||||
public async Task Schedule_clock_padded_fill_with_group_offline()
|
||||
{
|
||||
List<PlayoutItem> items = await BuildSchedulePaddedFillWithGroupPlayout();
|
||||
|
||||
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
|
||||
content.Count.ShouldBeGreaterThan(2);
|
||||
|
||||
// No filler of any kind is emitted (offline gaps only).
|
||||
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
|
||||
|
||||
foreach (PlayoutItem item in content.Skip(1))
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(
|
||||
0,
|
||||
$"fill-with-group content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -134,6 +268,36 @@ public class PlayoutBuildGoldenTests
|
||||
[Test]
|
||||
public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);
|
||||
|
||||
// Sequential (YAML) builder (#381, follow-up to #163). SequentialPlayoutBuilder reads a YAML schedule
|
||||
// file (Playout.ScheduleFile) instead of a ProgramSchedule/Block calendar. The committed fixture
|
||||
// Goldens/Fixtures/sequential-schedule.yml schedules two `count: 2` instructions over one chronological
|
||||
// collection, so the builder lays exactly four items back-to-back from the pinned start (the enumerator
|
||||
// is cached by content key and continues across the two instructions). Besides the golden we assert the
|
||||
// contiguity invariant: it is what "sequential" means here, and a deliberate change to a count or a
|
||||
// duration flips both the assertion and the golden (#12 non-vacuity). The count/all/duration handlers do
|
||||
// pure UTC arithmetic off the caller-supplied start (no TimeZoneInfo.Local / ToLocalTime), so this case
|
||||
// is TZ-independent and needs no Assume guard — unlike Block, and unlike the wait_until/pad_* handlers
|
||||
// the fixture deliberately avoids.
|
||||
[Test]
|
||||
public async Task Sequential_yaml()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSequentialPlayout();
|
||||
|
||||
List<PlayoutItem> ordered = items.OrderBy(i => i.Start).ToList();
|
||||
|
||||
ordered.Count.ShouldBe(4);
|
||||
ordered.ShouldAllBe(i => i.FillerKind == FillerKind.None);
|
||||
ordered[0].Start.ShouldBe(Start.UtcDateTime);
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
ordered[i].Start.ShouldBe(
|
||||
ordered[i - 1].Finish,
|
||||
$"sequential item {i} at {ordered[i].Start:HH:mm:ss} is not contiguous with the previous finish");
|
||||
}
|
||||
|
||||
await CompareGolden("sequential-yaml.txt", items, titles);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
|
||||
public async Task Regenerate_goldens()
|
||||
@@ -142,7 +306,11 @@ public class PlayoutBuildGoldenTests
|
||||
try
|
||||
{
|
||||
foreach (Func<Task> regen in new Func<Task>[]
|
||||
{ Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle })
|
||||
{
|
||||
Classic_chronological, Block_playout, Classic_clock_padded,
|
||||
Classic_schedule_clock_padded_offline, Classic_schedule_clock_padded_fallback,
|
||||
Classic_shuffle, Classic_weighted, Sequential_yaml
|
||||
})
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -689,11 +857,12 @@ public class PlayoutBuildGoldenTests
|
||||
|
||||
// --- Clock-boundary pad builder (issue #77) ---
|
||||
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout()
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout(
|
||||
int? schedulePadMinutes = null)
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedPaddedData(cancellationToken);
|
||||
var (playoutId, titles) = await SeedPaddedData(cancellationToken, schedulePadMinutes);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
@@ -733,11 +902,17 @@ public class PlayoutBuildGoldenTests
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedPaddedData(
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
int? schedulePadMinutes = null)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Padded LibraryPath" };
|
||||
// Suffix distinguishes this call from the base (schedulePadMinutes: null) call so unique-name/guid
|
||||
// constraints don't collide when both are seeded into the same shared in-memory database. It never
|
||||
// touches a Movie/MovieMetadata title, so the golden snapshot (which only records those) is unaffected.
|
||||
string suffix = schedulePadMinutes.HasValue ? $" SchedulePad{schedulePadMinutes}" : string.Empty;
|
||||
|
||||
var path = new LibraryPath { Path = $"Padded LibraryPath{suffix}" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
@@ -786,12 +961,12 @@ public class PlayoutBuildGoldenTests
|
||||
|
||||
var contentCollection = new Collection
|
||||
{
|
||||
Name = "Padded Content Collection",
|
||||
Name = $"Padded Content Collection{suffix}",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
var fillerCollection = new Collection
|
||||
{
|
||||
Name = "Padded Filler Collection",
|
||||
Name = $"Padded Filler Collection{suffix}",
|
||||
MediaItems = new List<MediaItem> { fillerClip }
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
@@ -806,7 +981,7 @@ public class PlayoutBuildGoldenTests
|
||||
// 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",
|
||||
Name = $"Pad To Quarter Hour{suffix}",
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
FillerMode = FillerMode.Pad,
|
||||
PadToNearestMinute = 15,
|
||||
@@ -830,21 +1005,29 @@ public class PlayoutBuildGoldenTests
|
||||
}
|
||||
};
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Padded FFmpeg Profile" };
|
||||
var ffmpegProfile = new FFmpegProfile { Name = $"Padded FFmpeg Profile{suffix}" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000003"))
|
||||
var channel = new Channel(
|
||||
schedulePadMinutes.HasValue
|
||||
? Guid.Parse("00000000-0000-0000-0000-000000000007")
|
||||
: Guid.Parse("00000000-0000-0000-0000-000000000003"))
|
||||
{
|
||||
Name = "Padded Test Channel",
|
||||
Number = "3",
|
||||
Name = $"Padded Test Channel{suffix}",
|
||||
Number = schedulePadMinutes.HasValue ? "7" : "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 };
|
||||
var schedule = new ProgramSchedule { Name = $"Padded Test Schedule{suffix}", Items = scheduleItems };
|
||||
if (schedulePadMinutes.HasValue)
|
||||
{
|
||||
schedule.PadToNearestMinute = schedulePadMinutes.Value;
|
||||
}
|
||||
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -891,6 +1074,529 @@ public class PlayoutBuildGoldenTests
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// #392: schedule-level PadToNearestMinute (no item-level Pad filler). Mirrors BuildPaddedPlayout/
|
||||
// SeedPaddedData/GetPaddedReferenceData above, but the item has no PostRollFiller and the schedule
|
||||
// itself carries the pad divisor.
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSchedulePaddedPlayout(
|
||||
bool withFallback)
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedSchedulePaddedData(cancellationToken, withFallback);
|
||||
|
||||
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 GetSchedulePaddedReferenceData(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)> SeedSchedulePaddedData(
|
||||
CancellationToken cancellationToken,
|
||||
bool withFallback)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
// Suffix distinguishes the offline/fallback variants so unique-name/guid constraints don't
|
||||
// collide when both fixtures are seeded into the same shared in-memory database.
|
||||
string suffix = withFallback ? "Fallback" : "Offline";
|
||||
|
||||
var path = new LibraryPath { Path = $"Schedule Padded LibraryPath {suffix}" };
|
||||
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 = $"Schedule Padded Movie {suffix} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
// Filler: a SINGLE 1-minute clip, only used for the fallback variant.
|
||||
var fillerClip = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new() { Title = $"Schedule Fallback Filler Clip {suffix}", 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 = $"Schedule Padded Content Collection {suffix}",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
var fillerCollection = new Collection
|
||||
{
|
||||
Name = $"Schedule Padded Fallback Collection {suffix}",
|
||||
MediaItems = new List<MediaItem> { fillerClip }
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
await context.Collections.AddAsync(fillerCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
FillerPreset fallbackFiller = null;
|
||||
if (withFallback)
|
||||
{
|
||||
fallbackFiller = new FillerPreset
|
||||
{
|
||||
Name = $"Schedule Pad Fallback {suffix}",
|
||||
FillerKind = FillerKind.Fallback,
|
||||
FillerMode = FillerMode.None,
|
||||
CollectionType = CollectionType.Collection,
|
||||
Collection = fillerCollection,
|
||||
CollectionId = fillerCollection.Id
|
||||
};
|
||||
await context.FillerPresets.AddAsync(fallbackFiller, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemOne
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
};
|
||||
if (withFallback)
|
||||
{
|
||||
scheduleItem.FallbackFiller = fallbackFiller;
|
||||
scheduleItem.FallbackFillerId = fallbackFiller.Id;
|
||||
}
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded FFmpeg Profile {suffix}" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse(withFallback ? "00000000-0000-0000-0000-000000000009" : "00000000-0000-0000-0000-000000000008"))
|
||||
{
|
||||
Name = $"Schedule Padded Test Channel {suffix}",
|
||||
Number = withFallback ? "9" : "8",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = $"Schedule Padded Test Schedule {suffix}",
|
||||
Items = scheduleItems,
|
||||
PadToNearestMinute = 15
|
||||
};
|
||||
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> GetSchedulePaddedReferenceData(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.FallbackFiller)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return new PlayoutReferenceData(
|
||||
channel,
|
||||
Option<Deco>.None,
|
||||
[],
|
||||
[],
|
||||
programSchedule,
|
||||
[],
|
||||
[],
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// #392: schedule-level pad + offline advance for a NON-One scheduler (Flood / Duration / Multiple).
|
||||
// Same shape as SeedSchedulePaddedData (no item-level Pad filler, no FallbackFiller → offline), only the
|
||||
// ProgramScheduleItem subtype differs. Reuses GetSchedulePaddedReferenceData for the build query.
|
||||
private async Task<List<PlayoutItem>> BuildSchedulePaddedModePlayout(string mode)
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
int playoutId = await SeedSchedulePaddedModeData(cancellationToken, mode);
|
||||
|
||||
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 GetSchedulePaddedReferenceData(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;
|
||||
}
|
||||
|
||||
private async Task<int> SeedSchedulePaddedModeData(CancellationToken cancellationToken, string mode)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
// Per-mode suffix + GUID keep unique constraints from colliding across fixtures in the shared DB.
|
||||
string guid = mode switch
|
||||
{
|
||||
"Flood" => "00000000-0000-0000-0000-00000000000a",
|
||||
"Duration" => "00000000-0000-0000-0000-00000000000b",
|
||||
"Multiple" => "00000000-0000-0000-0000-00000000000c",
|
||||
_ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
|
||||
};
|
||||
|
||||
var path = new LibraryPath { Path = $"Schedule Padded Mode LibraryPath {mode}" };
|
||||
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);
|
||||
|
||||
// Sub-15-min durations (7/11/13) so each padded item occupies exactly one :15 slot. This makes a
|
||||
// whole :15-multiple block (Duration's 3h) tile exactly with no leftover — Duration therefore never
|
||||
// packs a final item unpadded to fill the block (its fill-the-duration contract legitimately
|
||||
// overrides per-item clock-pad when content straddles a :15, which would obscure the pad/seam signal
|
||||
// this test is pinning). Every mode then holds the same strict boundary invariant.
|
||||
int[] durationsMinutes = [7, 11, 13];
|
||||
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 = $"Schedule Padded Mode Movie {mode} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var contentCollection = new Collection
|
||||
{
|
||||
Name = $"Schedule Padded Mode Content Collection {mode}",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
ProgramScheduleItem scheduleItem = mode switch
|
||||
{
|
||||
"Flood" => new ProgramScheduleItemFlood
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
"Duration" => new ProgramScheduleItemDuration
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlayoutDuration = TimeSpan.FromHours(3),
|
||||
TailMode = TailMode.Offline,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
"Multiple" => new ProgramScheduleItemMultiple
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
MultipleMode = MultipleMode.Count,
|
||||
Count = "3",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
_ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
|
||||
};
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded Mode FFmpeg Profile {mode}" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse(guid))
|
||||
{
|
||||
Name = $"Schedule Padded Mode Channel {mode}",
|
||||
Number = mode switch { "Flood" => "10", "Duration" => "11", _ => "12" },
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = $"Schedule Padded Mode Schedule {mode}",
|
||||
Items = scheduleItems,
|
||||
PadToNearestMinute = 15
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
// Regression harness for the Fill-With-Group DeepCopy defect: same shape as
|
||||
// SeedSchedulePaddedModeData (off-boundary durations, no item-level Pad filler, no FallbackFiller
|
||||
// -> offline), except the single ProgramScheduleItemMultiple sets FillWithGroupMode so PlayoutBuilder
|
||||
// schedules it via a synthesized (DeepCopy'd) fake schedule item instead of the original.
|
||||
private async Task<int> SeedSchedulePaddedFillWithGroupData(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Schedule Padded FillWithGroup 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);
|
||||
|
||||
// Off-boundary durations (22/37/52 min), same as SeedSchedulePaddedData, 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 = $"Schedule Padded FillWithGroup Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var contentCollection = new Collection
|
||||
{
|
||||
Name = "Schedule Padded FillWithGroup Content Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemMultiple
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
MultipleMode = MultipleMode.Count,
|
||||
Count = "3",
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups
|
||||
};
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Schedule Padded FillWithGroup FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-00000000000d"))
|
||||
{
|
||||
Name = "Schedule Padded FillWithGroup Channel",
|
||||
Number = "13",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Schedule Padded FillWithGroup Schedule",
|
||||
Items = scheduleItems,
|
||||
PadToNearestMinute = 15
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
private async Task<List<PlayoutItem>> BuildSchedulePaddedFillWithGroupPlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
int playoutId = await SeedSchedulePaddedFillWithGroupData(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 GetSchedulePaddedReferenceData(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;
|
||||
}
|
||||
|
||||
// --- Block builder ---
|
||||
//
|
||||
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
|
||||
@@ -1141,6 +1847,166 @@ public class PlayoutBuildGoldenTests
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// --- Sequential (YAML) builder (issue #381) ---
|
||||
//
|
||||
// SequentialPlayoutBuilder reads its schedule from a YAML file at Playout.ScheduleFile. It checks the
|
||||
// file's existence through the injected IFileSystem but reads the bytes with the static System.IO.File,
|
||||
// so the test writes a REAL committed fixture on disk (Goldens/Fixtures/sequential-schedule.yml) and
|
||||
// only stubs IFileSystem.File.Exists -> true. The schema validator is stubbed (the real one loads a JSON
|
||||
// schema from a runtime cache folder that a unit test has no reason to populate); this golden locks the
|
||||
// BUILDER's PlayoutItem output, not the validator, which is a separate surface.
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSequentialPlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedSequentialData(cancellationToken);
|
||||
|
||||
var fileSystem = Substitute.For<System.IO.Abstractions.IFileSystem>();
|
||||
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
|
||||
|
||||
var validator = Substitute.For<ISequentialScheduleValidator>();
|
||||
validator.ValidateSchedule(Arg.Any<string>(), Arg.Any<bool>()).Returns(Task.FromResult(true));
|
||||
|
||||
var builder = new SequentialPlayoutBuilder(
|
||||
fileSystem,
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
Substitute.For<IChannelRepository>(),
|
||||
Substitute.For<IGraphicsElementRepository>(),
|
||||
validator,
|
||||
NullLogger<SequentialPlayoutBuilder>.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 GetSequentialReferenceData(context, playoutId);
|
||||
|
||||
// Reset over the pinned window: with no prior Anchor, Reset avoids the YamlPlayoutContext.Reset path
|
||||
// (its ToLocalTime() only runs on a saved-anchor Continue), keeping the build TZ-independent.
|
||||
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)> SeedSequentialData(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Sequential 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, distinct release dates (so chronological order is unambiguous) and varied durations so
|
||||
// item boundaries are visible. Only the first four are scheduled (2 + 2 counts).
|
||||
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 = $"Sequential Movie {i:D2}",
|
||||
ReleaseDate = new DateTime(2005, 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);
|
||||
|
||||
// Name must match the fixture YAML's `collection:` value — EnumeratorCache resolves content by
|
||||
// Collection.Name.
|
||||
var collection = new Collection
|
||||
{
|
||||
Name = "Sequential Test Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddAsync(collection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Sequential FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000006"))
|
||||
{
|
||||
Name = "Sequential Test Channel",
|
||||
Number = "6",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Sequential playout: no ProgramSchedule; content comes from the YAML ScheduleFile.
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Sequential,
|
||||
ScheduleFile = Path.Combine(FixtureDir(), "sequential-schedule.yml")
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private static async Task<PlayoutReferenceData> GetSequentialReferenceData(TvContext dbContext, int playoutId)
|
||||
{
|
||||
Channel channel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Sequential reads content from the YAML file, not a ProgramSchedule; empty history + a fresh build.
|
||||
return new PlayoutReferenceData(
|
||||
channel,
|
||||
Option<Deco>.None,
|
||||
[],
|
||||
[],
|
||||
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)
|
||||
@@ -1176,6 +2042,12 @@ public class PlayoutBuildGoldenTests
|
||||
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
|
||||
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
|
||||
|
||||
// Committed YAML input fixtures (not golden outputs) for builders that read a schedule file. Note
|
||||
// GoldenDir nests as Goldens/Goldens (this test file already lives under Goldens/), so fixtures sit in a
|
||||
// sibling Goldens/Fixtures to keep inputs and snapshot outputs visually separate.
|
||||
private static string FixtureDir([CallerFilePath] string thisFile = "") =>
|
||||
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");
|
||||
|
||||
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
|
||||
{
|
||||
public TvContext CreateDbContext() =>
|
||||
|
||||
@@ -66,7 +66,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
},
|
||||
new List<MediaChapter>(),
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(1);
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
},
|
||||
new List<MediaChapter> { new() },
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(1);
|
||||
}
|
||||
@@ -192,7 +192,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
|
||||
},
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(3);
|
||||
playoutItems[0].MediaItemId.ShouldBe(1);
|
||||
@@ -284,7 +284,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
},
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
|
||||
@@ -392,7 +392,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
|
||||
@@ -501,7 +501,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(6);
|
||||
|
||||
@@ -611,7 +611,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
},
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
|
||||
@@ -719,7 +719,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
using ErsatzTV.Application.Streaming;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Streaming;
|
||||
|
||||
// Verifies the atomic-claim contract of the work-ahead slot pool (ersatztv#536): at most
|
||||
// `limit` sessions may hold a slot at once, no matter how many race for one simultaneously.
|
||||
//
|
||||
// NEGATIVE CONTROL (per the ersatztv#231/#250 lesson — a one-shot Barrier + Task.WhenAll does NOT
|
||||
// catch this class on this hardware): to prove these tests are non-vacuous, temporarily replace the
|
||||
// compare-exchange in WorkAheadSlots.TryAcquire with the check-then-act shape this issue fixed —
|
||||
//
|
||||
// int current = Volatile.Read(ref _count);
|
||||
// if (current >= limit) return false;
|
||||
// Interlocked.Increment(ref _count);
|
||||
// return true;
|
||||
//
|
||||
// — and TryAcquire_ParallelCallers_NeverExceedsLimit must FAIL (badRounds > 0). Do NOT "break" it by
|
||||
// stubbing `if (true)`: that leaves values assigned-but-never-read, and CS0219 under
|
||||
// warnings-as-errors fails the build silently, so `dotnet test --no-build` then runs the STALE
|
||||
// (fixed) dll and the control falsely passes. Always grep the build output for `error CS` first.
|
||||
[TestFixture]
|
||||
public class WorkAheadSlotsTests
|
||||
{
|
||||
[Test]
|
||||
public void TryAcquire_BelowLimit_Succeeds()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.TryAcquire(2).ShouldBeTrue();
|
||||
slots.TryAcquire(2).ShouldBeTrue();
|
||||
slots.TryAcquire(2).ShouldBeFalse();
|
||||
slots.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryAcquire_DoesNotConsumeASlotWhenItFails()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.TryAcquire(1).ShouldBeTrue();
|
||||
slots.TryAcquire(1).ShouldBeFalse();
|
||||
slots.Count.ShouldBe(1);
|
||||
|
||||
// the failed attempt must not have leaked a slot: releasing the one real holder frees the pool
|
||||
slots.Release();
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.TryAcquire(1).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryAcquire_WithNonPositiveLimit_NeverSucceeds()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.TryAcquire(0).ShouldBeFalse();
|
||||
slots.TryAcquire(-1).ShouldBeFalse();
|
||||
slots.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// The pool is process-wide and never recreated, so a negative count would not self-heal: it would
|
||||
// permanently admit more than `limit` unthrottled transcodes, with nothing in the logs to find it by.
|
||||
[Test]
|
||||
public void Release_WithoutAcquire_ClampsAtZeroAndIsRecorded()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.Release();
|
||||
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.UnbalancedReleases.ShouldBe(1);
|
||||
|
||||
// the budget is intact: a limit of 1 still admits exactly one holder, not two
|
||||
slots.TryAcquire(1).ShouldBeTrue();
|
||||
slots.TryAcquire(1).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnbalancedReleases_IsZeroUnderCorrectUse()
|
||||
{
|
||||
var slots = new WorkAheadSlots();
|
||||
|
||||
slots.TryAcquire(1).ShouldBeTrue();
|
||||
slots.Release();
|
||||
|
||||
slots.UnbalancedReleases.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Hammers the acquire race over many rounds rather than a single simultaneous burst: the
|
||||
// read->increment window is far too narrow to collide reliably when threads release once.
|
||||
[Test]
|
||||
[TestCase(1)]
|
||||
[TestCase(2)]
|
||||
[TestCase(3)]
|
||||
public void TryAcquire_ParallelCallers_NeverExceedsLimit(int limit)
|
||||
{
|
||||
const int threads = 8;
|
||||
const int rounds = 20_000;
|
||||
|
||||
var slots = new WorkAheadSlots();
|
||||
var winners = 0;
|
||||
var badRounds = 0;
|
||||
|
||||
using var startRound = new Barrier(threads);
|
||||
using var endRound = new Barrier(
|
||||
threads,
|
||||
_ =>
|
||||
{
|
||||
int held = Volatile.Read(ref winners);
|
||||
if (held > limit || held != slots.Count)
|
||||
{
|
||||
Interlocked.Increment(ref badRounds);
|
||||
}
|
||||
|
||||
// reset for the next round: release every slot claimed this round
|
||||
for (var i = 0; i < held; i++)
|
||||
{
|
||||
slots.Release();
|
||||
}
|
||||
|
||||
Volatile.Write(ref winners, 0);
|
||||
});
|
||||
|
||||
var workers = new Thread[threads];
|
||||
for (var t = 0; t < threads; t++)
|
||||
{
|
||||
workers[t] = new Thread(() =>
|
||||
{
|
||||
for (var r = 0; r < rounds; r++)
|
||||
{
|
||||
startRound.SignalAndWait();
|
||||
if (slots.TryAcquire(limit))
|
||||
{
|
||||
Interlocked.Increment(ref winners);
|
||||
}
|
||||
|
||||
endRound.SignalAndWait();
|
||||
}
|
||||
});
|
||||
workers[t].Start();
|
||||
}
|
||||
|
||||
foreach (Thread worker in workers)
|
||||
{
|
||||
worker.Join();
|
||||
}
|
||||
|
||||
badRounds.ShouldBe(0);
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.UnbalancedReleases.ShouldBe(0);
|
||||
}
|
||||
|
||||
// §1 (ersatztv#539): an unbalanced Release() must never publish a NEGATIVE count, even
|
||||
// transiently. The pre-#539 shape decremented FIRST (0 -> -1) and clamped afterwards, so a
|
||||
// concurrent TryAcquire(limit) could read the -1, see "-1 < limit" as phantom room, and admit a
|
||||
// holder the budget doesn't have (the second acquirer then reads 0 and admits another). Because
|
||||
// TryAcquire is the sole, CAS-guarded increment path, a count that is provably never negative is
|
||||
// exactly what forecloses that over-admit. Here one thread hammers unbalanced releases on an
|
||||
// empty pool while readers sample the count; none may ever observe a value below zero.
|
||||
//
|
||||
// NEGATIVE CONTROL (per the class-level ersatztv#231/#250 lesson) — revert Release() to its
|
||||
// pre-#539 decrement-first body to prove this test is non-vacuous:
|
||||
//
|
||||
// if (Interlocked.Decrement(ref _count) >= 0) return true;
|
||||
// Interlocked.Increment(ref _unbalancedReleases);
|
||||
// while (true) { int c = Volatile.Read(ref _count);
|
||||
// if (c >= 0 || Interlocked.CompareExchange(ref _count, 0, c) == c) return false; }
|
||||
//
|
||||
// — and this test must FAIL (sawNegative > 0): the readers catch the transient -1. Do NOT stub
|
||||
// `if (true)`: that leaves values assigned-but-never-read, and CS0219 under warnings-as-errors
|
||||
// fails the build silently so `dotnet test --no-build` runs the STALE dll and the control falsely
|
||||
// passes. Always grep the build output for `error CS` first.
|
||||
[Test]
|
||||
public void Release_Unbalanced_NeverPublishesNegativeCount()
|
||||
{
|
||||
const int releases = 2_000_000;
|
||||
const int readers = 4;
|
||||
|
||||
var slots = new WorkAheadSlots();
|
||||
var sawNegative = 0;
|
||||
var done = false;
|
||||
|
||||
var readerThreads = new Thread[readers];
|
||||
for (var i = 0; i < readers; i++)
|
||||
{
|
||||
readerThreads[i] = new Thread(() =>
|
||||
{
|
||||
while (!Volatile.Read(ref done))
|
||||
{
|
||||
if (slots.Count < 0)
|
||||
{
|
||||
Interlocked.Increment(ref sawNegative);
|
||||
}
|
||||
}
|
||||
});
|
||||
readerThreads[i].Start();
|
||||
}
|
||||
|
||||
// every release finds the pool empty, so every one is unbalanced
|
||||
for (var r = 0; r < releases; r++)
|
||||
{
|
||||
slots.Release();
|
||||
}
|
||||
|
||||
Volatile.Write(ref done, true);
|
||||
foreach (Thread reader in readerThreads)
|
||||
{
|
||||
reader.Join();
|
||||
}
|
||||
|
||||
sawNegative.ShouldBe(0);
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.UnbalancedReleases.ShouldBe(releases);
|
||||
}
|
||||
|
||||
// The release path is the fiddly half: a slot freed by its owner must become available again,
|
||||
// and a burst of acquire/release cycles must not drift the count in either direction.
|
||||
[Test]
|
||||
public void AcquireAndRelease_UnderContention_LeavesNoLeakedOrDoubleFreedSlots()
|
||||
{
|
||||
const int threads = 8;
|
||||
const int rounds = 20_000;
|
||||
const int limit = 3;
|
||||
|
||||
var slots = new WorkAheadSlots();
|
||||
var overLimit = 0;
|
||||
var live = 0;
|
||||
|
||||
var workers = new Thread[threads];
|
||||
for (var t = 0; t < threads; t++)
|
||||
{
|
||||
workers[t] = new Thread(() =>
|
||||
{
|
||||
for (var r = 0; r < rounds; r++)
|
||||
{
|
||||
if (!slots.TryAcquire(limit))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Interlocked.Increment(ref live) > limit)
|
||||
{
|
||||
Interlocked.Increment(ref overLimit);
|
||||
}
|
||||
|
||||
Interlocked.Decrement(ref live);
|
||||
slots.Release();
|
||||
}
|
||||
});
|
||||
workers[t].Start();
|
||||
}
|
||||
|
||||
foreach (Thread worker in workers)
|
||||
{
|
||||
worker.Join();
|
||||
}
|
||||
|
||||
overLimit.ShouldBe(0);
|
||||
slots.Count.ShouldBe(0);
|
||||
slots.UnbalancedReleases.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,10 @@ public record ChannelDetailResponseModel(
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg);
|
||||
bool ShowInEpg,
|
||||
int[] GraphicsElementIds,
|
||||
// Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel.
|
||||
ChannelHealthResponseModel Health);
|
||||
|
||||
// Wire-compatible mirror of the Application-layer ArtworkContentTypeModel (which lives in
|
||||
// ErsatzTV.Application and therefore can't be referenced from Core). Same serialized shape the
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
// Server-derived per-channel health. The SPA and MCP both read Status/Faults rather than deriving a
|
||||
// verdict themselves (the api.channel-health-object decision, superseding #72's raw-fact-only stance).
|
||||
// Faults are plain strings (see ChannelFault) not a C# enum, to keep the OpenAPI schema simple — the
|
||||
// SPA hand-maintains its own union, matching the ChannelPreviewAvailability pattern.
|
||||
public record ChannelHealthResponseModel(
|
||||
// One of ChannelHealthStatus's values.
|
||||
string Status,
|
||||
// The specific fault classes that fired (each a ChannelFault value); empty when Healthy/Unknown.
|
||||
string[] Faults,
|
||||
// Retained #72 fact: total playouts (mirror-aware).
|
||||
int PlayoutCount,
|
||||
// Count of upcoming built items pointing at a FileNotFound/Unavailable MediaItem; 0 when none.
|
||||
int BrokenSourceItemCount);
|
||||
|
||||
public static class ChannelHealthStatus
|
||||
{
|
||||
public const string Healthy = "Healthy";
|
||||
public const string Problems = "Problems";
|
||||
public const string Unknown = "Unknown";
|
||||
}
|
||||
|
||||
public static class ChannelFault
|
||||
{
|
||||
public const string NoPlayout = "NoPlayout";
|
||||
public const string NeverBuilt = "NeverBuilt";
|
||||
public const string BuildFailed = "BuildFailed";
|
||||
public const string EmptyUpcoming = "EmptyUpcoming";
|
||||
public const string BrokenSource = "BrokenSource";
|
||||
}
|
||||
|
||||
// Per-playout upcoming-item aggregate (Task 2's repository query fills this). Lives in Core so both
|
||||
// the repository interface (Core) and Mapper (Application) can reference it.
|
||||
public readonly record struct PlayoutUpcoming(int TotalUpcoming, int BrokenUpcoming);
|
||||
@@ -0,0 +1,32 @@
|
||||
#nullable enable
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
/// <summary>Server-declared browser-preview capability for a channel.</summary>
|
||||
/// <remarks>
|
||||
/// The SPA renders and acts on this; it never derives preview eligibility itself (see
|
||||
/// docs/decisions.md, api.healthcheck-remediation-dto for the same pattern). Deriving it in the
|
||||
/// SPA would mean keying behavior off the human-readable StreamingMode label.
|
||||
/// </remarks>
|
||||
public record ChannelPreviewResponseModel(
|
||||
// One of ChannelPreviewAvailability's values. A plain string (not a C# enum), which keeps the
|
||||
// OpenAPI schema simple, but the tradeoff is that the generated TypeScript types this as a bare
|
||||
// `string`, not a literal union — the SPA hand-maintains its own `ChannelPreviewAvailability`
|
||||
// union (web/src/api/channels.ts) and narrows against it, rather than getting one for free.
|
||||
string Availability,
|
||||
// Rooted, directly-usable HLS manifest URL; null when Availability is Unavailable.
|
||||
string? ManifestUrl,
|
||||
// Human-readable reason; non-null only when Availability is Unavailable.
|
||||
string? UnavailableReason);
|
||||
|
||||
public static class ChannelPreviewAvailability
|
||||
{
|
||||
/// <summary>The channel's configured mode is browser-playable; preview exercises the real pipeline.</summary>
|
||||
public const string Available = "Available";
|
||||
|
||||
/// <summary>Configured for Transport Stream; preview must force an HLS session and is content-only.</summary>
|
||||
public const string ForcedHlsOnly = "ForcedHlsOnly";
|
||||
|
||||
/// <summary>Preview cannot run at all (IPTV JWT auth is enabled and the SPA cannot mint a token).</summary>
|
||||
public const string Unavailable = "Unavailable";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
@@ -19,4 +20,11 @@ public record ChannelResponseModel(
|
||||
int PlayoutCount,
|
||||
// Rooted, directly-usable logo URL for the SPA's <img src>; null when the channel has no logo
|
||||
// (SPA then renders the generated initials fallback). See ErsatzTV.Application Channels.Mapper.GetLogoUrl.
|
||||
string? Logo);
|
||||
string? Logo,
|
||||
// Server-declared browser-preview capability; see ChannelPreviewResponseModel.
|
||||
ChannelPreviewResponseModel Preview,
|
||||
// Immutable creation-provenance (auto-tuned vs user-created). Raw fact; the SPA decides how to render it.
|
||||
// Unknown for rows created before the origin column existed (never back-filled).
|
||||
ChannelOrigin Origin,
|
||||
// Server-derived health rollup (api.channel-health-object). See ChannelHealthResponseModel.
|
||||
ChannelHealthResponseModel Health);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
// The "clear to none" signal for POST /api/v1/channels/from-lineup (#135). For these
|
||||
// template-inheritable advanced fields a null/omitted override means INHERIT the template value;
|
||||
// naming the field here forces it to NONE on the new channel even when the template sets one.
|
||||
// Omitting the field entirely keeps the historical omitted=inherit behavior stable for existing
|
||||
// clients. Sending both a set value and a clear for the same field is a validation error (see
|
||||
// CreateChannelFromLineupHandler). Lives in Core so the OpenAPI string-enum scan (Startup
|
||||
// UseStringEnumSchemas) renders it as a string enum, matching every sibling advanced-options enum.
|
||||
public enum CreateChannelFromLineupClearField
|
||||
{
|
||||
Watermark,
|
||||
FallbackFiller,
|
||||
PreRollFiller,
|
||||
MidRollFiller,
|
||||
PostRollFiller,
|
||||
PreferredAudioLanguage,
|
||||
PreferredAudioTitle,
|
||||
PreferredSubtitleLanguage
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Graphics;
|
||||
|
||||
public record GraphicsElementResponseModel(int Id, string Name);
|
||||
public record GraphicsElementResponseModel(int Id, string Name, bool BuiltIn);
|
||||
|
||||
@@ -7,6 +7,7 @@ public record PlayoutListItemResponseModel(
|
||||
int Id,
|
||||
string ChannelNumber,
|
||||
string ChannelName,
|
||||
int ChannelId,
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ScheduleName,
|
||||
TimeSpan? DailyRebuildTime,
|
||||
|
||||
@@ -13,4 +13,5 @@ public record ProgramScheduleResponseModel(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior);
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Distinct term values for a single text field in the search index, used to power the visual rule
|
||||
/// builder's facet-value typeahead.
|
||||
/// </summary>
|
||||
public record SearchFieldValuesResponseModel(List<string> Values);
|
||||
@@ -25,6 +25,8 @@ public class Channel
|
||||
public StreamingMode StreamingMode { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
public List<Artwork> Artwork { get; set; }
|
||||
public List<GraphicsElement> GraphicsElements { get; set; }
|
||||
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
|
||||
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
|
||||
public string StreamSelector { get; set; }
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
@@ -43,5 +45,8 @@ public class Channel
|
||||
public ChannelIdleBehavior IdleBehavior { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public bool ShowInEpg { get; set; }
|
||||
|
||||
// Immutable creation-provenance (auto-tuned vs user-created); stamped once at insert, never on edit.
|
||||
public ChannelOrigin Origin { get; set; }
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class ChannelGraphicsElement
|
||||
{
|
||||
public int ChannelId { get; set; }
|
||||
public Channel Channel { get; set; }
|
||||
public int GraphicsElementId { get; set; }
|
||||
public GraphicsElement GraphicsElement { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
// How a Channel row came to exist. This is immutable creation-provenance: it records how the channel
|
||||
// was born and a later user edit never changes it. Unknown is the honest default for rows that predate
|
||||
// this column — provenance was never recorded for them and is deliberately not back-filled (inferring it
|
||||
// from the "Channel Lineups" system playlist group is the mislabeling heuristic #414 rejected).
|
||||
public enum ChannelOrigin
|
||||
{
|
||||
Unknown = 0,
|
||||
UserCreated = 1,
|
||||
AutoTuned = 2
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
|
||||
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
|
||||
public static ConfigElementKey WatermarkChannelBugSeeded => new("watermark.channel_bug_seeded");
|
||||
public static ConfigElementKey GraphicsOnNowNextSeeded => new("graphics.on_now_next_seeded");
|
||||
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
|
||||
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
|
||||
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
|
||||
|
||||
@@ -16,6 +16,8 @@ public class GraphicsElement
|
||||
public List<BlockItemGraphicsElement> BlockItemGraphicsElements { get; set; }
|
||||
public List<Deco> Decos { get; set; }
|
||||
public List<DecoGraphicsElement> DecoGraphicsElements { get; set; }
|
||||
public List<Channel> Channels { get; set; }
|
||||
public List<ChannelGraphicsElement> ChannelGraphicsElements { get; set; }
|
||||
|
||||
// for unit testing
|
||||
public override string ToString() => Path;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class JellyfinMusicVideo : MusicVideo
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ProgramSchedule : IVersionedAggregate
|
||||
public bool ShuffleScheduleItems { get; set; }
|
||||
public bool RandomStartPoint { get; set; }
|
||||
public FixedStartTimeBehavior FixedStartTimeBehavior { get; set; }
|
||||
public int? PadToNearestMinute { get; set; }
|
||||
public List<ProgramScheduleItem> Items { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||
|
||||
@@ -1043,9 +1043,11 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
{
|
||||
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
|
||||
|
||||
// Percent-encode so a token containing '&'/'"' can't malform this internal segmenter URL (#421);
|
||||
// a no-op for a normal base64url JWT.
|
||||
string accessTokenQuery = string.IsNullOrWhiteSpace(accessToken)
|
||||
? string.Empty
|
||||
: $"&access_token={accessToken}";
|
||||
: $"&access_token={Uri.EscapeDataString(accessToken)}";
|
||||
|
||||
var concatInputFile = new ConcatInputFile(
|
||||
$"http://localhost:{Settings.StreamingPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter{accessTokenQuery}",
|
||||
|
||||
@@ -135,6 +135,15 @@ public class GraphicsElementSelector(IDecoSelector decoSelector, ILogger<Graphic
|
||||
|
||||
result.AddRange(playoutItem.PlayoutItemGraphicsElements);
|
||||
|
||||
// channel-level overlays are a base layer: merged with playout-item / Merge-deco elements,
|
||||
// but suppressed by a deco in Override/Disable mode (which returns before reaching here).
|
||||
if (channel.ChannelGraphicsElements is not null)
|
||||
{
|
||||
result.AddRange(
|
||||
channel.ChannelGraphicsElements.Map(cge =>
|
||||
new PlayoutItemGraphicsElement { PlayoutItem = playoutItem, GraphicsElement = cge.GraphicsElement }));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Graphics;
|
||||
|
||||
public static class GraphicsElementDefaults
|
||||
{
|
||||
// Built-in "On Now / Next" text element; identity is by filename, never by user-editable Name.
|
||||
public const string OnNowNextFileName = "on-now-next.yml";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Emby;
|
||||
|
||||
@@ -8,22 +9,40 @@ public interface IEmbyApiClient
|
||||
Task<Either<BaseError, EmbyServerInformation>> GetServerInformation(string address, string apiKey);
|
||||
Task<Either<BaseError, List<EmbyLibrary>>> GetLibraries(string address, string apiKey);
|
||||
|
||||
IAsyncEnumerable<Tuple<EmbyMovie, int>> GetMovieLibraryItems(string address, string apiKey, EmbyLibrary library);
|
||||
// #484: the four enumerations that feed a reconciliation sweep accept an optional per-enumeration
|
||||
// counter -- the two library-level ones (movies, shows) AND the two nested ones (seasons per show,
|
||||
// episodes per season), which feed the per-parent sweeps in MediaServerTelevisionLibraryScanner.
|
||||
// The caller creates one instance per enumeration, scoped to the parent it sweeps, and reads it
|
||||
// only after that enumeration completes; passing null (the default) opts out and costs existing
|
||||
// callers nothing.
|
||||
IAsyncEnumerable<Tuple<EmbyMovie, int>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<EmbyShow, int>> GetShowLibraryItems(string address, string apiKey, EmbyLibrary library);
|
||||
IAsyncEnumerable<Tuple<EmbyShow, int>> GetShowLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
// #484: the nested per-show season and per-season episode enumerations feed their own sweeps
|
||||
// (FlagFileNotFoundSeasons / FlagFileNotFoundEpisodes), so they carry the same optional counter.
|
||||
IAsyncEnumerable<Tuple<EmbySeason, int>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string showId);
|
||||
string showId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string showId,
|
||||
string seasonId);
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<EmbyCollection, int>> GetCollectionLibraryItems(string address, string apiKey);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
@@ -8,38 +9,52 @@ public interface IJellyfinApiClient
|
||||
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string authorizationHeader);
|
||||
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string authorizationHeader);
|
||||
|
||||
// #484: the six enumerations that feed a reconciliation sweep accept an optional per-enumeration
|
||||
// counter -- the three library-level ones (movies, music videos, shows) AND the three nested ones
|
||||
// (seasons per show, episodes per season, both episode variants), which feed the per-parent sweeps
|
||||
// in MediaServerTelevisionLibraryScanner. The caller creates one instance per enumeration, scoped
|
||||
// to the parent it sweeps, and reads it only after that enumeration completes; passing null (the
|
||||
// default) opts out and costs existing callers nothing.
|
||||
IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
JellyfinLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
|
||||
IAsyncEnumerable<Tuple<JellyfinMusicVideo, int>> GetMusicVideoLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
JellyfinLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
JellyfinLibrary library,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
// #484: the nested per-show season and per-season episode enumerations feed their own sweeps
|
||||
// (FlagFileNotFoundSeasons / FlagFileNotFoundEpisodes), so they carry the same optional counter.
|
||||
IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showId);
|
||||
string showId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
string seasonId,
|
||||
MediaServerProjectionFailureCounter projectionFailures = null);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
|
||||
string address,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -8,4 +9,9 @@ public interface IChannelRepository
|
||||
Task<Option<Channel>> GetByNumber(string number);
|
||||
Task<List<Channel>> GetAll(CancellationToken cancellationToken);
|
||||
Task<Option<ChannelWatermark>> GetWatermarkByName(string name);
|
||||
|
||||
Task<Dictionary<int, PlayoutUpcoming>> GetPlayoutUpcomingHealth(
|
||||
IReadOnlyCollection<int> playoutIds,
|
||||
DateTime nowUtc,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface
|
||||
IJellyfinMusicVideoRepository : IMediaServerMusicVideoRepository<JellyfinLibrary, JellyfinMusicVideo,
|
||||
JellyfinItemEtag>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IMediaServerMusicVideoRepository<in TLibrary, TMusicVideo, TEtag> where TLibrary : Library
|
||||
where TMusicVideo : MusicVideo
|
||||
where TEtag : MediaServerItemEtag
|
||||
{
|
||||
Task<List<TEtag>> GetExistingMusicVideos(TLibrary library);
|
||||
Task<Option<int>> FlagNormal(TLibrary library, TMusicVideo musicVideo);
|
||||
Task<Option<int>> FlagUnavailable(TLibrary library, TMusicVideo musicVideo);
|
||||
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> musicVideoItemIds);
|
||||
|
||||
// Unlike the movie/other-video seams, GetOrAdd takes the resolved Artist and LibraryFolder: a music video is
|
||||
// owned by an Artist (FK, not null) and ersatztv#488 requires the media file to carry its LibraryFolderId.
|
||||
//
|
||||
// `localPath` is the PATH-REPLACED local path and is the only path this seam may store or match on. The
|
||||
// `item` argument still carries the path the media server reported, which for any install with configured
|
||||
// path replacements is a DIFFERENT string — matching or storing that one would miss the row it is meant to
|
||||
// find and duplicate it under a server-side path (ersatztv#496).
|
||||
Task<Either<BaseError, MediaItemScanResult<TMusicVideo>>> GetOrAdd(
|
||||
TLibrary library,
|
||||
Artist artist,
|
||||
LibraryFolder libraryFolder,
|
||||
TMusicVideo item,
|
||||
string localPath,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// ersatztv#496: music videos that predate per-item identity carry no TEtag row, so they are invisible to the
|
||||
// itemId diff above. They are reconciled by their local path until a scan adopts them.
|
||||
Task<List<string>> GetExistingLegacyMusicVideoPaths(TLibrary library);
|
||||
Task<List<int>> FlagFileNotFoundByPaths(TLibrary library, List<string> localPaths);
|
||||
|
||||
Task<Unit> SetEtag(TMusicVideo musicVideo, string etag);
|
||||
}
|
||||
@@ -2,5 +2,14 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
public interface IPlayoutTimeShifter
|
||||
{
|
||||
Task TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
|
||||
/// <summary>
|
||||
/// Slides an on-demand playout's materialized timeline forward so the item the viewer had
|
||||
/// reached is active again at <paramref name="now" />.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The channel numbers whose cached XMLTV guide is now stale and should be rebuilt — the shifted
|
||||
/// channel plus any channels that mirror it — when a non-zero shift was persisted; otherwise an
|
||||
/// empty list.
|
||||
/// </returns>
|
||||
Task<List<string>> TimeShift(int playoutId, DateTimeOffset now, bool force, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,13 @@ public class ChannelPlaylist
|
||||
string accessTokenUriAmp = string.Empty;
|
||||
if (_accessToken != null)
|
||||
{
|
||||
accessTokenUri = $"?access_token={_accessToken}";
|
||||
accessTokenUriAmp = $"&access_token={_accessToken}";
|
||||
// Percent-encode the token: it is interpolated into URL query strings that land inside quoted
|
||||
// M3U attributes (url-tvg="...", tvg-logo="..."). A token containing a double-quote or ampersand
|
||||
// would otherwise terminate the attribute or the query early for strict parsers (#421). For a
|
||||
// normal base64url JWT this is a no-op (all characters are RFC 3986 unreserved).
|
||||
string encodedToken = Uri.EscapeDataString(_accessToken);
|
||||
accessTokenUri = $"?access_token={encodedToken}";
|
||||
accessTokenUriAmp = $"&access_token={encodedToken}";
|
||||
}
|
||||
|
||||
var xmltv = $"{_scheme}://{_host}{_baseUrl}/iptv/xmltv.xml{accessTokenUri}";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ErsatzTV.Core.Metadata;
|
||||
|
||||
// #484: the narrow seam that carries "how many items did the server return that we silently dropped
|
||||
// because their projection threw?" out of a media-server API client and back to the scanner that owns
|
||||
// the reconciliation sweep.
|
||||
//
|
||||
// Lifetime is deliberately PER ENUMERATION: the scanner that is about to run a sweep creates one
|
||||
// instance, hands it to the single library-items call whose result it will diff, and reads Count only
|
||||
// after that enumeration has completed. The API clients are long-lived singletons and scans for
|
||||
// different libraries run concurrently, so the counter must never be a field on a client or any
|
||||
// ambient/static state — that would leak one library's failures into another library's sweep decision.
|
||||
// Increments are interlocked anyway so a paginator that ever fans out stays correct.
|
||||
public sealed class MediaServerProjectionFailureCounter
|
||||
{
|
||||
private int _count;
|
||||
|
||||
/// <summary>
|
||||
/// Number of items the server returned whose projection threw and was swallowed.
|
||||
/// </summary>
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
|
||||
public void RecordFailure() => Interlocked.Increment(ref _count);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace ErsatzTV.Core.Metadata;
|
||||
|
||||
// #484: a media-server API client maps each item the server returned through a private projection. That
|
||||
// projection has TWO reasons to produce nothing, and conflating them is dangerous:
|
||||
//
|
||||
// - Skipped — a deliberate guard clause (a virtual/non-FileSystem item, a STRM file, an unsupported
|
||||
// item type). The item is permanently and expectedly absent from the incoming set; a
|
||||
// library containing one STRM file produces a Skipped on every single scan, forever.
|
||||
// - Failed — the projection threw and was swallowed by a `catch { LogWarning; }`. The server DID
|
||||
// return the item; we simply could not build it. At the reconcile step that is
|
||||
// indistinguishable from a deletion, so a projection regression can mass-flag healthy
|
||||
// items FileNotFound (which EmptyTrash then deletes permanently).
|
||||
//
|
||||
// Only Failed may suppress the reconciliation sweep (see MediaServerReconciliationGuard). Treating
|
||||
// Skipped as a failure would permanently disable reconciliation for any library holding a single STRM
|
||||
// file, so stale rows would accumulate forever — a regression, not a safe default.
|
||||
public readonly struct MediaServerProjectionResult<T>
|
||||
{
|
||||
private MediaServerProjectionResult(Option<T> item, bool isFailure)
|
||||
{
|
||||
Item = item;
|
||||
IsFailure = isFailure;
|
||||
}
|
||||
|
||||
public Option<T> Item { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True only when the projection threw. A deliberate guard-clause skip is NOT a failure.
|
||||
/// </summary>
|
||||
public bool IsFailure { get; }
|
||||
|
||||
public static MediaServerProjectionResult<T> Projected(T item) => new(item, false);
|
||||
|
||||
/// <summary>
|
||||
/// The server returned an item we deliberately and permanently do not import (virtual item, STRM
|
||||
/// file, unsupported type). Expected on every scan; never suppresses a sweep.
|
||||
/// </summary>
|
||||
public static MediaServerProjectionResult<T> Skipped() => new(Option<T>.None, false);
|
||||
|
||||
/// <summary>
|
||||
/// The projection threw and the exception was swallowed. The item exists upstream but is missing
|
||||
/// from the incoming set, so the sweep must not run.
|
||||
/// </summary>
|
||||
public static MediaServerProjectionResult<T> Failed() => new(Option<T>.None, true);
|
||||
|
||||
public static implicit operator MediaServerProjectionResult<T>(T item) => Projected(item);
|
||||
|
||||
public Option<T> ToOption() => Item;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the stateless-index <see cref="IMediaCollectionEnumerator" /> for a single content entry from its
|
||||
/// <see cref="PlaybackOrder" /> and flat item list.
|
||||
/// <para>
|
||||
/// Extracted (issue #395) so the Scripted engine (<c>SchedulingEngine.EnumeratorForContent</c>) and the
|
||||
/// Sequential/YAML engine (<c>EnumeratorCache.GetEnumeratorForContent</c>) share ONE construction switch
|
||||
/// instead of two byte-identical copies. Kept a <c>public static</c> helper with dependencies passed as
|
||||
/// parameters, matching the sibling <see cref="ShuffleSourceBuilder" /> / <see cref="MultiPartEpisodeGrouper" />
|
||||
/// seams (issue #380).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately per-family and narrow: only <see cref="PlaybackOrder.Chronological" /> and
|
||||
/// <see cref="PlaybackOrder.Shuffle" /> are supported, and Shuffle maps to the <em>block</em> variant
|
||||
/// <see cref="BlockPlayoutShuffledMediaCollectionEnumerator" /> — NOT Classic's
|
||||
/// <see cref="ShuffledMediaCollectionEnumerator" />, which is a different algorithm. Every other order returns
|
||||
/// <see cref="Option{A}" />.<c>None</c> so each caller logs its own engine-specific "not supported" warning
|
||||
/// (#70); folding the warning in here would lose that per-engine message.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ContentEnumeratorBuilder
|
||||
{
|
||||
public static Option<IMediaCollectionEnumerator> ForContent(
|
||||
List<MediaItem> items,
|
||||
CollectionEnumeratorState state,
|
||||
PlaybackOrder playbackOrder,
|
||||
bool multiPart)
|
||||
{
|
||||
switch (playbackOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
return new ChronologicalMediaCollectionEnumerator(items, state);
|
||||
case PlaybackOrder.Shuffle:
|
||||
List<GroupedMediaItem> groupedMediaItems = multiPart
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
|
||||
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
default:
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1225,25 +1225,19 @@ public class SchedulingEngine(
|
||||
PlaybackOrder playbackOrder,
|
||||
bool multiPart = false)
|
||||
{
|
||||
switch (playbackOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
return new ChronologicalMediaCollectionEnumerator(items, state);
|
||||
case PlaybackOrder.Shuffle:
|
||||
bool keepMultiPartEpisodesTogether = multiPart;
|
||||
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
|
||||
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
}
|
||||
Option<IMediaCollectionEnumerator> enumerator =
|
||||
ContentEnumeratorBuilder.ForContent(items, state, playbackOrder, multiPart);
|
||||
|
||||
// None means the caller's foreach never runs, so the content is simply absent from the playout with
|
||||
// nothing said. Report it instead of leaving a silently empty schedule (#70).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
|
||||
playbackOrder);
|
||||
if (enumerator.IsNone)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
|
||||
playbackOrder);
|
||||
}
|
||||
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
return enumerator;
|
||||
}
|
||||
|
||||
private void ApplyPlaylistHistory(
|
||||
|
||||
@@ -115,13 +115,36 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
PlayoutBuildResult result,
|
||||
PlayoutBuildMode mode,
|
||||
PlayoutParameters parameters,
|
||||
CancellationToken cancellationToken) =>
|
||||
mode switch
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// #392: the build query does not populate the ProgramScheduleItem.ProgramSchedule reverse nav
|
||||
// (AsNoTracking). Populate it so schedule-level settings (PadToNearestMinute) are readable in AddFiller.
|
||||
if (referenceData.ProgramSchedule?.Items is not null)
|
||||
{
|
||||
foreach (ProgramScheduleItem item in referenceData.ProgramSchedule.Items)
|
||||
{
|
||||
item.ProgramSchedule = referenceData.ProgramSchedule;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ProgramScheduleAlternate alternate in referenceData.ProgramScheduleAlternates)
|
||||
{
|
||||
if (alternate.ProgramSchedule?.Items is not null)
|
||||
{
|
||||
foreach (ProgramScheduleItem item in alternate.ProgramSchedule.Items)
|
||||
{
|
||||
item.ProgramSchedule = alternate.ProgramSchedule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mode switch
|
||||
{
|
||||
PlayoutBuildMode.Refresh => RefreshPlayout(playout, referenceData, result, parameters, cancellationToken),
|
||||
PlayoutBuildMode.Reset => ResetPlayout(playout, referenceData, result, parameters, cancellationToken),
|
||||
_ => ContinuePlayout(playout, referenceData, result, parameters, cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, PlayoutBuildResult>> Build(
|
||||
Playout playout,
|
||||
@@ -702,6 +725,10 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
|
||||
var copyScheduleItem = scheduleItem.DeepCopy();
|
||||
// DeepCopy uses Newtonsoft serialization, and ProgramSchedule is [JsonIgnore]'d there,
|
||||
// so the reverse nav is lost on the copy. Reassign it so schedule-level settings
|
||||
// (e.g. PadToNearestMinute) remain readable in AddFiller for Fill-With-Group items.
|
||||
copyScheduleItem.ProgramSchedule = scheduleItem.ProgramSchedule;
|
||||
copyScheduleItem.CollectionType = key.CollectionType;
|
||||
copyScheduleItem.MediaItemId = key.MediaItemId;
|
||||
copyScheduleItem.FakeCollectionKey = key.FakeCollectionKey;
|
||||
@@ -806,6 +833,12 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
|
||||
var timeCount = new Dictionary<DateTimeOffset, int>();
|
||||
|
||||
// #392: exact clock-boundary target the LAST scheduled item advanced CurrentTime to when a
|
||||
// schedule-level pad left an offline gap (no filler). The post-loop anchor clamp reads this by
|
||||
// exact CurrentTime equality so the offline advance survives the day seam — precise (tied to the
|
||||
// real offline-pad signal), not a magnitude heuristic.
|
||||
DateTimeOffset? lastClockPadOfflineTarget = null;
|
||||
|
||||
// loop until we're done filling the desired amount of time
|
||||
while (playoutBuilderState.CurrentTime < playoutFinish && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -880,6 +913,10 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
(PlayoutBuilderState nextState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings warnings) =
|
||||
schedulerResult;
|
||||
|
||||
// #392: remember the last scheduler's offline-pad target (null unless the final scheduled item
|
||||
// advanced CurrentTime to a clock boundary with no filler); the anchor clamp below reads it.
|
||||
lastClockPadOfflineTarget = schedulerResult.ClockPadOfflineTarget;
|
||||
|
||||
result.Warnings.Merge(warnings);
|
||||
|
||||
// if we completed a multiple/duration block, move to the next fill group
|
||||
@@ -924,7 +961,21 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
DateTimeOffset maxStartTime = result.AddedItems.Max(i => i.FinishOffset);
|
||||
if (maxStartTime < playoutBuilderState.CurrentTime)
|
||||
{
|
||||
playoutBuilderState = playoutBuilderState with { CurrentTime = maxStartTime };
|
||||
// #392: a schedule-level clock pad can legitimately advance CurrentTime past the last added
|
||||
// item with NO corresponding filler item (an intentional offline gap up to the next clock
|
||||
// boundary). Exempt the clamp ONLY when CurrentTime is exactly the offline-pad target the
|
||||
// final scheduled item recorded — a precise signal, not a magnitude heuristic. Any other
|
||||
// advance (a Duration/Flood/Multiple block ending short of its natural end, a hard-stop, a
|
||||
// tail-filler advance) changes CurrentTime away from that target, so it still clamps back to
|
||||
// the real content end and persists the correct NextStart.
|
||||
bool isOfflinePadAdvance =
|
||||
lastClockPadOfflineTarget is { } offlinePadTarget &&
|
||||
playoutBuilderState.CurrentTime == offlinePadTarget;
|
||||
|
||||
if (!isOfflinePadAdvance)
|
||||
{
|
||||
playoutBuilderState = playoutBuilderState with { CurrentTime = maxStartTime };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -279,7 +279,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
PlayoutBuilder.DisplayTitle(mediaItem),
|
||||
startTime);
|
||||
|
||||
internal List<PlayoutItem> AddFiller(
|
||||
internal (List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget) AddFiller(
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
|
||||
ProgramScheduleItem scheduleItem,
|
||||
@@ -290,6 +290,8 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
|
||||
DateTimeOffset? offlinePadTarget = null;
|
||||
|
||||
var allFiller = Optional(scheduleItem.PreRollFiller)
|
||||
.Append(Optional(scheduleItem.MidRollFiller))
|
||||
.Append(Optional(scheduleItem.PostRollFiller))
|
||||
@@ -299,7 +301,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
if (allFiller.Count(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue) > 1)
|
||||
{
|
||||
Logger.LogError("Multiple pad-to-nearest-minute values are invalid; no filler will be used");
|
||||
return [playoutItem];
|
||||
return ([playoutItem], null);
|
||||
}
|
||||
|
||||
// missing pad-to-nearest-minute value is invalid; use no filler
|
||||
@@ -310,7 +312,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
Logger.LogError(
|
||||
"Pad filler ({Filler}) without pad-to-nearest-minute value is invalid; no filler will be used",
|
||||
invalidPadFiller.Name);
|
||||
return [playoutItem];
|
||||
return ([playoutItem], null);
|
||||
}
|
||||
|
||||
List<MediaChapter> effectiveChapters = chapters;
|
||||
@@ -580,6 +582,48 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
}
|
||||
}
|
||||
|
||||
// #392: schedule-level clock padding. Applies only when the item has no own Pad filler preset (that
|
||||
// wins) and the parent schedule declares a positive divisor. Reuses the existing pad boundary math +
|
||||
// FallbackFillerForPad; when no fallback content exists, records an offline target so the caller
|
||||
// advances the build clock to the boundary (leaving an offline gap).
|
||||
bool itemHasPadFiller =
|
||||
allFiller.Any(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue);
|
||||
if (!itemHasPadFiller &&
|
||||
scheduleItem.ProgramSchedule?.PadToNearestMinute is int schedulePadMinutes &&
|
||||
schedulePadMinutes > 0)
|
||||
{
|
||||
TimeSpan totalDuration = result.Aggregate(
|
||||
TimeSpan.Zero,
|
||||
(acc, i) => acc + (i.FinishOffset - i.StartOffset));
|
||||
|
||||
DateTimeOffset targetTime = ComputePadBoundary(
|
||||
playoutItem.StartOffset, totalDuration, schedulePadMinutes);
|
||||
|
||||
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
|
||||
if (remainingToFill > TimeSpan.Zero)
|
||||
{
|
||||
Option<PlayoutItem> maybeFallback = FallbackFillerForPad(
|
||||
playoutBuilderState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
remainingToFill,
|
||||
cancellationToken);
|
||||
|
||||
if (maybeFallback.IsSome)
|
||||
{
|
||||
foreach (PlayoutItem fallbackItem in maybeFallback)
|
||||
{
|
||||
result.Add(fallbackItem);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No fallback content: leave an offline gap up to the boundary.
|
||||
offlinePadTarget = targetTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// after all non-padded filler has been added, figure out padding
|
||||
foreach (FillerPreset padFiller in Optional(
|
||||
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
|
||||
@@ -601,29 +645,9 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
totalDuration += TimeSpan.FromTicks(filteredChapters.Sum(c => (c.EndTime - c.StartTime).Ticks));
|
||||
}
|
||||
|
||||
int currentMinute = (playoutItem.StartOffset + totalDuration).Minute;
|
||||
// ReSharper disable once PossibleInvalidOperationException
|
||||
int targetMinute = (currentMinute + padFiller.PadToNearestMinute.Value - 1) /
|
||||
padFiller.PadToNearestMinute.Value * padFiller.PadToNearestMinute.Value;
|
||||
|
||||
DateTimeOffset almostTargetTime = playoutItem.StartOffset + totalDuration -
|
||||
TimeSpan.FromMinutes(currentMinute) +
|
||||
TimeSpan.FromMinutes(targetMinute);
|
||||
|
||||
var targetTime = new DateTimeOffset(
|
||||
almostTargetTime.Year,
|
||||
almostTargetTime.Month,
|
||||
almostTargetTime.Day,
|
||||
almostTargetTime.Hour,
|
||||
almostTargetTime.Minute,
|
||||
0,
|
||||
almostTargetTime.Offset);
|
||||
|
||||
// ensure filler works for content less than one minute
|
||||
if (targetTime <= playoutItem.StartOffset + totalDuration)
|
||||
{
|
||||
targetTime = targetTime.AddMinutes(padFiller.PadToNearestMinute.Value);
|
||||
}
|
||||
DateTimeOffset targetTime = ComputePadBoundary(
|
||||
playoutItem.StartOffset, totalDuration, padFiller.PadToNearestMinute.Value);
|
||||
|
||||
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
|
||||
|
||||
@@ -763,7 +787,40 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
currentTime = item.FinishOffset;
|
||||
}
|
||||
|
||||
return result;
|
||||
return (result, offlinePadTarget);
|
||||
}
|
||||
|
||||
// #392: shared clock-boundary ceiling used by both the item-level Pad filler and the schedule-level pad.
|
||||
// Returns the next `padToNearestMinute` boundary at or after (blockStart + totalDuration); when the block
|
||||
// already ends exactly on a boundary it advances one full interval (matches the pre-#392 Pad behavior).
|
||||
private static DateTimeOffset ComputePadBoundary(
|
||||
DateTimeOffset blockStart,
|
||||
TimeSpan totalDuration,
|
||||
int padToNearestMinute)
|
||||
{
|
||||
int currentMinute = (blockStart + totalDuration).Minute;
|
||||
int targetMinute = (currentMinute + padToNearestMinute - 1) / padToNearestMinute * padToNearestMinute;
|
||||
|
||||
DateTimeOffset almostTargetTime = blockStart + totalDuration -
|
||||
TimeSpan.FromMinutes(currentMinute) +
|
||||
TimeSpan.FromMinutes(targetMinute);
|
||||
|
||||
var targetTime = new DateTimeOffset(
|
||||
almostTargetTime.Year,
|
||||
almostTargetTime.Month,
|
||||
almostTargetTime.Day,
|
||||
almostTargetTime.Hour,
|
||||
almostTargetTime.Minute,
|
||||
0,
|
||||
almostTargetTime.Offset);
|
||||
|
||||
// ensure filler works for content less than one interval (and for content already on a boundary)
|
||||
if (targetTime <= blockStart + totalDuration)
|
||||
{
|
||||
targetTime = targetTime.AddMinutes(padToNearestMinute);
|
||||
}
|
||||
|
||||
return targetTime;
|
||||
}
|
||||
|
||||
private List<PlayoutItem> AddCountFiller(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -36,6 +36,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
var willFinishInTime = true;
|
||||
Option<DateTimeOffset> durationUntil = None;
|
||||
var discardAttempts = 0;
|
||||
DateTimeOffset? clockPadOfflineTarget = null;
|
||||
|
||||
IMediaCollectionEnumerator contentEnumerator =
|
||||
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
|
||||
@@ -200,7 +201,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
enumeratorStates.Add(key, enumerator.State.Clone());
|
||||
}
|
||||
|
||||
List<PlayoutItem> maybePlayoutItems = AddFiller(
|
||||
(List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
nextState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
@@ -219,6 +220,12 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
// }
|
||||
|
||||
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
|
||||
DateTimeOffset? itemClockPadOfflineTarget = null;
|
||||
if (clockPadTarget is { } durPadTarget && durPadTarget > itemEndTimeWithFiller)
|
||||
{
|
||||
itemEndTimeWithFiller = durPadTarget;
|
||||
itemClockPadOfflineTarget = durPadTarget;
|
||||
}
|
||||
|
||||
willFinishInTime = itemStartTime > durationFinish ||
|
||||
itemEndTimeWithFiller <= durationFinish;
|
||||
@@ -226,6 +233,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
{
|
||||
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
|
||||
playoutItems.AddRange(maybePlayoutItems);
|
||||
clockPadOfflineTarget = itemClockPadOfflineTarget;
|
||||
|
||||
nextState = nextState with
|
||||
{
|
||||
@@ -350,7 +358,10 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
|
||||
{
|
||||
ClockPadOfflineTarget = clockPadOfflineTarget
|
||||
};
|
||||
}
|
||||
|
||||
protected override string SchedulingContextName => "Duration";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -23,6 +23,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
|
||||
PlayoutBuilderState nextState = playoutBuilderState;
|
||||
var willFinishInTime = true;
|
||||
DateTimeOffset? clockPadOfflineTarget = null;
|
||||
|
||||
IMediaCollectionEnumerator contentEnumerator =
|
||||
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
|
||||
@@ -112,7 +113,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
enumeratorStates.Add(key, enumerator.State.Clone());
|
||||
}
|
||||
|
||||
List<PlayoutItem> maybePlayoutItems = AddFiller(
|
||||
(List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
nextState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
@@ -122,6 +123,12 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
cancellationToken);
|
||||
|
||||
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
|
||||
DateTimeOffset? itemClockPadOfflineTarget = null;
|
||||
if (clockPadTarget is { } floodPadTarget && floodPadTarget > itemEndTimeWithFiller)
|
||||
{
|
||||
itemEndTimeWithFiller = floodPadTarget;
|
||||
itemClockPadOfflineTarget = floodPadTarget;
|
||||
}
|
||||
|
||||
// if the next schedule item is supposed to start during this item,
|
||||
// don't schedule this item and just move on
|
||||
@@ -131,6 +138,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
if (willFinishInTime)
|
||||
{
|
||||
playoutItems.AddRange(maybePlayoutItems);
|
||||
clockPadOfflineTarget = itemClockPadOfflineTarget;
|
||||
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
|
||||
|
||||
nextState = nextState with
|
||||
@@ -204,7 +212,10 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
|
||||
{
|
||||
ClockPadOfflineTarget = clockPadOfflineTarget
|
||||
};
|
||||
}
|
||||
|
||||
protected override string SchedulingContextName => "Flood";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -77,6 +77,8 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeOffset? clockPadOfflineTarget = null;
|
||||
|
||||
while (contentEnumerator.Current.IsSome && nextState.MultipleRemaining > 0 &&
|
||||
nextState.CurrentTime < hardStop)
|
||||
{
|
||||
@@ -135,19 +137,30 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
|
||||
|
||||
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
|
||||
|
||||
playoutItems.AddRange(
|
||||
AddFiller(
|
||||
nextState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
playoutItem,
|
||||
itemChapters,
|
||||
warnings,
|
||||
cancellationToken));
|
||||
(List<PlayoutItem> filled, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
nextState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
playoutItem,
|
||||
itemChapters,
|
||||
warnings,
|
||||
cancellationToken);
|
||||
playoutItems.AddRange(filled);
|
||||
|
||||
DateTimeOffset multipleEnd = playoutItems.Max(pi => pi.FinishOffset);
|
||||
if (clockPadTarget is { } mulPadTarget && mulPadTarget > multipleEnd)
|
||||
{
|
||||
multipleEnd = mulPadTarget;
|
||||
clockPadOfflineTarget = mulPadTarget;
|
||||
}
|
||||
else
|
||||
{
|
||||
clockPadOfflineTarget = null;
|
||||
}
|
||||
|
||||
nextState = nextState with
|
||||
{
|
||||
CurrentTime = playoutItems.Max(pi => pi.FinishOffset),
|
||||
CurrentTime = multipleEnd,
|
||||
MultipleRemaining = nextState.MultipleRemaining.Map(i => i - 1),
|
||||
|
||||
// only bump guide group if we don't have a custom title
|
||||
@@ -205,7 +218,10 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
|
||||
{
|
||||
ClockPadOfflineTarget = clockPadOfflineTarget
|
||||
};
|
||||
}
|
||||
|
||||
protected override string SchedulingContextName => "Multiple";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -82,7 +82,7 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
|
||||
});
|
||||
}
|
||||
|
||||
List<PlayoutItem> playoutItems = AddFiller(
|
||||
(List<PlayoutItem> playoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
playoutBuilderState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
@@ -91,9 +91,17 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
|
||||
warnings,
|
||||
cancellationToken);
|
||||
|
||||
DateTimeOffset oneEnd = playoutItems.Max(pi => pi.FinishOffset);
|
||||
DateTimeOffset? clockPadOfflineTarget = null;
|
||||
if (clockPadTarget is { } onePadTarget && onePadTarget > oneEnd)
|
||||
{
|
||||
oneEnd = onePadTarget;
|
||||
clockPadOfflineTarget = onePadTarget;
|
||||
}
|
||||
|
||||
PlayoutBuilderState nextState = playoutBuilderState with
|
||||
{
|
||||
CurrentTime = playoutItems.Max(pi => pi.FinishOffset)
|
||||
CurrentTime = oneEnd
|
||||
};
|
||||
|
||||
nextState.ScheduleItemsEnumerator.MoveNext();
|
||||
@@ -133,7 +141,10 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
|
||||
{
|
||||
ClockPadOfflineTarget = clockPadOfflineTarget
|
||||
};
|
||||
}
|
||||
|
||||
return new PlayoutSchedulerResult(playoutBuilderState, [], warnings);
|
||||
|
||||
@@ -5,4 +5,11 @@ namespace ErsatzTV.Core.Scheduling;
|
||||
public record PlayoutSchedulerResult(
|
||||
PlayoutBuilderState State,
|
||||
List<PlayoutItem> PlayoutItems,
|
||||
PlayoutBuildWarnings Warnings);
|
||||
PlayoutBuildWarnings Warnings)
|
||||
{
|
||||
// #392: the exact clock-boundary target the last scheduled item advanced CurrentTime to when a
|
||||
// schedule-level pad left an OFFLINE gap (no filler emitted). Non-null ONLY for such an advance; the
|
||||
// day-boundary anchor clamp in PlayoutBuilder reads it (by exact CurrentTime equality) so an offline
|
||||
// pad advance survives the seam. Transient/in-memory only — never serialized to the PlayoutAnchor.
|
||||
public DateTimeOffset? ClockPadOfflineTarget { get; init; }
|
||||
}
|
||||
|
||||
@@ -180,26 +180,20 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
}
|
||||
|
||||
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
|
||||
switch (parsedOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
return new ChronologicalMediaCollectionEnumerator(items, state);
|
||||
case PlaybackOrder.Shuffle:
|
||||
bool keepMultiPartEpisodesTogether = content.MultiPart;
|
||||
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
|
||||
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
}
|
||||
Option<IMediaCollectionEnumerator> enumerator =
|
||||
ContentEnumeratorBuilder.ForContent(items, state, parsedOrder, content.MultiPart);
|
||||
|
||||
// this path schedules nothing for the content, which is indistinguishable from "no items" downstream.
|
||||
// Orders are addressed by name here, so any order the enum knows parses fine and then lands here --
|
||||
// say so, rather than leaving an empty schedule to be explained (#70).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
|
||||
parsedOrder);
|
||||
if (enumerator.IsNone)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
|
||||
parsedOrder);
|
||||
}
|
||||
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
return enumerator;
|
||||
}
|
||||
|
||||
private record PlaylistKey(string ContentKey, CollectionKey CollectionKey);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Filter;
|
||||
using ErsatzTV.FFmpeg.Filter.Cuda;
|
||||
using ErsatzTV.FFmpeg.Filter.Qsv;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Tests.Filter;
|
||||
|
||||
[TestFixture]
|
||||
public class OverlayWatermarkFilterTests
|
||||
{
|
||||
private static readonly FrameSize Resolution = new(1920, 1080);
|
||||
private static readonly FrameSize SquarePixelFrameSize = new(1920, 1080);
|
||||
|
||||
// a pillarboxed source: 1440x1080 of real content inside a 1920x1080 frame, so
|
||||
// SourceContentMargins() adds half the 480px horizontal padding to the margin
|
||||
private static readonly FrameSize PillarboxedSquarePixelFrameSize = new(1440, 1080);
|
||||
|
||||
private static WatermarkState StateFor(WatermarkLocation location, bool placeWithinSourceContent = false) =>
|
||||
new(
|
||||
Option<List<WatermarkFadePoint>>.None,
|
||||
location,
|
||||
WatermarkSize.ActualSize,
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
100,
|
||||
placeWithinSourceContent);
|
||||
|
||||
private static readonly (WatermarkLocation Location, string Expected)[] ExpectedPositions =
|
||||
[
|
||||
(WatermarkLocation.BottomRight, "x=W-w-192:y=H-h-108"),
|
||||
(WatermarkLocation.BottomLeft, "x=192:y=H-h-108"),
|
||||
(WatermarkLocation.TopRight, "x=W-w-192:y=108"),
|
||||
(WatermarkLocation.TopLeft, "x=192:y=108"),
|
||||
(WatermarkLocation.TopMiddle, "x=(W-w)/2:y=108"),
|
||||
(WatermarkLocation.RightMiddle, "x=W-w-192:y=(H-h)/2"),
|
||||
(WatermarkLocation.BottomMiddle, "x=(W-w)/2:y=H-h-108"),
|
||||
(WatermarkLocation.LeftMiddle, "x=192:y=(H-h)/2"),
|
||||
(WatermarkLocation.MiddleCenter, "x=(W-w)/2:y=(H-h)/2")
|
||||
];
|
||||
|
||||
private static IEnumerable<TestCaseData> AllLocations() =>
|
||||
ExpectedPositions.Select(x => new TestCaseData(x.Location, x.Expected).SetName(
|
||||
$"{{m}}({x.Location})"));
|
||||
|
||||
[TestCaseSource(nameof(AllLocations))]
|
||||
public void Software_Overlay_Maps_Every_Location_To_Exact_Position(WatermarkLocation location, string expectedPosition)
|
||||
{
|
||||
var filter = new OverlayWatermarkFilter(
|
||||
StateFor(location),
|
||||
Resolution,
|
||||
SquarePixelFrameSize,
|
||||
new PixelFormatUnknown(),
|
||||
NullLogger.Instance);
|
||||
|
||||
filter.Filter.ShouldBe($"overlay={expectedPosition}:format=0");
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(AllLocations))]
|
||||
public void Cuda_Overlay_Maps_Every_Location_To_Exact_Position(WatermarkLocation location, string expectedPosition)
|
||||
{
|
||||
var filter = new OverlayWatermarkCudaFilter(
|
||||
StateFor(location),
|
||||
Resolution,
|
||||
SquarePixelFrameSize,
|
||||
NullLogger.Instance);
|
||||
|
||||
filter.Filter.ShouldBe($"overlay_cuda={expectedPosition}");
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(AllLocations))]
|
||||
public void Qsv_Overlay_Maps_Every_Location_To_Exact_Position(WatermarkLocation location, string expectedPosition)
|
||||
{
|
||||
var filter = new OverlayWatermarkQsvFilter(
|
||||
StateFor(location),
|
||||
Resolution,
|
||||
SquarePixelFrameSize,
|
||||
NullLogger.Instance);
|
||||
|
||||
filter.Filter.ShouldBe($"overlay_qsv={expectedPosition}");
|
||||
}
|
||||
|
||||
// #503: the position switch has a catch-all default that silently renders bottom-right, so a
|
||||
// newly added WatermarkLocation would compile, pass every case above, and reintroduce the exact
|
||||
// bug this fixture exists to prevent. Fail here instead, at the point the enum grows.
|
||||
[Test]
|
||||
public void Every_WatermarkLocation_Has_An_Expected_Position()
|
||||
{
|
||||
IEnumerable<WatermarkLocation> covered = ExpectedPositions.Select(x => x.Location);
|
||||
|
||||
Enum.GetValues<WatermarkLocation>().Except(covered).ShouldBeEmpty(
|
||||
"every WatermarkLocation needs an explicit arm in OverlayWatermarkFilter.Position and a "
|
||||
+ "matching entry in ExpectedPositions; otherwise it silently renders bottom-right");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Source_Content_Margins_Offset_By_Half_The_Pillarbox_Padding()
|
||||
{
|
||||
var filter = new OverlayWatermarkFilter(
|
||||
StateFor(WatermarkLocation.BottomRight, placeWithinSourceContent: true),
|
||||
Resolution,
|
||||
PillarboxedSquarePixelFrameSize,
|
||||
new PixelFormatUnknown(),
|
||||
NullLogger.Instance);
|
||||
|
||||
// horizontal: round(0.10 * 1440 + 480 / 2) = 384; vertical: round(0.10 * 1080 + 0) = 108
|
||||
filter.Filter.ShouldBe("overlay=x=W-w-384:y=H-h-108:format=0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unmapped_Location_Falls_Back_To_BottomRight_Position()
|
||||
{
|
||||
var filter = new OverlayWatermarkFilter(
|
||||
StateFor((WatermarkLocation)999),
|
||||
Resolution,
|
||||
SquarePixelFrameSize,
|
||||
new PixelFormatUnknown(),
|
||||
NullLogger.Instance);
|
||||
|
||||
filter.Filter.ShouldBe("overlay=x=W-w-192:y=H-h-108:format=0");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -36,18 +36,35 @@ public class OverlayWatermarkFilter : BaseFilter
|
||||
? SourceContentMargins()
|
||||
: NormalMargins();
|
||||
|
||||
return _watermarkState.Location switch
|
||||
switch (_watermarkState.Location)
|
||||
{
|
||||
// TODO: can these be pre-calculated (and used with accelerated overlay filters)
|
||||
WatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
|
||||
WatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
|
||||
WatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
|
||||
WatermarkLocation.TopMiddle => $"x=(W-w)/2:y={verticalMargin}",
|
||||
WatermarkLocation.RightMiddle => $"x=W-w-{horizontalMargin}:y=(H-h)/2",
|
||||
WatermarkLocation.BottomMiddle => $"x=(W-w)/2:y=H-h-{verticalMargin}",
|
||||
WatermarkLocation.LeftMiddle => $"x={horizontalMargin}:y=(H-h)/2",
|
||||
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
|
||||
};
|
||||
case WatermarkLocation.BottomLeft:
|
||||
return $"x={horizontalMargin}:y=H-h-{verticalMargin}";
|
||||
case WatermarkLocation.TopLeft:
|
||||
return $"x={horizontalMargin}:y={verticalMargin}";
|
||||
case WatermarkLocation.TopRight:
|
||||
return $"x=W-w-{horizontalMargin}:y={verticalMargin}";
|
||||
case WatermarkLocation.TopMiddle:
|
||||
return $"x=(W-w)/2:y={verticalMargin}";
|
||||
case WatermarkLocation.RightMiddle:
|
||||
return $"x=W-w-{horizontalMargin}:y=(H-h)/2";
|
||||
case WatermarkLocation.BottomMiddle:
|
||||
return $"x=(W-w)/2:y=H-h-{verticalMargin}";
|
||||
case WatermarkLocation.LeftMiddle:
|
||||
return $"x={horizontalMargin}:y=(H-h)/2";
|
||||
case WatermarkLocation.MiddleCenter:
|
||||
return $"x=(W-w)/2:y=(H-h)/2";
|
||||
case WatermarkLocation.BottomRight:
|
||||
return $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}";
|
||||
default:
|
||||
// unmapped enum value; log loudly and fall back to the BottomRight position
|
||||
// rather than throwing, since this runs on the playback hot path
|
||||
_logger.LogWarning(
|
||||
"Unrecognized watermark location {Location}; falling back to bottom-right overlay position",
|
||||
_watermarkState.Location);
|
||||
return $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+7303
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_ChannelGraphicsElement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChannelGraphicsElement",
|
||||
columns: table => new
|
||||
{
|
||||
ChannelId = table.Column<int>(type: "int", nullable: false),
|
||||
GraphicsElementId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChannelGraphicsElement", x => new { x.ChannelId, x.GraphicsElementId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ChannelGraphicsElement_Channel_ChannelId",
|
||||
column: x => x.ChannelId,
|
||||
principalTable: "Channel",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ChannelGraphicsElement_GraphicsElement_GraphicsElementId",
|
||||
column: x => x.GraphicsElementId,
|
||||
principalTable: "GraphicsElement",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ChannelGraphicsElement_GraphicsElementId",
|
||||
table: "ChannelGraphicsElement",
|
||||
column: "GraphicsElementId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChannelGraphicsElement");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7306
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_ProgramSchedule_PadToNearestMinute : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PadToNearestMinute",
|
||||
table: "ProgramSchedule",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PadToNearestMinute",
|
||||
table: "ProgramSchedule");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7309
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_Channel_Origin : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Origin",
|
||||
table: "Channel",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Origin",
|
||||
table: "Channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7337
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_JellyfinMusicVideo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JellyfinMusicVideo",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false),
|
||||
ItemId = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Etag = table.Column<string>(type: "varchar(36)", unicode: false, maxLength: 36, nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JellyfinMusicVideo", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JellyfinMusicVideo_MusicVideo_Id",
|
||||
column: x => x.Id,
|
||||
principalTable: "MusicVideo",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JellyfinMusicVideo_ItemId",
|
||||
table: "JellyfinMusicVideo",
|
||||
column: "ItemId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JellyfinMusicVideo");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user