Part ones first, on every series channel
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m40s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m46s

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) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 18:18:49 +00:00
co-authored by Claude Opus 5.5
parent a4d3b3ea11
commit 50346872fb
4 changed files with 127 additions and 12 deletions
@@ -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<PoolSeries>, 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<PoolSeries>, cursor: ScheduleCursor, from
/** Film channels only: see [ChannelProgramming.adaptiveFilmRest]. */
adaptiveRest: Boolean = false,
/** Film channels only: see [FixedSlot]. */
fixed: List<FixedSlot> = emptyList(), held: (PoolSeries, Long) -> Boolean = { _, _ -> false }): ChannelPlan {
fixed: List<FixedSlot> = 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<PoolSeries>, 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)
}
@@ -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)) } }
}
}
}
@@ -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=<copies of the schedule folder> ./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<PoolSeries> {
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)
}
}
+17
View File
@@ -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)