Roadmap: After Dark editorial events (Hammer Horror Double Bill, Universal Monsters Takeover, J-Horror After Midnight), proposals only
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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") })
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user