0.99.17: Vortex Afterburn premiere of Dragon Ball Super: Beerus (Stage D1)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m3s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m28s

Saturday 21:00 from 21 November, first airings only, two a night while two are waiting, each episode its own booking,
pinned to Emby 8016753; whole Dragon Ball film before it on premiere nights, never cut. Fresh box-data replay; every
channel's fingerprint identical to 0.99.16.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-04 16:07:15 +00:00
co-authored by Claude Opus 5.5
parent ef30493924
commit d5fa2edeab
15 changed files with 487 additions and 63 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 146
versionName = "0.99.16-auto-subtitles"
versionCode = 147
versionName = "0.99.17-beerus-premiere"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -124,13 +124,34 @@ private val VortexLate = listOf("Attack on Titan", "Ghost in the Shell: Stand Al
"Tears to Tiara", "Zakuro", "Karas", "The Maxx", "Legend of the Galactic Heroes Gaiden", "Dragon Ball Z Abridged",
"Dragon Ball Z", "Gundam Wing", "Mobile Suit Gundam Wing", "Outlaw Star", "The Big O", "Trigun", "Megalobox", "Kingdom (2012)", "Fighting Spirit")
/** What Vortex asks the library for. Its other favourites came in as reviewed suggestions. */
internal val VortexShows get() = (VortexCartoons + VortexAnime + "Pokémon").distinct()
internal val VortexShows get() = (VortexCartoons + VortexAnime + "Pokémon" + BEERUS).distinct()
/**
* The Vortex Afterburn premiere of Dragon Ball Super: Beerus (Stage D1, approved by Paul on 4 November 2026): Saturdays at
* 21:00 from 21 November 2026, two new episodes a night while two or more are waiting, then one, never a repeat. Only the
* complete copy, Emby 8016753, is used: the other copy is withdrawn from Vortex.
*/
internal const val BEERUS = "Dragon Ball Super: Beerus"
internal val BeerusPremiereFrom: java.time.LocalDate = java.time.LocalDate.of(2026, 11, 21)
internal const val BEERUS_OTHER_COPY = "8055740"
/**
* Movie Night on premiere nights: one whole Dragon Ball film of about an hour or less before the premiere, from Vortex's own
* pool. Battle of Gods and Resurrection 'F' tell the Beerus story, so they're left out.
*/
internal val VortexPremiereFilms = listOf("Dragon Ball: Mystical Adventure", "Dragon Ball Z: Wrath of the Dragon", "Dragon Ball Z: Dead Zone", "Dragon Ball Z: Lord Slug",
"Dragon Ball Z: Cooler's Revenge", "Dragon Ball Z: Fusion Reborn", "Dragon Ball Z: Bojack Unbound", "Dragon Ball Z: The World's Strongest", "Dragon Ball Z: Bio-Broly",
"Dragon Ball: Curse of the Blood Rubies", "Dragon Ball: Sleeping Princess in Devil's Castle", "Dragon Ball GT: A Hero's Legacy", "Dragon Ball Z: Super Android 13!",
"Dragon Ball Z: Broly - Second Coming", "Dragon Ball Z: The Return of Cooler", "Dragon Ball Z: The History of Trunks", "Dragon Ball Z: Bardock - The Father of Goku")
/**
* Movie Night comes first, so on Friday and Saturday it takes 20:00 to 22:00 and Toonami's evening hour picks up after the film.
* Daytime suggestions not yet placed by hand go late, never into the daytime. Evening suggestions keep their evening.
*/
internal val VortexDayParts get() = listOf(
DayPart(20, 22, group = "movie", days = setOf(DayOfWeek.FRIDAY, DayOfWeek.SATURDAY), key = "vortex.movie"),
// The premiere comes first, then on its nights a shorter, Dragon Ball Movie Night that ends before it; other Saturdays are as before.
DayPart(21, 22, listOf(BEERUS), days = setOf(DayOfWeek.SATURDAY), onTime = true, key = "vortex.afterburn.premiere", from = BeerusPremiereFrom, premiere = true, premiereBuffer = 2, graceMs = 10 * 60_000L),
DayPart(20, 21, VortexPremiereFilms, days = setOf(DayOfWeek.SATURDAY), once = true, key = "vortex.movie", premiereNights = true),
DayPart(20, 22, group = "movie", days = setOf(DayOfWeek.FRIDAY), key = "vortex.movie"),
DayPart(20, 22, group = "movie", days = setOf(DayOfWeek.SATURDAY), key = "vortex.movie", premiereNights = false),
// Mornings are the staples. Reviewed modern anime joins in the afternoon, so it complements the channel rather than leading it.
DayPart(6, 12, VortexCartoons + VortexFavourites + listOf("Cardcaptor Sakura", "Cardcaptors", "Sailor Moon", "Dragon Ball", "Zoids: Chaotic Century", "Zoids")),
DayPart(12, 16, VortexCartoons + VortexFavourites + VortexFamilyAnime + listOf("Dragon Ball Z", "Mobile Suit Gundam Wing", "Tenchi Muyo! Ryo-Ohki", "Yu Yu Hakusho", "Outlaw Star", "The Big O")),
@@ -13,7 +13,21 @@ internal data class DayPart(val fromHour: Int, val toHour: Int, val titles: List
/** On a channel with weighted rotation: false keeps this block on equal turns, as Tots' Wind Down and Night (4 November 2026). */
val weighted: Boolean = true,
/** On a channel with weighted rotation: tier weights for this block only, over the channel's, as ToonZ's teatime trio. */
val weights: Map<String, Double> = emptyMap()) {
val weights: Map<String, Double> = emptyMap(),
/** An event that starts on this date: before it, the event gives way to the usual block. */
val from: java.time.LocalDate? = null,
/**
* A premiere (Stage D1, approved by Paul on 4 November 2026): a series event that only ever shows episodes not yet aired on
* Orbit, in order, [premiereDouble] a night while at least [premiereBuffer] are waiting, otherwise one, and nothing once
* they run out, so the slot goes back to the usual block rather than repeating one as new. Weighted channels only.
*/
val premiere: Boolean = false,
val premiereDouble: Int = 2,
val premiereBuffer: Int = 4,
/** Set to true for a block only on the channel's premiere nights, false for one only on its other nights. Null is every night. */
val premiereNights: Boolean? = null,
/** An appointment's own allowance for starting late, instead of [ON_TIME_GRACE_MS]. */
val graceMs: Long? = null) {
fun covers(hour: Int) = if (fromHour <= toHour) hour in fromHour until toHour else hour >= fromHour || hour < toHour
/** An event that runs past midnight belongs to the night it started, so its small hours count from the day before. */
fun covers(hour: Int, day: java.time.DayOfWeek) = covers(hour) && (days.isEmpty() || (if (fromHour > toHour && hour < toHour) day.minus(1) else day) in days)
@@ -168,7 +182,7 @@ internal val SmartChannels = listOf(
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, catalogueScaleHours = CATALOGUE_SCALE_HOURS,
dayParts = VortexDayParts,
dayParts = VortexDayParts, withdrawn = setOf(BEERUS_OTHER_COPY),
suggestionRules = listOf(
SuggestionRule("movie", "Movie Night", "Movie", listOf("Animation"), 1985..2025, 5f, setOf("NC-17", "X"), 30, setOf("Documentary"), 30, titleContains = "Dragon Ball"),
SuggestionRule("movie", "Movie Night", "Movie", listOf("Animation", "Action"), 1985..2015, 7f, setOf("NC-17", "X"), 30, setOf("Documentary", "Family"), 60),
@@ -551,19 +565,19 @@ internal fun nextAppointment(at: Long, zone: java.time.ZoneId, lookaheadHours: I
*/
internal fun <T> fitBeforeAppointment(appointment: Long, start: Long, chosen: T, candidates: List<T>, runInMs: Long = ON_TIME_RUN_IN_MS,
/** How many more programmes may follow before the appointment: 1 for series, 2 for films, which are long. */
followers: Int = 1, ends: (T) -> Long): T? {
val latest = appointment + ON_TIME_GRACE_MS
followers: Int = 1, graceMs: Long = ON_TIME_GRACE_MS, ends: (T) -> Long): T? {
val latest = appointment + graceMs
if (ends(chosen) <= appointment - runInMs) return null
val remaining = appointment - start
fun length(x: T) = ends(x) - start
/** Whether [first] can open a run of up to [followers] more different candidates that ends at the appointment or in its grace. */
fun opensRun(first: T): Boolean {
val total = length(first)
if (total in remaining..remaining + ON_TIME_GRACE_MS) return true
if (total in remaining..remaining + graceMs) return true
if (total > remaining) return false
val rest = candidates.filter { it != first }.map(::length)
if (followers >= 1 && rest.any { total + it in remaining..remaining + ON_TIME_GRACE_MS }) return true
if (followers >= 2) rest.forEachIndexed { i, a -> if (total + a < remaining) for (j in i + 1 until rest.size) if (total + a + rest[j] in remaining..remaining + ON_TIME_GRACE_MS) return true }
if (followers >= 1 && rest.any { total + it in remaining..remaining + graceMs }) return true
if (followers >= 2) rest.forEachIndexed { i, a -> if (total + a < remaining) for (j in i + 1 until rest.size) if (total + a + rest[j] in remaining..remaining + graceMs) return true }
return false
}
if (opensRun(chosen)) return null
@@ -645,10 +659,55 @@ internal fun extendWeighted(pool: List<PoolSeries>, cursor: ScheduleCursor, from
/** How long [show]'s next booking runs: the whole story when it opens one. */
fun lengthOf(show: PoolSeries): Long = following(show).let { episode -> storiesOf(show)?.takeIf { it.partOf(episode) == 0 }?.of(episode)?.durationMs ?: episode.durationMs }
fun hadItsTurn(available: List<PoolSeries>, at: Long) = available.any { film -> film.isFilm && shown[film.id]?.let { at - it < ONCE_A_NIGHT_MS } == true }
// A premiere event: its show, and the files already booked for it in the day before this stretch, so a night split by a top-up carries on.
val premierePart = dayParts.firstOrNull { it.premiere }
val premiereShows = premierePart?.let { p -> pool.filter { show -> p.titles.any { seriesKey(it) == seriesKey(show.title) } } }.orEmpty()
val premiereFiles = premiereShows.flatMapTo(HashSet()) { show -> show.episodes.map { it.id } }
/** The evening a time belongs to: the small hours count with the day before. */
fun nightOf(at: Long) = java.time.Instant.ofEpochMilli(at).atZone(zone).let { if (it.hour < 6) it.toLocalDate().minusDays(1) else it.toLocalDate() }
fun unaired(show: PoolSeries) = marks[show.id]?.let { mark -> show.episodes.count { it.mark > mark } } ?: show.episodes.size
/** Premiere episodes started on [night], booked in this stretch or just before it. */
fun premieresOn(night: java.time.LocalDate) = (aired.filterKeys { it in premiereFiles }.values + entries.filter { it.media.item.id in premiereFiles }.map { it.startMs })
.distinct().count { nightOf(it) == night }
fun premiereCap(show: PoolSeries, night: java.time.LocalDate): Int =
if (unaired(show) + premieresOn(night) >= premierePart!!.premiereBuffer) premierePart.premiereDouble else 1
/** Whether the premiere runs on the night of [at]: its day, on or after its start date, with a new episode to show or one already shown. */
fun premiereNight(at: Long): Boolean {
val p = premierePart ?: return false
val night = nightOf(at)
return (p.days.isEmpty() || night.dayOfWeek in p.days) && (p.from == null || !night.isBefore(p.from)) &&
premiereShows.any { unaired(it) > 0 || premieresOn(night) > 0 }
}
fun premiereStart(at: Long) = nightOf(at).atTime(premierePart!!.fromHour, 0).atZone(zone).toInstant().toEpochMilli()
/** Whether [part] may be used at [at] by the premiere rules, and with which of its shows. Null means it gives way. */
fun premiereFilter(part: DayPart, shows: List<PoolSeries>, at: Long): List<PoolSeries>? {
if (part.from != null && nightOf(at).isBefore(part.from)) return null
if (part.premiereNights != null && part.premiereNights != premiereNight(at)) return null
if (part.premiere) return shows.filter { unaired(it) > 0 && premieresOn(nightOf(at)) < premiereCap(it, nightOf(at)) }.ifEmpty { null }
// Before a premiere, only a film that ends in time for it: nothing is cut to make room.
if (part.premiereNights == true && premierePart != null) {
val latest = premiereStart(at) + (premierePart.graceMs ?: ON_TIME_GRACE_MS)
return shows.filter { !it.isFilm || at + it.episodes.first().durationMs <= latest }.ifEmpty { null }
}
return shows
}
fun partAt(at: Long): Triple<List<PoolSeries>, String, DayPart?> {
// A premiere night's second episode follows its first straight away, whatever the hour.
if (premierePart != null) premiereShows.firstOrNull { it.id == last && unaired(it) > 0 && premieresOn(nightOf(at)) in 1 until premiereCap(it, nightOf(at)) &&
entries.lastOrNull()?.media?.item?.id.let { id -> id == null || id in premiereFiles } }
?.let { return Triple(listOf(it), "event " + premierePart.group.ifEmpty { "${premierePart.fromHour}-${premierePart.toHour}" }, premierePart) }
val first = dayPartFor(pool, dayParts, at, zone)
val part = first.third ?: return first
return if (part.once && hadItsTurn(first.first, at)) dayPartFor(pool, dayParts - part, at, zone) else first
val spent = part.once && hadItsTurn(first.first, at)
var found = if (spent) dayPartFor(pool, dayParts - part, at, zone) else first
if (premierePart == null && dayParts.none { it.from != null || it.premiereNights != null }) return found
// Premiere rules: a part that gives way leaves the time to the next one that covers it.
var parts = if (spent) dayParts - part else dayParts
while (true) {
val candidate = found.third ?: return found
val allowed = premiereFilter(candidate, found.first, at) ?: run { parts = parts - candidate; found = dayPartFor(pool, parts, at, zone); null } ?: continue
return Triple(allowed, found.second, candidate)
}
}
// A show that has left the pool loses its tally, so if it comes back it joins level rather than owed a backlog.
val ids = pool.mapTo(HashSet()) { it.id }
@@ -687,7 +746,8 @@ internal fun extendWeighted(pool: List<PoolSeries>, cursor: ScheduleCursor, from
// Before an appointment, the eligible show that leaves it on time, then any clear one, in tally order.
val appointment = if (part?.onTime == true) null else nextAppointment(start, zone, partAt = ::partAt)
val fitted = if (continuing != null) null else appointment?.let { at -> fun ends(show: PoolSeries) = start + lengthOf(show)
fitBeforeAppointment(at, start, preferred, eligible.sortedWith(byTally), ends = ::ends) ?: fitBeforeAppointment(at, start, preferred, clear.sortedWith(byTally), ends = ::ends) }
val grace = partAt(at).third?.graceMs ?: ON_TIME_GRACE_MS
fitBeforeAppointment(at, start, preferred, eligible.sortedWith(byTally), graceMs = grace, ends = ::ends) ?: fitBeforeAppointment(at, start, preferred, clear.sortedWith(byTally), graceMs = grace, ends = ::ends) }
val series = continuing ?: fitted ?: preferred
val episode = following(series)
val story = storiesOf(series)?.let { it.of(episode)?.let { found -> found.parts.size to it.partOf(episode) + 1 } }
@@ -697,7 +757,8 @@ internal fun extendWeighted(pool: List<PoolSeries>, cursor: ScheduleCursor, from
series.id == last && unclashed.size < available.size -> "; again, as the others here air elsewhere then"
else -> ""
} + (if (fitted != null) "; chosen to finish before the appointment at ${java.time.Instant.ofEpochMilli(appointment!!).atZone(zone).toLocalTime()}" else "") +
(if (story != null) "; story: part 1 of ${story.first}, booked whole" else "")
(if (story != null) "; story: part 1 of ${story.first}, booked whole" else "") +
(if (part?.premiere == true && series in available) "; premiere: first airing on Orbit" else "")
val item = if (series.isFilm) SampleItem(series.title, "Movie", episode.durationMs / 60_000, "", episode.id, episode.durationMs) else scheduledItem(series.title, episode)
entries += ScheduleEntry(series.id, episode.mark, ScheduledMedia(item, episode.sourceId, episode.durationMs), start, why, if (series in available) part?.key.orEmpty() else "")
val weight = part?.takeIf { series in available }?.weights?.get(seriesKey(series.title)) ?: weightOf(series)
@@ -947,7 +1008,8 @@ internal class ScheduledChannel(val loop: SampleChannel, plan: ChannelPlan) : Sa
}
}
internal val PRESENTATION_KEY = Regex("[a-z0-9]{1,20}\\.[a-z0-9]{1,20}")
// Two or three parts, such as "riot.laughs" or "vortex.afterburn.premiere" (Stage D1).
internal val PRESENTATION_KEY = Regex("[a-z0-9]{1,20}(\\.[a-z0-9]{1,20}){1,2}")
/**
* A booked programme's presentation: the key saved with it when it was booked. A programme booked before keys were kept
@@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
connection.connectTimeout = 10_000
connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.99.16\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.99.17\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -278,7 +278,9 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
if (pool.any { it.id == match.id || seriesKey(it.title) == seriesKey(match.title) }) continue
// A library can hold several copies of one show, one with a single episode and another with the whole run.
// The copy with the most episodes this box can play is the one the channel uses.
val copies = (listOf(match) + candidates.filter { it.id != match.id && seriesKey(it.title) == seriesKey(match.title) }.sortedBy { it.year ?: Int.MAX_VALUE }).take(MAX_SERIES_COPIES)
// A copy taken off the channel by editorial decision is never read, so it can't stand in for the one chosen.
val copies = (listOf(match) + candidates.filter { it.id != match.id && seriesKey(it.title) == seriesKey(match.title) }.sortedBy { it.year ?: Int.MAX_VALUE })
.filter { it.id !in programming.withdrawn }.take(MAX_SERIES_COPIES)
val read = try { traced("read") { copies.map { copy -> readWholeSeries(transport, userId, ::currentToken, copy, checkActive, onRequest = { requests++ }, canDecode = BoxDecoders::canDecode, englishAudio = programming.englishAudio) } } }
catch (failure: EmbyCheckException) {
val kept = partial.carry(seriesKey(match.title), failure)
@@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B
next.instanceFollowRedirects = false
next.connectTimeout = 10_000
next.readTimeout = 20_000
next.setRequestProperty("User-Agent", "OrbitTV/0.99.16")
next.setRequestProperty("User-Agent", "OrbitTV/0.99.17")
next.setRequestProperty("Accept-Encoding", "identity")
session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) }
if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) {
@@ -13,7 +13,9 @@ class AfterDarkReviewTest {
@Test fun thirtyEightExactItemsLeaveAfterDarkAndOnlyThere() {
assertEquals(38, afterDark.withdrawn.size)
assertTrue(afterDark.withdrawn.all { EmbyClient.validId(it) })
assertTrue(SmartChannels.filter { it.number != AFTER_DARK_NUMBER }.all { it.withdrawn.isEmpty() })
// The only other withdrawal is Vortex's second copy of Beerus (Stage D1), so the complete copy is the one used.
assertTrue(SmartChannels.filter { it.number != AFTER_DARK_NUMBER && it.number != VORTEX_NUMBER }.all { it.withdrawn.isEmpty() })
assertEquals(setOf(BEERUS_OTHER_COPY), SmartChannels.first { it.number == VORTEX_NUMBER }.withdrawn)
// The kept films are untouched: nothing withdrawn is on After Dark's own list.
val film = { id: String, title: String -> PoolSeries(id, title, listOf(PoolEpisode(id, "src", EpisodeMark(1, 1), title, 6_000_000))) }
val pool = listOf(film("5653603", "Koi... Mil Gaya"), film("5660745", "The Shining"), film("1", "Zombieland"), film("2", "Black Swan"), film("3", "Terrorizers"), film("4", "The Outcasts"))
@@ -31,7 +33,7 @@ class AfterDarkReviewTest {
@Test fun onlyAfterDarkAndHorrorReadTheirPoolsAgain() {
assertTrue("#withdrawn:" in afterDark.signature)
assertTrue(SmartChannels.filter { it.number != AFTER_DARK_NUMBER }.none { "#withdrawn:" in it.signature })
assertTrue(SmartChannels.filter { it.number != AFTER_DARK_NUMBER && it.number != VORTEX_NUMBER }.none { "#withdrawn:" in it.signature })
}
@Test fun optionBLetsNineteenLighterFilmsPlayByDayAndKeepsTheIntenseOnesLate() {
@@ -8,13 +8,13 @@ import org.junit.Test
import java.io.File
/**
* Stage D proposal, read-only: Vortex with a prototype Afterburn premiere of Dragon Ball Super: Beerus on Saturdays at 21:00
* and a shortened, Dragon Ball-themed Movie Night before it, from copies of the box's files. Nothing in the app changes:
* the prototype blocks exist only here. Each double bill is modelled as one 46-minute programme, which is how it would fill
* the evening; Beerus joins the pool only after Saturday 14 November's evening, as a booking release installed by then
* would make it. Run by hand:
* Stage D1's check: Vortex as built, with the Afterburn premiere of Dragon Ball Super: Beerus, from copies of the box's files.
* Beerus's four episodes, as the box's inspection read them (Emby 8016753, 23 minutes each; their exact seconds aren't in that
* read, so each is taken as 23:40), join Vortex's pool from the first top-up, as the release's first pool read would add them.
* Four weeks of top-ups every six hours with Beerus and without it, avoiding every other channel's committed programmes.
* Run by hand:
* ORBIT_REPLAY_DIR=<copies of the box's files> ./gradlew :app:testDebugUnitTest --tests '*BeerusPremiereReplayTest*'
* Writes beerus-premiere-replay.txt there.
* Writes beerus-premiere-replay.txt there, and fails on any broken rule.
*/
class BeerusPremiereReplayTest {
private val dir = System.getenv("ORBIT_REPLAY_DIR")?.let(::File)
@@ -48,52 +48,63 @@ class BeerusPremiereReplayTest {
private fun pct(x: Double) = "%.0f%%".format(100 * x)
@Test fun premiere() {
assumeTrue(dir != null && File(dir, "schedule-601.json").exists())
val vortex = SmartChannels.first { it.number == VORTEX_NUMBER }
val committed = plan(File(dir, "schedule-601.json")); val series = pool(File(dir, "episodes-601.json"))
fun at(date: String, h: Int, m: Int = 0) = java.time.LocalDate.parse(date).atTime(h, m).atZone(zone).toInstant().toEpochMilli()
fun t(ms: Long) = java.time.Instant.ofEpochMilli(ms).atZone(zone).toLocalDateTime().withNano(0)
val minute = 60_000L
val beerus = PoolSeries("8016753", "Dragon Ball Super: Beerus", listOf(
PoolEpisode("8016755", "mediasource_8016755", EpisodeMark(1, 1), "Episodes 1 and 2", 46 * minute),
PoolEpisode("8062564", "mediasource_8062564", EpisodeMark(1, 3), "Episodes 3 and 4", 46 * minute)))
// Dragon Ball films up to an hour, leaving out the two that tell the Beerus story.
val dbFilms = series.filter { it.isFilm && it.title.startsWith("Dragon Ball") && it.episodes[0].durationMs <= 61 * minute && "Battle of Gods" !in it.title && "Resurrection" !in it.title }
val movie = VortexDayParts.first()
val parts = listOf(
DayPart(21, 22, listOf(beerus.title), days = setOf(java.time.DayOfWeek.SATURDAY), onTime = true, once = true, key = "vortex.afterburn.premiere"),
DayPart(20, 21, dbFilms.map { it.title }, days = setOf(java.time.DayOfWeek.SATURDAY), key = movie.key),
movie.copy(days = setOf(java.time.DayOfWeek.FRIDAY))) + VortexDayParts.drop(1)
val joins = at("2026-11-14", 22)
private fun replay(committed: ChannelPlan, pool: List<PoolSeries>, vortex: ChannelProgramming, avoid: ProgrammeClash, restartEvery: Int = 0): Pair<List<ScheduleEntry>, Map<Long, Pair<Long, Long>>> {
var current = committed; var now = committed.entries.first().startMs + SCHEDULE_KEEP_PAST_MS
val all = committed.entries.toMutableList()
val out = StringBuilder("Stage D prototype replay: Vortex from the box's files (${t(committed.entries.first().startMs)} to ${t(committed.endMs)} committed), four weeks of top-ups every six hours.\n")
out.appendLine("Dragon Ball films that fit an hour (Battle of Gods and Resurrection 'F' left out): ${dbFilms.size}")
val bookedAt = mutableMapOf<Long, Pair<Long, Long>>()
val folder = java.nio.file.Files.createTempDirectory("premiere").toFile()
var step = 0
while (now < committed.endMs + 28 * day) {
val pool = if (now >= joins) series + beerus else series
if (restartEvery > 0 && ++step % restartEvery == 0) { ScheduleStore(folder).savePlan("user", vortex.number, current); current = ScheduleStore(folder).loadPlan("user", vortex.number)!! }
val before = current.endMs
current = topUpPlan(current, current.tail, pool, now, vortex.filmRestMs, vortex.finishTogether, parts, zone, shuffle = vortex.shuffleEpisodes, mixed = vortex::mixes,
current = topUpPlan(current, current.tail, pool, now, vortex.filmRestMs, vortex.finishTogether, vortex.dayParts, zone, avoid, shuffle = vortex.shuffleEpisodes, mixed = vortex::mixes,
weightOf = vortex.tierWeights?.let { vortex::weightOf }, adaptiveRest = vortex.adaptiveFilmRest, stories = vortex.storyGrouping) ?: break
current.entries.filter { e -> all.none { it.startMs == e.startMs } }.forEach { e -> all += e; if (e.seriesId == beerus.id) bookedAt[e.startMs] = now to before }
current.entries.filter { e -> all.none { it.startMs == e.startMs } }.forEach { e -> all += e; if (e.seriesId == "8016753") bookedAt[e.startMs] = now to before }
now += 6 * hour
}
all.sortBy { it.startMs }
assertEquals("committed kept", committed.entries, all.take(committed.entries.size))
bookedAt.forEach { (start, info) -> out.appendLine("premiere booked for ${t(start)} at the top-up of ${t(info.first)}; the plan then ended ${t(info.second)}, so the slot was uncommitted: ${info.second <= start}") }
val dbs = all.filter { it.media.item.title.startsWith("Dragon Ball Super / ") }
out.appendLine("Dragon Ball Super (2015) Beerus saga (season 1, 14 episodes): last episode booked ${dbs.lastOrNull { it.mark.season == 1 }?.let { "S${it.mark.season}E${it.mark.episode} at ${t(it.startMs)}" }}; first after it ${dbs.firstOrNull { it.mark.season > 1 }?.let { "S${it.mark.season}E${it.mark.episode} at ${t(it.startMs)}" }}" +
"; committed plan holds up to ${dbs.filter { it.startMs < committed.endMs }.lastOrNull()?.mark}")
return all to bookedAt
}
@Test fun premiere() {
assumeTrue(dir != null && File(dir, "schedule-601.json").exists())
val vortex = SmartChannels.first { it.number == VORTEX_NUMBER }
val committed = plan(File(dir, "schedule-601.json")); val series = withoutWithdrawn(pool(File(dir, "episodes-601.json")), vortex.withdrawn)
fun at(date: String, h: Int, m: Int = 0) = java.time.LocalDate.parse(date).atTime(h, m).atZone(zone).toInstant().toEpochMilli()
fun t(ms: Long) = java.time.Instant.ofEpochMilli(ms).atZone(zone).toLocalDateTime().withNano(0)
val minute = 60_000L
val beerus = PoolSeries("8016753", BEERUS, listOf("8016755", "8055410", "8062564", "8070600").mapIndexed { i, id ->
PoolEpisode(id, "mediasource_$id", EpisodeMark(1, i + 1), "Episode ${i + 1}", 23 * minute + 40_000L) })
val others = clashesWith(SmartChannels.filter { it.number != VORTEX_NUMBER && File(dir, "schedule-${it.number}.json").exists() }.map { plan(File(dir, "schedule-${it.number}.json")) })
val (without, _) = replay(committed, series, vortex, others)
val (with, bookedAt) = replay(committed, series + beerus, vortex, others)
val (restarted, _) = replay(committed, series + beerus, vortex, others, restartEvery = 3)
assertEquals("a save and reload every 18 hours changes nothing", with, restarted)
val out = StringBuilder("Stage D1 replay: Vortex as built, from the box's files (committed ${t(committed.entries.first().startMs)} to ${t(committed.endMs)}), four weeks of top-ups every six hours, avoiding other channels' committed programmes.\n")
val firstDifference = with.indices.firstOrNull { i -> without.getOrNull(i) != with[i] }?.let { with[it].startMs }
out.appendLine("with and without Beerus, identical until ${firstDifference?.let(::t) ?: "the end"}")
bookedAt.toSortedMap().forEach { (start, info) -> out.appendLine("premiere episode booked for ${t(start)} at the top-up of ${t(info.first)}; the plan then ended ${t(info.second)}, so the slot was uncommitted: ${info.second <= start}") }
val dbs = with.filter { it.media.item.title.startsWith("Dragon Ball Super / ") }
out.appendLine("Dragon Ball Super (2015) Beerus saga (season 1, 14 episodes): last episode ${dbs.lastOrNull { it.mark.season == 1 }?.let { "S${it.mark.season}E${it.mark.episode} at ${t(it.startMs)}" }}; " +
"first after it ${dbs.firstOrNull { it.mark.season > 1 }?.let { "S${it.mark.season}E${it.mark.episode} at ${t(it.startMs)}" }}; committed plan holds up to ${dbs.filter { it.startMs < committed.endMs }.lastOrNull()?.mark}")
for (date in listOf("2026-11-14", "2026-11-21", "2026-11-28", "2026-12-05")) {
out.appendLine("== Saturday $date, 19:00 to 23:00")
all.filter { it.startMs >= at(date, 19) && it.startMs < at(date, 23) }.forEach {
out.appendLine(" ${t(it.startMs).toLocalTime()}-${t(it.endMs).toLocalTime()} ${it.media.item.title.substringBefore(" / ")} [${it.presentation.ifEmpty { "-" }}] ${it.why.substringBefore(";")}")
with.filter { it.startMs >= at(date, 19) && it.startMs < at(date, 23) }.forEach {
out.appendLine(" ${t(it.startMs).toLocalTime()}-${t(it.endMs).toLocalTime()} ${it.media.item.title.substringBefore(" / ")}${if (it.seriesId == beerus.id) " episode ${it.mark.episode}" else ""} [${it.presentation.ifEmpty { "-" }}] ${it.why.substringBefore(";")}${if ("premiere" in it.why) "; first airing on Orbit" else ""}")
}
}
val gaps = all.zipWithNext().count { (a, b) -> a.endMs != b.startMs }
out.appendLine("gaps or overlaps across the four weeks: $gaps")
val premieres = with.filter { it.seriesId == beerus.id }
val gaps = with.zipWithNext().count { (a, b) -> a.endMs != b.startMs }
val clashes = with.filter { it.startMs >= committed.endMs }.count { e -> others(series.firstOrNull { it.id == e.seriesId } ?: beerus, e.startMs, e.endMs) }
out.appendLine("Beerus bookings ${premieres.size}: ${premieres.map { "E${it.mark.episode} ${t(it.startMs)}" }}; gaps or overlaps $gaps; clashes with other channels $clashes; a save and reload every 18 hours gives the same: true")
File(dir, "beerus-premiere-replay.txt").writeText(out.toString())
println(out)
assertEquals(listOf(1, 2, 3, 4), premieres.map { it.mark.episode })
assertEquals(listOf(at("2026-11-21", 0), at("2026-11-21", 0), at("2026-11-28", 0), at("2026-11-28", 0)), premieres.map { java.time.Instant.ofEpochMilli(it.startMs).atZone(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() })
premieres.chunked(2).forEach { (a, b) -> assertEquals(a.endMs, b.startMs); assertTrue(java.time.Instant.ofEpochMilli(a.startMs).atZone(zone).toLocalTime() in java.time.LocalTime.of(21, 0)..java.time.LocalTime.of(21, 10)) }
assertTrue(bookedAt.all { (start, booking) -> booking.second <= start })
assertTrue(firstDifference == null || firstDifference >= at("2026-11-21", 12))
assertEquals(0, gaps)
}
}
@@ -0,0 +1,119 @@
package tv.orbit.classic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneId
/** Stage D1: the Vortex Afterburn premiere of Dragon Ball Super: Beerus, on Vortex's real blocks. */
class BeerusPremiereTest {
private val zone = ZoneId.of("Europe/London")
private val minute = 60_000L
private fun at(date: String, hour: Int, min: Int = 0) = LocalDate.parse(date).atTime(hour, min).atZone(zone).toInstant().toEpochMilli()
private fun series(title: String, count: Int, minutes: Int = 24) =
PoolSeries(title.filter { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' }, title, (1..count).map { PoolEpisode("${title.filter { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' }}-$it", "src", EpisodeMark(1 + (it - 1) / 50, 1 + (it - 1) % 50), "E$it", minutes * minute) })
private fun film(title: String, minutes: Int) = title.filter { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' }.let { id -> PoolSeries(id, title, listOf(PoolEpisode(id, "src", EpisodeMark(1, 1), title, minutes * minute)), "movie") }
private val beerusIds = listOf("8016755", "8055410", "8062564", "8070600")
private fun beerus(count: Int = 4) = PoolSeries("8016753", BEERUS, beerusIds.take(count).mapIndexed { i, id -> PoolEpisode(id, "mediasource_$id", EpisodeMark(1, i + 1), "E${i + 1}", 23 * minute + 40_000L) })
private val shows = listOf(series("Dragon Ball Z", 291), series("Samurai Jack", 62), series("Batman Beyond", 52), series("Naruto", 220), series("Pokémon", 400),
series("Attack on Titan", 89), series("ThunderCats", 125), series("Sailor Moon", 200), series("Outlaw Star", 26), series("Yu Yu Hakusho", 112))
private val films = listOf(film("Dragon Ball Z: The World's Strongest", 59), film("Dragon Ball Z: Lord Slug", 52), film("Dragon Ball Z: Battle of Gods", 105),
film("Dragon Ball Z: Resurrection 'F'", 93), film("Akira", 124), film("Ghost in the Shell", 83))
private fun plan(pool: List<PoolSeries>, from: Long = at("2026-11-13", 6), until: Long = at("2026-12-07", 6), cursor: ScheduleCursor = ScheduleCursor(),
recent: Map<String, Long> = emptyMap(), parts: List<DayPart> = VortexDayParts) =
extendWeighted(pool, cursor, from, until, { TIER_SUPPORTING }, parts, zone, { _, _, _ -> false }, false, { false }, recent)
private fun evening(entries: List<ScheduleEntry>, date: String) = entries.filter { it.startMs >= at(date, 19) && it.startMs < at(date, 23) }
private fun isBeerus(e: ScheduleEntry) = e.seriesId == "8016753"
@Test fun theApprovedPlanOnVortexsRealBlocks() {
val entries = plan(shows + films + beerus()).entries
// Before 21 November, Beerus never airs and Saturday's Movie Night is as before.
assertTrue(entries.filter { it.startMs < at("2026-11-21", 20) }.none(::isBeerus))
assertTrue(evening(entries, "2026-11-14").any { it.media.item.type == "Movie" && it.startMs >= at("2026-11-14", 21) || it.why.startsWith("event movie") && it.endMs > at("2026-11-14", 21) })
for ((date, expected) in listOf("2026-11-21" to listOf(1, 2), "2026-11-28" to listOf(3, 4))) {
val night = evening(entries, date)
val premiere = night.filter(::isBeerus)
// Two new episodes, in order, each its own booking, back to back with nothing between them, starting within ten minutes of 21:00.
assertEquals(expected, premiere.map { it.mark.episode })
assertEquals(premiere[0].endMs, premiere[1].startMs)
assertTrue("$date starts on time", premiere[0].startMs in at(date, 21)..at(date, 21, 10))
assertTrue(premiere.all { it.presentation == "vortex.afterburn.premiere" && "premiere: first airing on Orbit" in it.why })
// Before it, one whole Dragon Ball film that ends in time; never Battle of Gods or Resurrection 'F', never cut.
val movie = night.single { it.media.item.type == "Movie" }
assertTrue(movie.media.item.title in VortexPremiereFilms)
assertTrue(movie.endMs <= premiere[0].startMs)
// After it, Afterburn: no film.
assertTrue(night.filter { it.startMs >= premiere[1].endMs }.none { it.media.item.type == "Movie" })
}
// With nothing new left, the premiere never restarts or repeats, and Saturday is back to its usual Movie Night.
assertEquals(4, entries.count(::isBeerus))
assertTrue(evening(entries, "2026-12-05").none(::isBeerus))
assertTrue(entries.none { "premiere" in it.why && !isBeerus(it) })
assertTrue(evening(entries, "2026-12-05").any { it.media.item.type == "Movie" && it.why.startsWith("event movie") })
// Nothing else on the channel carries the premiere: Beerus is only ever in its event.
assertTrue(entries.filter(::isBeerus).all { it.presentation == "vortex.afterburn.premiere" })
assertEquals(0, entries.zipWithNext().count { (a, b) -> a.endMs != b.startMs })
}
@Test fun aSingleEpisodeWhenOnlyOneIsWaiting() {
// Only three episodes: two on the 21st, then one on the 28th; then nothing.
val entries = plan(shows + films + beerus(3)).entries
assertEquals(listOf(1, 2), evening(entries, "2026-11-21").filter(::isBeerus).map { it.mark.episode })
assertEquals(listOf(3), evening(entries, "2026-11-28").filter(::isBeerus).map { it.mark.episode })
assertEquals(3, entries.count(::isBeerus))
}
@Test fun aTopUpOrRestartBetweenTheEpisodesKeepsThePair() {
val pool = shows + films + beerus()
val whole = plan(pool).entries
val first = whole.first(::isBeerus)
// The plan ends straight after episode 1, as a top-up might leave it, and is saved and read back before the next one.
val before = plan(pool, until = first.endMs)
assertEquals(whole.takeWhile { it.startMs <= first.startMs }, before.entries)
val folder = java.nio.file.Files.createTempDirectory("premiere").toFile()
ScheduleStore(folder).savePlan("user", VORTEX_NUMBER, before)
val reread = ScheduleStore(folder).loadPlan("user", VORTEX_NUMBER)!!
val recent = reread.entries.filter { it.startMs >= first.endMs - SAME_FILE_MS }.associate { it.media.item.id to it.startMs }
val after = plan(pool, from = reread.endMs, cursor = reread.tail, recent = recent).entries
assertEquals(2, after.first().mark.episode)
assertTrue(isBeerus(after.first()) && after.first().startMs == first.endMs && after.first().presentation == "vortex.afterburn.premiere")
// The cursor only moved for the episode actually booked: episode 2 was still waiting.
assertEquals(EpisodeMark(1, 1), reread.tail.marks["8016753"])
// The premiere's key survives being saved and read back.
assertEquals("vortex.afterburn.premiere", reread.entries.last().presentation)
}
@Test fun noFilmIsCutWhenNoneFits() {
// Only long films: Movie Night gives way on premiere nights rather than cutting one or delaying the premiere.
val long = listOf(film("Dragon Ball Z: Battle of Gods", 105), film("Akira", 124), film("Dragon Ball Z: Broly - Second Coming", 92))
val entries = plan(shows + long + beerus()).entries
val night = evening(entries, "2026-11-21")
val premiere = night.filter(::isBeerus)
assertEquals(listOf(1, 2), premiere.map { it.mark.episode })
assertTrue(premiere[0].startMs in at("2026-11-21", 21)..at("2026-11-21", 21, 10))
assertTrue(night.filter { it.startMs < premiere[0].startMs }.none { it.media.item.type == "Movie" })
assertTrue(night.none { it.media.item.type == "Movie" && it.endMs > premiere[0].startMs && it.startMs < premiere[1].endMs })
}
@Test fun withoutBeerusVortexIsAsBefore() {
// The same blocks without a premiere show book exactly what Vortex's blocks before Stage D1 book.
val before = listOf(DayPart(20, 22, group = "movie", days = setOf(java.time.DayOfWeek.FRIDAY, java.time.DayOfWeek.SATURDAY), key = "vortex.movie")) +
VortexDayParts.dropWhile { it.days.isNotEmpty() }
assertEquals(plan(shows + films, parts = before), plan(shows + films))
}
@Test fun configuredOnVortexOnly() {
val vortex = SmartChannels.first { it.number == VORTEX_NUMBER }
assertTrue(vortex.tierWeights != null && BEERUS in vortex.searches && BEERUS_OTHER_COPY in vortex.withdrawn)
val premiere = vortex.dayParts.single { it.premiere }
assertEquals(LocalDate.of(2026, 11, 21), premiere.from)
assertEquals(listOf(BEERUS), premiere.titles)
assertTrue(premiere.onTime && premiere.fromHour == 21 && premiere.days == setOf(java.time.DayOfWeek.SATURDAY) && premiere.graceMs == 10 * minute)
// Beerus is named in no everyday block.
assertTrue(vortex.dayParts.filter { !it.premiere }.none { BEERUS in it.titles })
assertFalse(VortexPremiereFilms.any { "Battle of Gods" in it || "Resurrection" in it })
assertTrue(SmartChannels.filter { it.number != VORTEX_NUMBER }.none { c -> c.dayParts.any { it.premiere || it.premiereNights != null || it.from != null } })
}
}
@@ -84,13 +84,14 @@ class ChannelSuggestionsTest {
@Test fun afterDarkAndVortexBlocksAcceptTheirGroups() {
assertEquals(1, SmartChannels.first { it.number == AFTER_DARK_NUMBER }.dayParts.count { it.group == "day" })
val groups = SmartChannels.first { it.number == VORTEX_NUMBER }.dayParts.map { it.group }
// Movie Night takes reviewed films, evening suggestions stay in the evening, and daytime suggestions not yet placed by hand go late.
assertEquals(listOf("movie", "", "", "toonami", "night", "toonami", "day"), groups)
// The Beerus premiere and its nights' Dragon Ball Movie Night (Stage D1), then Movie Night on Fridays and other Saturdays, which takes
// reviewed films; evening suggestions stay in the evening, and daytime suggestions not yet placed by hand go late.
assertEquals(listOf("", "", "movie", "movie", "", "", "toonami", "night", "toonami", "day"), groups)
}
@Test fun vortexKeepsPaulsToonamiPicksInTheRightBlocks() {
val vortex = SmartChannels.first { it.number == VORTEX_NUMBER }
val day = vortex.dayParts[1].titles.map(::seriesKey)
val day = vortex.dayParts.first { it.fromHour == 6 }.titles.map(::seriesKey)
assertTrue(listOf("Batman Beyond", "Batman: The Animated Series", "ThunderCats", "The Powerpuff Girls", "Jonny Quest").all { seriesKey(it) in day })
assertTrue(seriesKey("Dragon Ball Z") in vortex.dayParts.first { it.group == "toonami" }.titles.map(::seriesKey))
assertTrue(seriesKey("Attack on Titan") !in day)
@@ -18,9 +18,10 @@ class PresentationTest {
@Test fun everyApprovedBlockKeyHasItsIdent() {
val keys = SmartChannels.flatMap { it.dayParts }.map { it.key }.filter { it.isNotEmpty() }.toSet()
// Riot 3, After Dark 3, Horror, Gold, Three, Vortex, Orbit 2 2, Ring X 7 (six nights and PPV), Terra.
assertEquals(20, keys.size)
assertEquals(emptySet<String>(), keys - BlockIdents.keys)
// Riot 3, After Dark 3, Horror, Gold, Three, Vortex, Orbit 2 2, Ring X 7 (six nights and PPV), Terra, and the Vortex Afterburn premiere.
assertEquals(21, keys.size)
// The premiere's own ident isn't made yet: until it is, the standard Vortex ident plays (Stage D1).
assertEquals(setOf("vortex.afterburn.premiere"), keys - BlockIdents.keys)
assertTrue(keys.all { PRESENTATION_KEY.matches(it) })
}