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) })
}
+21
View File
@@ -65,3 +65,24 @@ Approved by Paul at 15:35 ("yes install", replying to the D2 release). Another a
- run the playback test on Beerus, where English should be selected with the setting off;
- run it on an English episode, where none should be selected;
- check ordinary channel changes.
## On-box check (4 November 2026, 15:52 to 16:06 GMT)
Paul said the TV was idle at 15:50. Paul's subtitles setting is off. These were run with 0.99.16's playback test, which starts from the setting as a channel does.
| | Beerus episode 1 (Emby 8016755) | Friends episode 1 (Emby 6947925): the English regression |
|---|---|---|
| Picture | video/avc 1920×1080 | video/dolby-vision 3840×2160 |
| Sound | AAC, Japanese ("ja"), 2 channels | E-AC3, English, 6 channels |
| Subtitle tracks | 4 (English, Portuguese, Spanish, external English) | 10 (English ×2, Danish, Finnish, Norwegian, Swedish) |
| Selected | **English, automatically** | **none**, as before |
| Subtitle updates | 34 (first at 15.1 s) | 0 |
| Errors | 0 | 0 |
- **Friends launch:** the first attempt didn't start, because the box's home screen came to the front as the old Orbit process was force-stopped. Orbit was stopped and opened again, and the second attempt ran.
- **Orbit afterwards:** it resumed normally. All 30 channels topped up twice with \`added=0\` and \`clashes=0\`, with no player, decoder or crash errors.
- **Files:**
- The settings file is byte-identical.
- Every plan entry, presentation key and plan cursor is unchanged. The plans only dropped programmes that ended more than 24 hours ago.
- 33 aired places moved, each to the latest programme of its show already started.
- **Paul's view on the TV:** to be confirmed.
+77
View File
@@ -0,0 +1,77 @@
# Orbit TV 0.99.17: the Vortex Afterburn premiere of Dragon Ball Super: Beerus (Stage D1)
Paul approved building and verifying this on 4 November 2026 at 15:28. Installing it needs its own approval. The change is to Vortex's scheduling only. Every other channel's bookings are identical, and nothing already committed is rewritten. It includes 0.99.16 (D2, already installed).
## What's in it
- **Beerus on Vortex, pinned to Emby 8016753.**
- "Dragon Ball Super: Beerus" is on Vortex's list.
- The other copy, 8055740, is withdrawn from Vortex. A withdrawn copy is now never read as a stand-in for the chosen one.
- Beerus is named only in its premiere event, never in an everyday block.
- **The premiere event** (VortexDayParts):
- Saturdays at 21:00 from **21 November 2026**, as an on-time appointment with a **10-minute** allowance (`graceMs`).
- Presentation key `vortex.afterburn.premiere`, with the reason "premiere: first airing on Orbit".
- **New scheduler rules** (DayPart `from`, `premiere`, `premiereDouble`, `premiereBuffer`, `premiereNights`, `graceMs`; weighted channels; only Vortex sets them):
- **Before its start date,** the event gives way to the usual block.
- **First airings only:** it shows only episodes after the cursor. With none left it gives way, so it never restarts or repeats one as new.
- **Two a night** while two or more new episodes are waiting at the start of the night, otherwise one. Each episode is its own booking, with its own episode, cursor step and reason. Nothing is merged.
- **The second episode follows the first straight away,** whatever the hour, including across a top-up or a save and reload. It's counted from the bookings of that night.
- **The cursor moves only when an episode is booked,** as before.
- **Movie Night on premiere nights:** 20:00 to 21:00, one whole Dragon Ball film from Vortex's pool, from a list of 17 Dragon Ball films of about an hour or less. Battle of Gods and Resurrection 'F' are left out.
- Only a film that ends by the premiere's allowance can start, and nothing is cut.
- If none fits, Movie Night gives way to the evening block, and the premiere still starts on time.
- On other Saturdays and every Friday, Movie Night is exactly as before.
- **Presentation keys** may now have three parts, so `vortex.afterburn.premiere` is saved and read back. Older versions ignore an unexpected key, so a rollback just shows the standard ident.
- The premiere has no ident of its own yet. `blockIdentFor` finds none for it, so the standard Vortex ident plays.
- **Version:** 0.99.17 (versionCode 147).
## Checks before install
- **455 unit tests pass** (2 skipped: hand-run replays that need input files). Lint has no errors.
- **Six new tests in BeerusPremiereTest** (Vortex's real blocks):
- The approved plan:
- no Beerus before 21 November, and 14 November's Movie Night as before;
- 21 November: episodes 1 and 2; 28 November: episodes 3 and 4;
- each pair back to back, starting within 21:00 to 21:10, with the key and the first-airing reason;
- before it, one whole film from the list that ends in time; after it, no film;
- 5 December: no Beerus, no repeat, normal Movie Night;
- no gaps.
- With three episodes: two, then one, then nothing.
- A top-up split after episode 1, with a save and reload: episode 2 follows straight away with the key, and the cursor had moved only for episode 1.
- With only long films, no film is cut and the premiere starts on time.
- Without Beerus, Vortex books exactly what its blocks before D1 booked.
- Configured on Vortex only: start date, title, time, allowance, pinned copy and the excluded films.
- **Existing tests updated for the new blocks:**
- Vortex's block groups (ChannelSuggestionsTest);
- the one other withdrawal, Vortex's second Beerus copy (AfterDarkReviewTest);
- the presentation key list: 21 keys, with the premiere's the only one without an ident, by design (PresentationTest).
- **Fresh box data** (box files copied at 15:44 today; docs/replays/beerus-premiere-replay-0.99.17.txt, BeerusPremiereReplayTest):
- Four weeks of Vortex top-ups every six hours, with the box's own committed plan and avoiding every other channel's committed programmes.
- Beerus's four episodes are as the box inspected them. The exact seconds weren't in that read, so each is taken as 23:40.
| | Result |
|---|---|
| Committed plan (to 11 November 13:22) | kept exactly |
| With Beerus against without | identical until Saturday 21 November 19:57 |
| 21 November | 20:27 Dragon Ball Z: Dead Zone (whole), 21:08 episode 1, 21:32 episode 2, then Afterburn |
| 28 November | 20:08 Dragon Ball Z: Wrath of the Dragon, 21:00 episode 3, 21:24 episode 4, then Afterburn |
| 5 December | no Beerus; normal Movie Night (Bojack Unbound, The World's Strongest) |
| When booked | 21 November at the top-up of 15 November 15:13, when the plan ended 21 November 09:15. Episode 4 was booked at a later top-up, straight after episode 3. Every slot was uncommitted when booked. |
| Dragon Ball Super (2015) | its Beerus saga ends with S1E14 on 20 November 02:12, and S2E1 follows on 22 November |
| Gaps, clashes with other channels | 0, 0 |
| Save and reload every 18 hours | identical |
- **Every other channel:** the four-week replay of all 30 channels on the fresh files is identical between 0.99.16 and 0.99.17 on every channel, committed and four weeks (docs/replays/replay-fingerprints-0.99.17.txt).
- Vortex is identical too, because its pool on the box doesn't hold Beerus yet. Beerus joins at Vortex's first pool read after install, and then only Saturdays from 21 November change, as above.
## Known limits
- **Install by about 14 November.** If it's installed later, the premiere starts on the first Saturday not yet booked. 21 November would keep what's already committed, and nothing is rewritten.
- **Vortex's next refresh:** Vortex's list changes, so its pool is read again at its next top-up. That's a normal full Vortex pool read.
- **First airings:** these are counted from Vortex's own cursor. If the library gains episode 5 and later, they continue weekly, two at a time while two or more are waiting.
- **Simulated dates:** the Dragon Ball Super (2015) dates are from the replay. Exact times can shift slightly as other channels top up.
- **Not covered by the replays:** this is tested as scheduling logic. What airs needs checking on the box: the booking appearing around 15 November, then the night itself.
## Rollback
Install 0.99.16. Bookings made by 0.99.17 stay as booked, because the never-rewrite rule applies, and their premiere key is ignored by older readers. Beerus would stay booked on those nights, without the key.
@@ -0,0 +1,45 @@
Stage D1 replay: Vortex as built, from the box's files (committed 2026-11-03T15:13:54 to 2026-11-11T13:22:31), four weeks of top-ups every six hours, avoiding other channels' committed programmes.
with and without Beerus, identical until 2026-11-21T19:57:30
premiere episode booked for 2026-11-21T21:08:34 at the top-up of 2026-11-15T15:13:54; the plan then ended 2026-11-21T09:15:21, so the slot was uncommitted: true
premiere episode booked for 2026-11-21T21:32:14 at the top-up of 2026-11-15T15:13:54; the plan then ended 2026-11-21T09:15:21, so the slot was uncommitted: true
premiere episode booked for 2026-11-28T21:00:20 at the top-up of 2026-11-21T21:13:54; the plan then ended 2026-11-27T15:35, so the slot was uncommitted: true
premiere episode booked for 2026-11-28T21:24 at the top-up of 2026-11-23T03:13:54; the plan then ended 2026-11-28T21:24, so the slot was uncommitted: true
Dragon Ball Super (2015) Beerus saga (season 1, 14 episodes): last episode S1E14 at 2026-11-20T02:12:42; first after it S2E1 at 2026-11-22T00:28:35; committed plan holds up to EpisodeMark(season=1, episode=6)
== Saturday 2026-11-14, 19:00 to 23:00
19:05:49-19:30:06 Mobile Suit Zeta Gundam [-] block night
19:30:06-19:54 Mobile Suit Gundam SEED [-] block night
19:54-20:18:27 Nobunaga the Fool [-] block night
20:18:27-21:10:15 Dragon Ball Z: Wrath of the Dragon [vortex.movie] event movie
21:10:15-21:51:29 Dragon Ball Z: Dead Zone [vortex.movie] event movie
21:51:29-22:43:30 Dragon Ball Z: Lord Slug [vortex.movie] event movie
22:43:30-23:05:47 ThunderCats [-] block toonami
== Saturday 2026-11-21, 19:00 to 23:00
19:09:15-19:33:23 Dragon Ball GT [-] block night
19:33:23-19:57:30 Sailor Moon [-] block night
19:57:30-20:27:21 Tenchi Muyo! Ryo-Ohki [-] block night
20:27:21-21:08:34 Dragon Ball Z: Dead Zone [vortex.movie] event 20-21
21:08:34-21:32:14 Dragon Ball Super: Beerus episode 1 [vortex.afterburn.premiere] event 21-22; first airing on Orbit
21:32:14-21:55:54 Dragon Ball Super: Beerus episode 2 [vortex.afterburn.premiere] event 21-22; first airing on Orbit
21:55:54-22:18:02 Yu-Gi-Oh! Duel Monsters [-] block toonami
22:18:02-22:40:20 ThunderCats [-] block toonami
22:40:20-23:10:16 Tenchi Muyo! Ryo-Ohki [-] block toonami
== Saturday 2026-11-28, 19:00 to 23:00
19:15:21-19:39:34 Sailor Moon [-] block night
19:39:34-20:08:32 Tenchi Muyo! Ryo-Ohki [-] block night
20:08:32-21:00:20 Dragon Ball Z: Wrath of the Dragon [vortex.movie] event 20-21
21:00:20-21:24 Dragon Ball Super: Beerus episode 3 [vortex.afterburn.premiere] event 21-22; first airing on Orbit
21:24-21:47:40 Dragon Ball Super: Beerus episode 4 [vortex.afterburn.premiere] event 21-22; first airing on Orbit
21:47:40-22:09:56 ThunderCats [-] block toonami
22:09:56-22:35:08 The Big O [-] block toonami
22:35:08-22:58:09 Pokémon [-] block toonami
22:58:09-23:23:09 Cardcaptor Sakura [-] block toonami
== Saturday 2026-12-05, 19:00 to 23:00
19:01:38-19:25:52 Dragon Ball Z [-] block night
19:25:52-19:47:16 Yu-Gi-Oh! Duel Monsters [-] block night
19:47:16-20:11:16 Mobile Suit Gundam Wing [-] block night
20:11:16-21:01:40 Dragon Ball Z: Bojack Unbound [vortex.movie] event movie
21:01:40-22:01:11 Dragon Ball Z: The World's Strongest [vortex.movie] event movie
22:01:11-22:26:27 The Big O [-] block toonami
22:26:27-22:52:06 Outlaw Star [-] block toonami
22:52:06-23:16:19 Sailor Moon [-] block toonami
Beerus bookings 4: [E1 2026-11-21T21:08:34, E2 2026-11-21T21:32:14, E3 2026-11-28T21:00:20, E4 2026-11-28T21:24]; gaps or overlaps 0; clashes with other channels 0; a save and reload every 18 hours gives the same: true
@@ -0,0 +1,62 @@
--- 0.99.16 (commit 0d2ea65), box files from 4 November 15:44
101 committed=a29a6565a9152757 kept=true fourWeeks=fc2dc64d70b2604d programmes=2161
102 committed=eedfc214aa5f5ba6 kept=true fourWeeks=9d0edd9e8753e876 programmes=1313
103 committed=e807e4e9eaec9e5e kept=true fourWeeks=b1487ed77ae76683 programmes=1808
104 committed=f4ce83ef8be37e51 kept=true fourWeeks=bd73ba4f2a9e7fa2 programmes=1723
105 committed=024393931783c210 kept=true fourWeeks=582048188ae9acd1 programmes=2540
106 committed=7e963ca86fe58ce0 kept=true fourWeeks=d410477fe1fe4178 programmes=1142
107 committed=9a0c61745c574ad4 kept=true fourWeeks=226cf74cfd9b69c4 programmes=1006
201 committed=9b236f40b63be66a kept=true fourWeeks=506952ece7a15e99 programmes=956
202 committed=c74b41f30ee7d2d5 kept=true fourWeeks=e07fafae6bdbf493 programmes=1124
203 committed=eecf43fad5df1742 kept=true fourWeeks=8bb571bf107cd854 programmes=1130
204 committed=dfe36fb6df45b11c kept=true fourWeeks=30eee87fdd9cc87f programmes=1253
301 committed=a60230082d64c730 kept=true fourWeeks=e52b5c61079837aa programmes=359
302 committed=63451c5e20f4ac7d kept=true fourWeeks=c95e938f667359e5 programmes=442
303 committed=a8f13e22455a5da9 kept=true fourWeeks=a4aad9cf93bab051 programmes=416
304 committed=54c130b5c64e8b7e kept=true fourWeeks=15c34fc89631f0b0 programmes=490
305 committed=4078f134215f37bb kept=true fourWeeks=97e4f77b7fc48b9d programmes=500
306 committed=9efe3fbd598311c3 kept=true fourWeeks=49dc6256788603ad programmes=489
307 committed=8adb935ea55ff9a2 kept=true fourWeeks=ff4450b5f41cfd0f programmes=438
308 committed=c032d416971fe6c8 kept=true fourWeeks=dcc5c2e0072a75f3 programmes=514
309 committed=5fd2f41b64189808 kept=true fourWeeks=8e00279866f1956d programmes=498
401 committed=9d7ea89a72686ffb kept=true fourWeeks=8b66b2d9cc8b06df programmes=874
601 committed=78654495e4d87e5d kept=true fourWeeks=853ee8c2ace232c8 programmes=2188
602 committed=012e3694020099bd kept=true fourWeeks=e812832c0285db95 programmes=2321
603 committed=884e044218a1aeb1 kept=true fourWeeks=1673cd14543fcfa3 programmes=2300
604 committed=97b2c8367d71770e kept=true fourWeeks=0f199d8d19bf9b0a programmes=3232
605 committed=505c511e9e7a9b00 kept=true fourWeeks=209ec32891b12e18 programmes=4480
611 committed=25db66cdf1e9517c kept=true fourWeeks=98027cb9d12fa94a programmes=2732
612 committed=8a0db30b6ded8cb0 kept=true fourWeeks=5fff983191812a54 programmes=575
613 committed=e072fd42fa7bec53 kept=true fourWeeks=98e5dea86bb07759 programmes=518
614 committed=62abd42bbc465e20 kept=true fourWeeks=0f8cb8079f37e63b programmes=3822
--- 0.99.17
101 committed=a29a6565a9152757 kept=true fourWeeks=fc2dc64d70b2604d programmes=2161
102 committed=eedfc214aa5f5ba6 kept=true fourWeeks=9d0edd9e8753e876 programmes=1313
103 committed=e807e4e9eaec9e5e kept=true fourWeeks=b1487ed77ae76683 programmes=1808
104 committed=f4ce83ef8be37e51 kept=true fourWeeks=bd73ba4f2a9e7fa2 programmes=1723
105 committed=024393931783c210 kept=true fourWeeks=582048188ae9acd1 programmes=2540
106 committed=7e963ca86fe58ce0 kept=true fourWeeks=d410477fe1fe4178 programmes=1142
107 committed=9a0c61745c574ad4 kept=true fourWeeks=226cf74cfd9b69c4 programmes=1006
201 committed=9b236f40b63be66a kept=true fourWeeks=506952ece7a15e99 programmes=956
202 committed=c74b41f30ee7d2d5 kept=true fourWeeks=e07fafae6bdbf493 programmes=1124
203 committed=eecf43fad5df1742 kept=true fourWeeks=8bb571bf107cd854 programmes=1130
204 committed=dfe36fb6df45b11c kept=true fourWeeks=30eee87fdd9cc87f programmes=1253
301 committed=a60230082d64c730 kept=true fourWeeks=e52b5c61079837aa programmes=359
302 committed=63451c5e20f4ac7d kept=true fourWeeks=c95e938f667359e5 programmes=442
303 committed=a8f13e22455a5da9 kept=true fourWeeks=a4aad9cf93bab051 programmes=416
304 committed=54c130b5c64e8b7e kept=true fourWeeks=15c34fc89631f0b0 programmes=490
305 committed=4078f134215f37bb kept=true fourWeeks=97e4f77b7fc48b9d programmes=500
306 committed=9efe3fbd598311c3 kept=true fourWeeks=49dc6256788603ad programmes=489
307 committed=8adb935ea55ff9a2 kept=true fourWeeks=ff4450b5f41cfd0f programmes=438
308 committed=c032d416971fe6c8 kept=true fourWeeks=dcc5c2e0072a75f3 programmes=514
309 committed=5fd2f41b64189808 kept=true fourWeeks=8e00279866f1956d programmes=498
401 committed=9d7ea89a72686ffb kept=true fourWeeks=8b66b2d9cc8b06df programmes=874
601 committed=78654495e4d87e5d kept=true fourWeeks=853ee8c2ace232c8 programmes=2188
602 committed=012e3694020099bd kept=true fourWeeks=e812832c0285db95 programmes=2321
603 committed=884e044218a1aeb1 kept=true fourWeeks=1673cd14543fcfa3 programmes=2300
604 committed=97b2c8367d71770e kept=true fourWeeks=0f199d8d19bf9b0a programmes=3232
605 committed=505c511e9e7a9b00 kept=true fourWeeks=209ec32891b12e18 programmes=4480
611 committed=25db66cdf1e9517c kept=true fourWeeks=98027cb9d12fa94a programmes=2732
612 committed=8a0db30b6ded8cb0 kept=true fourWeeks=5fff983191812a54 programmes=575
613 committed=e072fd42fa7bec53 kept=true fourWeeks=98e5dea86bb07759 programmes=518
614 committed=62abd42bbc465e20 kept=true fourWeeks=0f8cb8079f37e63b programmes=3822