Seasonal / date-conditional channels & schedule rules #73
Closed
opened 2026-07-01 23:44:23 +02:00 by timothy
·
3 comments
No Branch/Tag Specified
main
renovate/lucene.net
renovate/dotnet-monorepo
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
renovate/meziantou.analyzer-3.x
renovate/cliwrap-3.x
913-scripted-playout-401
release/v26.15.0-notes
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Scheduling: refactor + distribution
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#73
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Concept borrowed from PseudoTV (holiday/seasonal channels).
Goal
Channels or schedule items that surface content based on the date — e.g. a holiday channel that only airs (or prioritizes) seasonal content in December.
Scope
Notes
Related: #59.
🔵 Claiming (Claude Code / Opus 4.8 orchestrator session).
How selection landed here — backlog-mode cascade, live state:
priority:backlog.Auto-Tune DetailPanel(11),Scheduling: refactor + distribution(10),Design system(6).in-progress(claimed 08:49 today), #385 is blocked-by open #70, #386 is blocked-by open #385 + #384.Design systemtier-1 member #59 is likewise an epic container.priority: low, no open deps; #395 is blocked-by open #381). Tie on priority → lowest issue number → #73.Coordination note (shared surface, not a duplicate pickup): #70 (Weighted / fair-share content distribution) was claimed at 09:06 today and is actively reworking playout-builder selection logic across the ~5
PlaybackOrderswitch sites — the same surface a date predicate in the playout builder would touch. Claiming #73 prevents duplicate pickup, not overlapping edits, so I'm scoping this to avoid colliding with that in-flight work and will re-check #70's landed shape before touching any shared scheduling file.First step (before any design): per the standing "recon whether the feature already exists" rule, this issue's premise — "ErsatzTV has no native date-conditional scheduling today" — is exactly the kind of claim this fork has repeatedly falsified (the fork imported the full upstream codebase; #77's clock-boundary padding turned out to be fully present via
FillerMode.Pad, and re-scoped to verify+document). Upstream has a Deco / DecoTemplate concept that I believe already carries date-range gating. Recon is running now against the Deco entities, the playout builders/scheduling engines, the schedule/block item entities, and the search-query date fields.Outcome of that recon decides the shape of this issue: a genuine build, or a re-scope to verify + document (+ a possible SPA-reachability gap). I'll post the verdict here before proposing anything.
Also flagging: #73 currently has no
## Done-whensection, so the H6/H10 merge gate would fall back toask. I'll add one once recon fixes the real scope.🔎 Recon verdict: this issue's premise is false — date-conditional scheduling FULLY EXISTS.
The body says "ErsatzTV has no native date-conditional scheduling today; you'd hand-build it seasonally." That is not the case. It is a first-class, shipped, SPA-reachable feature on both scheduling engines — and it's already documented in this fork. Same pattern as #77 (clock-boundary padding turned out to be fully present via
FillerMode.Pad).The predicate
ErsatzTV.Core/Domain/Scheduling/IAlternateScheduleItem.cs:3-16:DaysOfWeek,DaysOfMonth,MonthsOfYear,LimitToDateRange,StartMonth/StartDay/StartYear?,EndMonth/EndDay/EndYear?.Nullable
StartYear/EndYearis the seasonal switch: null years = repeats every year (the holiday-channel case); set years = a one-off absolute window.Two implementers, one per engine:
ProgramScheduleAlternate.cs— gates whichProgramSchedulea classic playout uses on a given date.Scheduling/PlayoutTemplate.cs— gates whichTemplate(+ optionalDecoTemplate) a block playout uses on a given date.The evaluator
ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs:22-108—GetScheduleForDate<T>, first-match-wins inIndexorder, broadest/unconditional row last = catch-all default. It already handles both hard cases: year-wrapping ranges (Nov 1 → Feb 1) and invalid/leap dates (Feb 31 clamped, not thrown).Wired into every engine:
PlayoutBuilder.cs:572,:956(classic, incl. anchor replay),BlockScheduling/EffectiveBlock.cs:19(block),BlockPlayoutFillerBuilder.cs:486(filler),FFmpeg/DecoSelector.cs:24(deco at stream time).Reachable from the SPA today — yes
web/src/screens/PlayoutScheduleEditors.tsx:267-300ships a "Limit to date range" checkbox + start/end month/day/year selects for both editors;playoutTemplateCalendar.ts:54renders a per-date calendar preview (wrap-around covered byplayoutTemplateCalendar.test.ts:59-100). Routes/app/playouts/{id}/alternate-schedules+/templates; APIGET|PUT /api/v1/playouts/{id}/alternate-schedules.Already documented:
docs/domain-model.md:57-58,64-65anddocs/channels.md:74-76— the latter literally says "Useful for seasonal programming."Correcting the issue body's Deco hypothesis
The body suggests "a Deco-like modifier". Decos do not carry dates:
DecoTemplateItem.cs:10-11isTimeSpan StartTime/EndTime— time-of-day, not date.Deco,DecoTemplate,Template,Block,BlockItem,Playlist,ProgramSchedule,ProgramScheduleItemcarry no date field at all. The date dimension lives exclusively on thePlayoutTemplate/ProgramScheduleAlternatejoin row. Composition is PlayoutTemplate (date range) → Template (time-of-day grid) → Block (content). Upstream provenance: migrations20240403150903_PlayoutTemplate_OptionalDateRange,20240403180938_Rework_PlayoutTemplate_ActiveDateRange.Residual gaps vs. what #73 asked
PlayoutTemplaterow'sDecoTemplateId); aPlayout.DecoIddefault deco is not date-gated at all.Where this is heading
My read is that #73 is mostly already done, and the honest residue is a docs/UX-discoverability problem plus a #70-owned weighting ask — not a scheduling-engine build. Because the evidence contradicts the issue body, I've escalated the disposition (close vs. re-scope-to-verify+document vs. narrow build) to a frontier judge rather than deciding it unilaterally. Verdict to follow here; no code will be written before it lands.
✅ Closing — already implemented. Frontier verdict is in and executed. Docs landed on
mainat62c15da4.What was done
No production code. The feature #73 asked for already exists; the only real gap was discoverability, and it was a docs gap — the mechanism was documented as a mechanism, never as a task. Fixed with a task-shaped recipe.
Root cause of the issue (why #73 existed at all)
The feature is named "Alternate Schedules" (classic) / "Playout Templates" (block). A user thinking "holiday channel" has no reason to look there, and on finding
channels.md:76("useful for seasonal programming") gets one clause and no worked steps. So the capability read as absent and got filed as a build. Same class as #77 (clock-boundary padding was fully present viaFillerMode.Pad).Issue-ask → shipped mechanism
IAlternateScheduleItem.LimitToDateRange+StartMonth/Day/Year?/EndMonth/Day/Year?→ProgramScheduleAlternate(classic),PlayoutTemplate(block); evaluated byAlternateScheduleSelector.GetScheduleForDate(first-match byIndex, catch-all last)/app/playouts/{id}/alternate-schedules,/app/playouts/{id}/templateschannels.mdrecipechannels.mdgotchasDecoTemplateItem.StartTime/EndTime= time-of-day). Date-gate a deco via thePlayoutTemplaterow'sDecoTemplateId/app/playouts/{id}/templatesdomain-model.mdrowdecisions.mddecisions.mdThe load-bearing undocumented fact (
AlternateScheduleSelector.cs:32-40, verified by reading it, not assumed): years default to the queried date's year, so blank years = repeats every year — the "set once, works every December" switch. The override branch requires bothStartYearandEndYear(setting only one silently stays yearly), and explicit years forcereverse = false, disabling wrap-around detection. That appeared in no doc anywhere and is now in all three.Why closed, not #77-style "re-scope to verify + document"
#77 had two real gaps: undocumented and untested. #73 has neither —
AlternateScheduleSelectorTests.cs(year-wrap + Feb-31 clamping) andplayoutTemplateCalendar.test.tsalready existed on main, as diddomain-model.md:57-58/channels.md:74-76. Even #77's re-scope target was already met. Under the close-don't-park convention (decisions.md2026-07-17), holding #73 open for a hypothetical future build is exactly the parking that convention forbids. Reopen instantly if revived.Deferred / rejected (deliberate, not overlooked)
PlaybackOrderswitch sites). Once #70 lands it's pure composition. Building a second weighting primitive here while #70 is claimed and mid-flight in the same milestone would have been a direct collision. Composition note posted on #70 (the one caveat: weighting must live on the schedule item/block, not the playout, or the date-gate can't vary it).ProgramScheduleItemrows touches the classic playout builder's core loop for zero new expressible behaviour, with regression risk in the exact engine #163/#380 just built golden nets around.Playout.DecoId— rejected: the gated path exists (PlayoutTemplate.DecoTemplateId); a date-conditional fallback is a contradiction in terms.ShowInEpg). Per close-don't-park, no placeholder issue filed — refile if a real need appears.Files changed
docs/channels.md— "Recipe: seasonal / holiday programming" (both engines, ~5 steps each + 5 gotchas)docs/domain-model.md— glossary row (mirrors the #77 "already exists" row)docs/decisions.md— verdict entry + index linkVerification
No live-E2E — no production code changed, so the write-path/500 justification doesn't apply (same reasoning as #77). The mechanism stays pinned by the existing
AlternateScheduleSelectorTests+playoutTemplateCalendar.test.ts. The one claim worth verifying was the yearly-repeat semantics, and that was confirmed by readingAlternateScheduleSelector.cs:32-40directly before writing it into the docs. Independent review: skipped under the stated rubric (docs-only, no server-state effect) — stating the exemption rather than staying silent.Follow-ups created
None, deliberately (see close-don't-park above).