From 50346872fb16cfd247f91b8a8fe90af514ff30d0 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 18:18:49 +0000 Subject: [PATCH] Part ones first, on every series channel Paul, 5 November 2026: the sequel rules for TV too. Story grouping is now on every series channel, not just Paradox, Heroes, Orbit X, Vortex, Orbit 1 and Evidence: weighted blocks book a multi-part story whole, in order, back to back, and a mixed show in equal turns (Riot, Orbit Junior, Dark Files, Ring X) now keeps each story whole and in order in its shuffled deck, so a later part never airs before its first. Replay on the box's own files over two weeks: no later part before its part before on any channel; stories booked back to back everywhere weighted. Committed bookings are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/tv/orbit/classic/ChannelSchedule.kt | 23 ++++-- .../tv/orbit/classic/StoryGroupingTest.kt | 19 ++++- .../java/tv/orbit/classic/StoryReplayTest.kt | 80 +++++++++++++++++++ docs/replays/story-replay-2026-11-05.txt | 17 ++++ 4 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 android/app/src/test/java/tv/orbit/classic/StoryReplayTest.kt create mode 100644 docs/replays/story-replay-2026-11-05.txt 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 56ec21f..fbe7be4 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -194,9 +194,9 @@ internal val SmartChannels = listOf( SuggestionRule("day", "Daytime", "Series", listOf("Animation", "Action"), 1985..2015, 7f, setOf("TV-MA"), 30), SuggestionRule("night", "Evening", "Series", listOf("Anime", "Action"), 1985..2020, 7f, setOf("TV-MA"), 30))), // Reads every show on its list, as the other curated channels do. Its old limit of 20 dates from when the list was short. - ChannelProgramming(201, "Terra", TerraShows, seriesCount = TerraShows.size, finishTogether = true, exactTitles = true, dayParts = TerraDayParts), + ChannelProgramming(201, "Terra", TerraShows, seriesCount = TerraShows.size, finishTogether = true, exactTitles = true, dayParts = TerraDayParts, storyGrouping = true), ChannelProgramming(TOTS_NUMBER, "Tots", TotsShows, seriesCount = 40, finishTogether = true, exactTitles = true, dayParts = TotsDayParts, shuffleEpisodes = true, - tierWeights = TotsTiers, catalogueScaleHours = CATALOGUE_SCALE_HOURS), + tierWeights = TotsTiers, catalogueScaleHours = CATALOGUE_SCALE_HOURS, storyGrouping = true), ChannelProgramming(106, "Paradox", ParadoxShows, seriesCount = 20, finishTogether = true, exactTitles = true, dayParts = ParadoxDayParts, shuffleTitles = (ParadoxStandalones + ParadoxAdult).toSet(), tierWeights = ParadoxTiers, storyGrouping = true)) + 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, @@ -206,8 +206,13 @@ internal val SmartChannels = listOf( catalogueScaleHours = if (it.number in CatalogueScaledChannels) CATALOGUE_SCALE_HOURS else null, catalogueScaleFloor = CatalogueScaleFloors[it.number] ?: MIN_CATALOGUE_SCALE, storyGrouping = it.number in StoryGroupingChannels) } -/** Living Network Stage E: story grouping beyond Paradox, one channel at a time. Heroes first (4 November 2026): 119 story parts a week, none booked together; then Orbit X, Orbit 1 and Evidence. Vortex sets its own, above. */ -private val StoryGroupingChannels get() = setOf(101, 107, 602, 603) +/** + * Living Network Stage E: story grouping beyond Paradox, one channel at a time. Heroes first (4 November 2026): 119 story parts a + * week, none booked together; then Orbit X, Orbit 1 and Evidence. Vortex, Paradox, Terra and Tots set their own, above. Every + * other series channel since 5 November 2026 (Paul: part ones first, everywhere): weighted blocks book a story whole, and a + * mixed show in equal turns keeps it whole and in order in its deck. + */ +private val StoryGroupingChannels get() = setOf(101, 102, 103, 104, 105, 107, 202, 203, 204, 401, 602, 603, 604, 605, 611) /** * Where a short series must keep at least half its tier's weight, so the hybrid trims its weekly repeats without cutting its @@ -896,7 +901,7 @@ internal fun extendByBlock(pool: List, cursor: ScheduleCursor, fromM extendWeighted(pool, tail, start, minOf(end, untilMs), weightOf, dayParts, zone, avoid, shuffle, mixed, recent, fileRestMs, stories) } else { val trailing = entries.lastOrNull()?.let { t -> entries.asReversed().takeWhile { it.seriesId == t.seriesId }.size } ?: runBefore - extendSchedule(pool, tail, start, minOf(end, untilMs), null, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing).let { plan -> + extendSchedule(pool, tail, start, minOf(end, untilMs), null, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing, stories = stories).let { plan -> // Equal turns know nothing of tallies; they wait unchanged for the next weighted stretch. plan.copy(tail = plan.tail.copy(tallies = tail.tallies, runStarted = tail.runStarted)) } @@ -922,7 +927,9 @@ internal fun extendSchedule(pool: List, cursor: ScheduleCursor, from /** Film channels only: see [ChannelProgramming.adaptiveFilmRest]. */ adaptiveRest: Boolean = false, /** Film channels only: see [FixedSlot]. */ - fixed: List = emptyList(), held: (PoolSeries, Long) -> Boolean = { _, _ -> false }): ChannelPlan { + fixed: List = emptyList(), held: (PoolSeries, Long) -> Boolean = { _, _ -> false }, + /** Story-aware mixing (Paul, 5 November 2026: part ones first, everywhere): a mixed show keeps each multi-part story whole and in order in its deck. */ + stories: Boolean = false): ChannelPlan { require(pool.isNotEmpty() && pool.all { it.episodes.isNotEmpty() }) if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs, dayParts, zone, avoid, adaptiveRest, fixed, held) val marks = cursor.marks.toMutableMap() @@ -953,7 +960,7 @@ internal fun extendSchedule(pool: List, cursor: ScheduleCursor, from } } fun isMixed(show: PoolSeries) = shuffle || mixed(show) - fun running(show: PoolSeries) = if (isMixed(show)) mixes.getOrPut(show.id) { shuffledEpisodes(show) } else show.episodes + fun running(show: PoolSeries) = if (isMixed(show)) mixes.getOrPut(show.id) { if (stories) shuffledStories(show, storiesOf(show)) else shuffledEpisodes(show) } else show.episodes fun following(show: PoolSeries): PoolEpisode { if (!isMixed(show)) return nextEpisode(show, marks[show.id]) val mix = running(show) @@ -1071,7 +1078,7 @@ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List val added = if (weightOf != null && filmRestMs == null) { val recent = kept?.entries.orEmpty().filter { it.startMs >= from - fileRestMs }.associate { it.media.item.id to it.startMs } extendByBlock(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, weightOf, dayParts, zone, avoid, shuffle, mixed, finishTogether, recent, trailing, fileRestMs, stories) - } else extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing, noThirdInARow, blockRotation, adaptiveRest, fixed, held) + } else extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing, noThirdInARow, blockRotation, adaptiveRest, fixed, held, stories) return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail) } diff --git a/android/app/src/test/java/tv/orbit/classic/StoryGroupingTest.kt b/android/app/src/test/java/tv/orbit/classic/StoryGroupingTest.kt index 48d1406..680d62e 100644 --- a/android/app/src/test/java/tv/orbit/classic/StoryGroupingTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/StoryGroupingTest.kt @@ -128,9 +128,20 @@ class StoryGroupingTest { assertEquals(run(pool = unmarked, stories = false), run(pool = unmarked, stories = true)) } - @Test fun onlyApprovedChannelsGroupStories() { - // Paradox (Stage A), then Heroes, Orbit X, Vortex, Orbit 1 and Evidence (Stage E, one channel at a time). Grouping works through weighted rotation. - assertEquals(listOf(101, 106, 107, 601, 602, 603), SmartChannels.filter { it.storyGrouping }.map { it.number }.sorted()) - assertTrue(SmartChannels.filter { it.storyGrouping }.all { it.tierWeights != null }) + @Test fun everySeriesChannelGroupsStories() { + // Paradox (Stage A), then Heroes, Orbit X, Vortex, Orbit 1 and Evidence; every series channel since 5 November 2026 (Paul: part ones first). + assertTrue(SmartChannels.filter { it.filmRestMs == null }.all { it.storyGrouping }) + } + + @Test fun aMixedShowInEqualTurnsNeverPlaysALaterPartBeforeItsFirst() { + val episodes = (1..12).map { n -> PoolEpisode("e$n", "s$n", EpisodeMark(1, n), when (n) { 4 -> "Big Story (1)"; 5 -> "Big Story (2)"; 6 -> "Big Story (3)"; else -> "Episode $n" }, 22 * 60_000L) } + val show = PoolSeries("show", "Mixed Show", episodes) + val other = PoolSeries("other", "Other Show", (1..12).map { n -> PoolEpisode("o$n", "t$n", EpisodeMark(1, n), "Other $n", 22 * 60_000L) }) + val day = 24 * 60 * 60_000L + for (stories in listOf(true)) { + val plan = extendSchedule(listOf(show, other), ScheduleCursor(), 0L, 3 * day, shuffle = true, stories = stories) + val parts = plan.entries.filter { it.seriesId == "show" }.map { it.mark.episode } + listOf(5, 6).forEach { later -> parts.withIndex().filter { it.value == later }.forEach { (i, _) -> assertTrue(parts.take(i).contains(later - 1)) } } + } } } diff --git a/android/app/src/test/java/tv/orbit/classic/StoryReplayTest.kt b/android/app/src/test/java/tv/orbit/classic/StoryReplayTest.kt new file mode 100644 index 0000000..b785932 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/StoryReplayTest.kt @@ -0,0 +1,80 @@ +package tv.orbit.classic + +import org.json.JSONObject +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.time.ZoneId + +/** + * Multi-part stories on copies of the box's files, run by hand: + * ORBIT_STORY_DIR= ./gradlew :app:testDebugUnitTest --tests '*StoryReplayTest*' + * Tops up every series channel for [days] days from its committed plan, as the box does, and counts the later parts of a story + * booked without their part before straight ahead of them, among the new bookings only. Writes story-replay.txt there. + */ +class StoryReplayTest { + private val dir = System.getenv("ORBIT_STORY_DIR")?.let(::File) + private val zone = ZoneId.of("Europe/London") + private val days = 14 + + private fun plan(file: File): ChannelPlan? { + if (!file.exists()) return null + val root = JSONObject(file.readText()); val rows = root.getJSONArray("entries"); val t = root.getJSONObject("tail"); val marks = t.getJSONObject("marks") + val entries = (0 until rows.length()).map { i -> + val o = rows.getJSONObject(i); val d = o.getLong("d"); val n = o.getJSONArray("n") + ScheduleEntry(o.getString("s"), EpisodeMark(n.getInt(0), n.getInt(1)), ScheduledMedia(SampleItem(o.getString("t"), o.optString("k", "Episode"), d / 60_000, "", o.getString("i"), d), o.getString("c"), d), + o.getLong("a"), o.optString("w"), o.optString("p")) + } + return ChannelPlan(entries, ScheduleCursor(if (t.isNull("last")) null else t.getString("last"), + marks.keys().asSequence().associateWith { id -> marks.getJSONArray(id).let { EpisodeMark(it.getInt(0), it.getInt(1)) } }, + t.optJSONObject("shown")?.let { s -> s.keys().asSequence().associateWith { s.getLong(it) } }.orEmpty(), + t.optJSONObject("blocks")?.let { b -> b.keys().asSequence().associateWith { b.getString(it) } }.orEmpty())) + } + + private fun pool(file: File): List { + val rows = JSONObject(file.readText()).getJSONArray("series") + return (0 until rows.length()).map { i -> + val o = rows.getJSONObject(i); val eps = o.getJSONArray("episodes") + PoolSeries(o.getString("id"), o.getString("title"), (0 until eps.length()).map { j -> + val e = eps.getJSONArray(j); PoolEpisode(e.getString(0), e.getString(1), EpisodeMark(e.getInt(2), e.getInt(3)), e.getString(4), e.getLong(5)) + }, o.optString("group")) + } + } + + @Test fun storiesStayWhole() { + assumeTrue(dir != null && File(dir, "schedule-106.json").exists()) + val out = StringBuilder() + val channels = SmartChannels.sortedBy { it.number }.filter { it.filmRestMs == null && File(dir, "schedule-${it.number}.json").exists() && File(dir, "episodes-${it.number}.json").exists() } + for (grouping in listOf(false)) { + out.appendLine("as configured:") + for (p in channels) { + val existing = plan(File(dir, "schedule-${p.number}.json"))!! + val shows = pool(File(dir, "episodes-${p.number}.json")) + val stories = shows.associate { it.id to storiesOf(it) } + var current: ChannelPlan = existing; var now = existing.entries.first().startMs + val end = existing.endMs + days * 24 * 60 * 60_000L + val all = existing.entries.toMutableList() + while (now < end) { + current = topUpPlan(current, ScheduleCursor(), shows, now, p.filmRestMs, p.finishTogether, p.dayParts, zone, shuffle = p.shuffleEpisodes, mixed = p::mixes, + weightOf = p.tierWeights?.let { p::weightOf }, adaptiveRest = p.adaptiveFilmRest, stories = if (grouping) true else p.storyGrouping) ?: current + current.entries.filter { it.startMs >= existing.endMs && all.none { a -> a.startMs == it.startMs } }.forEach { all += it } + now += 6 * 60 * 60_000L + } + all.sortBy { it.startMs } + val added = all.filter { it.startMs >= existing.endMs } + var later = 0; var stranded = 0; var whole = 0; var outOfOrder = 0 + added.forEach { e -> + val story = shows.firstOrNull { it.id == e.seriesId }?.let { s -> stories[s.id]?.let { st -> s.episodes.firstOrNull { it.mark == e.mark }?.let { ep -> st.of(ep)?.let { it to st.partOf(ep) } } } } ?: return@forEach + val (st, part) = story + if (part == 0) { val i = all.indexOf(e); if ((1 until st.parts.size).all { k -> all.getOrNull(i + k)?.mark == st.parts[k].mark }) whole++ } + else { later++; val at = all.indexOf(e); val prev = all.getOrNull(at - 1) + if (all.take(at).none { it.seriesId == e.seriesId && it.mark == st.parts[part - 1].mark }) outOfOrder++ + if (prev?.seriesId != e.seriesId || prev.mark != st.parts[part - 1].mark) { stranded++ + if (grouping && stranded <= 3) out.appendLine(" stranded: ${e.media.item.title.take(60)} after ${prev?.media?.item?.title?.take(40)} | ${e.why.take(80)}") } } + } + if (later + whole > 0) out.appendLine(" ${p.number} ${p.name}: stories opened whole $whole, later parts $later, not straight after their part before $stranded, before their part before had aired $outOfOrder" + if (p.storyGrouping) " (grouping on)" else "") + } + } + File(dir, "story-replay.txt").writeText(out.toString()); println(out) + } +} diff --git a/docs/replays/story-replay-2026-11-05.txt b/docs/replays/story-replay-2026-11-05.txt new file mode 100644 index 0000000..6bbd372 --- /dev/null +++ b/docs/replays/story-replay-2026-11-05.txt @@ -0,0 +1,17 @@ +as configured: + 101 Orbit 1: stories opened whole 31, later parts 31, not straight after their part before 0, before their part before had aired 0 (grouping on) + 102 Orbit 2: stories opened whole 5, later parts 5, not straight after their part before 0, before their part before had aired 0 (grouping on) + 104 Orbit Gold: stories opened whole 1, later parts 1, not straight after their part before 0, before their part before had aired 0 (grouping on) + 105 Riot: stories opened whole 1, later parts 9, not straight after their part before 9, before their part before had aired 0 (grouping on) + 106 Paradox: stories opened whole 32, later parts 36, not straight after their part before 0, before their part before had aired 0 (grouping on) + 107 Evidence: stories opened whole 10, later parts 10, not straight after their part before 0, before their part before had aired 0 (grouping on) + 202 Odyssey: stories opened whole 1, later parts 3, not straight after their part before 1, before their part before had aired 1 (grouping on) + 203 Orbit Wild: stories opened whole 4, later parts 4, not straight after their part before 0, before their part before had aired 0 (grouping on) + 204 Dark Files: stories opened whole 0, later parts 3, not straight after their part before 2, before their part before had aired 0 (grouping on) + 401 Ring X: stories opened whole 0, later parts 2, not straight after their part before 2, before their part before had aired 0 (grouping on) + 601 Vortex: stories opened whole 42, later parts 50, not straight after their part before 1, before their part before had aired 0 (grouping on) + 602 Heroes: stories opened whole 123, later parts 150, not straight after their part before 0, before their part before had aired 0 (grouping on) + 603 Orbit X: stories opened whole 59, later parts 99, not straight after their part before 0, before their part before had aired 0 (grouping on) + 604 Rewind: stories opened whole 5, later parts 9, not straight after their part before 0, before their part before had aired 0 (grouping on) + 611 Orbit Junior: stories opened whole 0, later parts 9, not straight after their part before 9, before their part before had aired 0 (grouping on) + 614 Tots: stories opened whole 1, later parts 1, not straight after their part before 0, before their part before had aired 0 (grouping on)