Roadmap: After Dark editorial events (Hammer Horror Double Bill, Universal Monsters Takeover, J-Horror After Midnight), proposals only
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m53s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m37s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-04 09:09:33 +00:00
co-authored by Claude Opus 5.5
parent e0d1450011
commit 0f7ce59166
4 changed files with 75 additions and 11 deletions
@@ -374,7 +374,7 @@ internal val PpvSunday = listOf("WWE WrestleMania III", "WCW Capital Combat: The
"IMPACT Wrestling: Bound for Glory 2019", "WCW Bash at the Beach 1996", "WWE WrestleMania XV", "NWA Chi-Town Rumble", "AEW Full Gear (2021)",
"WWE Survivor Series 1997", "ECW November To Remember 1998", "WCW Spring Stampede 1994", "WWE King of the Ring 1998")
// Weighted rotation for seven more channels (approved 4 November 2026). Rewind and Dark Files wait on a repeat investigation;
// Weighted rotation for seven more channels (approved 4 November 2026). Rewind and Dark Files follow below, with the catalogue-size hybrid;
// Riot, Terra, Ring X and Junior keep equal turns.
/** Orbit 1's tiers, from docs/channels/orbit-1.md (approved 4 November 2026): core 3, surprises 1, everything else 2. */
internal val Orbit1Tiers: Map<String, Double> get() =
@@ -401,3 +401,10 @@ internal val HeroesTiers: Map<String, Double> get() =
/** Vortex's tiers, from docs/channels/vortex.md (approved 4 November 2026): core 3, surprises 1, everything else 2. */
internal val VortexTiers: Map<String, Double> get() =
listOf("Batman Beyond", "Cardcaptor Sakura", "Dragon Ball", "Dragon Ball GT", "Dragon Ball Z", "Jonny Quest", "Gundam Wing", "Mobile Suit Gundam Wing", "Mortal Kombat: Defenders of the Realm", "Outlaw Star", "Pokémon", "Sailor Moon", "Samurai Jack", "Tenchi Muyo", "Tenchi Muyo! Ryo-Ohki", "Tenchi Universe", "Tenchi Muyo! Tenchi Universe", "The Big O", "ThunderCats", "Yu Yu Hakusho", "Yu-Gi-Oh! Duel Monsters").associate { seriesKey(it) to TIER_CORE }
// Rewind and Dark Files join with the catalogue-size hybrid (approved 4 November 2026), as Vortex does.
/** Rewind's tiers, from docs/channels/rewind.md: the core 3, everything else 2. */
internal val RewindTiers: Map<String, Double> get() = RewindCore.associate { seriesKey(it) to TIER_CORE }
/** Dark Files' tiers, from docs/channels/dark-files.md: the core 3, everything else 2. ("Haunted" is its own series, not Most Haunted.) */
internal val DarkFilesTiers: Map<String, Double> get() =
listOf("Forensic Files", "Cold Case Files", "Snapped", "Unsolved Mysteries", "Most Haunted", "Ghost Adventures", "Paranormal Witness").associate { seriesKey(it) to TIER_CORE }
@@ -45,9 +45,18 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se
*/
val tierWeights: Map<String, Double>? = null,
/** A film channel's everyday blocks rest each film for a share of how long their own films take to play through ([adaptiveRestMs]). */
val adaptiveFilmRest: Boolean = false) {
/** A show's weight here, by its tier; a show the brief doesn't name counts as supporting. */
fun weightOf(show: PoolSeries) = tierWeights?.get(seriesKey(show.title)) ?: TIER_SUPPORTING
val adaptiveFilmRest: Boolean = false,
/**
* Weighted channels only: a show with fewer hours of episodes than this has its tier's weight scaled down by its size,
* so a short catalogue comes round at a pace it can sustain instead of every week. Null keeps every tier whole.
*/
val catalogueScaleHours: Double? = null) {
/** A show's weight here, by its tier (a show the brief doesn't name counts as supporting), scaled by [catalogueScaleHours]. */
fun weightOf(show: PoolSeries): Double {
val tier = tierWeights?.get(seriesKey(show.title)) ?: TIER_SUPPORTING
val hours = show.episodes.sumOf { it.durationMs } / 3_600_000.0
return tier * (catalogueScaleHours?.let { (hours / it).coerceIn(MIN_CATALOGUE_SCALE, 1.0) } ?: 1.0)
}
/** Whether a show plays its episodes in a mixed order here. */
fun mixes(show: PoolSeries) = shuffleEpisodes || seriesKey(show.title) in shuffleTitles.mapTo(HashSet(), ::seriesKey)
/** Changes when the list does, so the episode cache is read again. What's already committed stays. */
@@ -88,7 +97,7 @@ internal val SmartChannels = listOf(
SuggestionRule("day", "Daytime", "Movie", listOf("Science Fiction", "Action"), 1975..2005, 6.5f, setOf("NC-17", "X"), 40, NotCultCinema + "Adventure", 70, 7.9f),
SuggestionRule("day", "Daytime", "Movie", listOf("Thriller", "Mystery"), 1975..2005, 6.5f, setOf("NC-17", "X"), 30, NotCultCinema + "Science Fiction", 70, 7.9f),
SuggestionRule("late", "Late night", "Movie", listOf("Horror"), 1970..2010, 6f, emptySet(), 90, NotCultCinema, 70))),
ChannelProgramming(VORTEX_NUMBER, "Vortex", VortexShows, seriesCount = 40, finishTogether = true, exactTitles = true, englishAudio = false, tierWeights = VortexTiers,
ChannelProgramming(VORTEX_NUMBER, "Vortex", VortexShows, seriesCount = 40, finishTogether = true, exactTitles = true, englishAudio = false, tierWeights = VortexTiers, catalogueScaleHours = CATALOGUE_SCALE_HOURS,
dayParts = VortexDayParts,
suggestionRules = listOf(
SuggestionRule("movie", "Movie Night", "Movie", listOf("Animation"), 1985..2025, 5f, setOf("NC-17", "X"), 30, setOf("Documentary"), 30, titleContains = "Dragon Ball"),
@@ -104,14 +113,18 @@ internal val SmartChannels = listOf(
ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = if (it.films) it.searches.size else 60, finishTogether = !it.films, exactTitles = true,
filmRestMs = if (it.films) 7 * 24 * 60 * 60_000L else null,
suggestionRules = SmartChannelRules[it.number].orEmpty(), dayParts = SmartChannelParts[it.number].orEmpty(), shuffleTitles = SmartChannelMixed[it.number].orEmpty(),
channelFilms = if (it.number == 401) PpvSunday else emptyList(), tierWeights = SmartChannelTiers[it.number], adaptiveFilmRest = it.number in AdaptiveFilmRestChannels) }
channelFilms = if (it.number == 401) PpvSunday else emptyList(), tierWeights = SmartChannelTiers[it.number], adaptiveFilmRest = it.number in AdaptiveFilmRestChannels,
catalogueScaleHours = if (it.number in CatalogueScaledChannels) CATALOGUE_SCALE_HOURS else null) }
/** Film channels whose rest adapts to their pools: Premiere, whose pool runs well past a week (After Dark is set above). Proposed 4 November 2026. */
private val AdaptiveFilmRestChannels get() = setOf(301)
/** Editorially weighted rotation, per channel, once its tiers are approved: Orbit Three (0.97.0), Odyssey, Wild, Orbit X and ToonZ (0.98.0), then Orbit 1, Orbit 2, Gold, Evidence and Heroes (0.99.4; Vortex and Paradox are set above). */
private val SmartChannelTiers: Map<Int, Map<String, Double>> get() = mapOf(103 to ThreeTiers, 202 to OdysseyTiers, 203 to WildTiers, 603 to OrbitXTiers, 605 to ToonZTiers,
101 to Orbit1Tiers, 102 to Orbit2Tiers, 104 to GoldTiers, 107 to EvidenceTiers, 602 to HeroesTiers)
101 to Orbit1Tiers, 102 to Orbit2Tiers, 104 to GoldTiers, 107 to EvidenceTiers, 602 to HeroesTiers, 604 to RewindTiers, 204 to DarkFilesTiers)
/** Weighted channels whose short catalogues are scaled down ([ChannelProgramming.catalogueScaleHours]): Rewind and Dark Files (Vortex is set above). */
private val CatalogueScaledChannels get() = setOf(604, 204)
/** Shows that play in a mixed order on channels whose identity is agreed. See ChannelIdentities.kt. */
private val SmartChannelMixed: Map<Int, Set<String>> get() = mapOf(101 to Orbit1Mixed, 102 to Orbit2Mixed, 103 to ThreeMixed, 104 to GoldMixed, 105 to RiotMixed,
@@ -340,6 +353,11 @@ private const val ON_TIME_RUN_IN_MS = 90 * 60_000L
/** A series on a film channel airs at most once in this long, so a long event window still holds one episode. */
private const val SERIES_NIGHT_MS = 12 * 60 * 60_000L
/** However short a show's catalogue, it keeps at least this share of its tier's weight, so it still comes round. */
internal const val MIN_CATALOGUE_SCALE = 0.02
/** The hybrid's threshold for Rewind, Dark Files and Vortex (approved 4 November 2026): under a day of episodes, a show's weight scales with its size. */
internal const val CATALOGUE_SCALE_HOURS = 24.0
/** The tiers' weights: a core show gets one and a half times a supporting show's airtime in a block, a surprise half. */
internal const val TIER_CORE = 3.0
internal const val TIER_SUPPORTING = 2.0
@@ -190,12 +190,14 @@ class WeightedRotationTest {
}
@Test fun theApprovedChannelsAreWeightedAndTotsBedtimeIsNot() {
assertEquals(listOf(101, 102, 103, 104, 106, 107, 202, 203, 601, 602, 603, 605, 614), SmartChannels.filter { it.tierWeights != null }.map { it.number }.sorted())
// Riot, Terra, Ring X and Junior keep equal turns; Rewind and Dark Files wait on their repeat investigation.
assertTrue(SmartChannels.filter { it.number in setOf(105, 201, 401, 611, 604, 204) }.all { it.tierWeights == null })
assertEquals(listOf(101, 102, 103, 104, 106, 107, 202, 203, 204, 601, 602, 603, 604, 605, 614), SmartChannels.filter { it.tierWeights != null }.map { it.number }.sorted())
// Riot, Terra, Ring X and Junior keep equal turns.
assertTrue(SmartChannels.filter { it.number in setOf(105, 201, 401, 611) }.all { it.tierWeights == null })
assertEquals(setOf(204, 601, 604), SmartChannels.filter { it.catalogueScaleHours != null }.map { it.number }.toSet())
assertEquals(TIER_SUPPORTING, SmartChannels.first { it.number == 204 }.weightOf(show("Haunted", 100, 60)), 0.0)
assertEquals(TIER_CORE, SmartChannels.first { it.number == 101 }.weightOf(show("The Wonder Years", 1)), 0.0)
assertEquals(TIER_SURPRISE, SmartChannels.first { it.number == 101 }.weightOf(show("Heartbeat", 1)), 0.0)
assertEquals(TIER_CORE, SmartChannels.first { it.number == 601 }.weightOf(show("Tenchi Muyo! Ryo-Ohki", 1)), 0.0)
assertEquals(TIER_CORE, SmartChannels.first { it.number == 601 }.weightOf(show("Tenchi Muyo! Ryo-Ohki", 100, 60)), 0.0)
assertEquals(TIER_CORE, SmartChannels.first { it.number == 106 }.weightOf(show("Doctor Who (2023)", 1)), 0.0)
val tots = SmartChannels.first { it.number == TOTS_NUMBER }
assertEquals(listOf(true, true, false, false), tots.dayParts.map { it.weighted })
@@ -211,6 +213,20 @@ class WeightedRotationTest {
assertTrue(SmartChannels.filter { it.number != 614 && it.number != 605 }.all { p -> p.dayParts.all { it.weighted && it.weights.isEmpty() } })
}
@Test fun aShortCatalogueHasItsWeightScaledDownAndSoRepeatsLessOften() {
val p = ChannelProgramming(1, "Hybrid", emptyList(), tierWeights = mapOf(seriesKey("Tiny core") to TIER_CORE), catalogueScaleHours = 24.0)
assertEquals(TIER_CORE, p.weightOf(show("Big core", 0).copy(title = "Tiny core", episodes = show("x", 60, 30).episodes)), 0.0)
assertEquals(TIER_CORE * 0.5, p.weightOf(show("Tiny core", 24, 30)), 1e-9)
assertEquals(TIER_SUPPORTING * MIN_CATALOGUE_SCALE, p.weightOf(show("Other", 1, 1)), 1e-9)
// Four weeks of a twelve-episode core show among ten big ones: scaled, it repeats far less often and still airs.
val pool = listOf(show("Tiny core", 12, 25)) + (1..10).map { show("Big $it", 300) }
fun weekly(weightOf: (PoolSeries) -> Double) = run(pool, weightOf).entries.groupBy { it.media.item.id }.values.sumOf { es -> es.zipWithNext().count { (a, b) -> b.startMs - a.startMs < 7 * day } }
val plain = ChannelProgramming(1, "Plain", emptyList(), tierWeights = p.tierWeights)
val before = weekly(plain::weightOf); val after = weekly(p::weightOf)
assertTrue("$before -> $after", after * 2 < before)
assertTrue(run(pool, p::weightOf).entries.any { it.seriesId == "Tiny core" })
}
@Test fun orbitThreesTiers() {
val three = SmartChannels.first { it.number == 103 }
assertTrue(three.searches.none { seriesKey(it) == seriesKey("Family Guy") })
+23
View File
@@ -137,6 +137,29 @@ Paul's direction, recorded as the frame for everything above and below. Not star
**Later technical roadmap, each part with its own acceptance criteria, tests and rollback:** continuous seven-day schedule maintenance; catalogue refresh and new-arrival detection; recommendations and special-event selection; user approval and automation preferences; premiere identification; What's On editorial selection; reusable takeover presentation.
## After Dark editorial events (Paul, 4 November 2026)
These are editorial proposals for the Living Network only. There are no bookings, no assets and no presentation keys until each event is approved.
**After Dark's identity.** Cult midnight cinema, in the spirit of Shudder-style horror discovery. That covers obscure international horror, experimental cinema, folk horror, B-movies and unusual genre films. It is not a duplicate of the Horror channel. Mainstream horror lives on Horror, Sci-Fi, BritX or VHS.
All three events below are occasional events within After Dark, not new channels.
1. **Hammer Horror Double Bill.** A curated double feature of verified Hammer horror films, scheduled late at night. Presentation: classic Gothic, with crimson theatrical styling.
2. **Universal Monsters Takeover.** A themed evening of Universal's classic monster films, using verified library titles. Presentation: black-and-white cinema styling, with its own special identity.
3. **J-Horror After Midnight.** An occasional late-night showcase of Japanese supernatural, psychological and atmospheric horror. It draws on verified, playable library titles where available, such as Ringu, Ju-on, Dark Water, Pulse (Kairo), Audition and Noroi.
- Presentation: an old CRT television flickers into life. The picture distorts with analogue tracking interference and static, and the After Dark identity appears briefly through it. Then the J-Horror After Midnight title emerges.
- It finishes on a fully lit, clean final frame so playback carries on seamlessly.
- The mood is eerie and restrained, with no aggressive flashing and no loud sting.
**When implemented,** each event follows the same pattern:
- The Living Network checks which films are available and playable, and selects programmes that fit the theme.
- It proposes suitable future slots for approval.
- The matching presentation activates only once the event has been approved and booked.
- Each event uses the existing event-key and reusable dynamic-presentation architecture (`DayPart.key`, `blockIdentFor`, the shared clip with dynamic text). No event artwork shows unless an actual event is booked.
**A possible special extra.** More Things That Happened (2007) leaves the normal After Dark film rotation, because it is Lynch extra material rather than a feature. It is kept in mind as a possible approved special extra later.
## Movie nights and specials on every channel (Paul, 3 November 2026)
A roadmap principle, not a request to build now. Films and specials aren't only for film channels. Any channel can carry intentional film blocks that match its identity, never random films in its episode rotation: