From 0f7ce591662550304ad3e1a2ce67f11e69a69f2d Mon Sep 17 00:00:00 2001 From: Pauljd Date: Wed, 4 Nov 2026 09:09:33 +0000 Subject: [PATCH] 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) --- .../tv/orbit/classic/ChannelIdentities.kt | 9 +++++- .../java/tv/orbit/classic/ChannelSchedule.kt | 30 +++++++++++++++---- .../tv/orbit/classic/WeightedRotationTest.kt | 24 ++++++++++++--- docs/roadmap.md | 23 ++++++++++++++ 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt index adf1e1b..a5a1f59 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt @@ -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 get() = @@ -401,3 +401,10 @@ internal val HeroesTiers: Map get() = /** Vortex's tiers, from docs/channels/vortex.md (approved 4 November 2026): core 3, surprises 1, everything else 2. */ internal val VortexTiers: Map 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 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 get() = + listOf("Forensic Files", "Cold Case Files", "Snapped", "Unsolved Mysteries", "Most Haunted", "Ghost Adventures", "Paranormal Witness").associate { seriesKey(it) to TIER_CORE } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index 859d0c1..3a60266 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -45,9 +45,18 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se */ val tierWeights: Map? = 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> 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> 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 diff --git a/android/app/src/test/java/tv/orbit/classic/WeightedRotationTest.kt b/android/app/src/test/java/tv/orbit/classic/WeightedRotationTest.kt index 6f5a598..702b5d4 100644 --- a/android/app/src/test/java/tv/orbit/classic/WeightedRotationTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/WeightedRotationTest.kt @@ -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") }) diff --git a/docs/roadmap.md b/docs/roadmap.md index 167fcbd..706d709 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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: