diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a231156..acce491 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt index 12eca3d..3836bcd 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt @@ -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")), diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index ec965a2..9848a0c 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -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 = emptyMap()) { + val weights: Map = 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 fitBeforeAppointment(appointment: Long, start: Long, chosen: T, candidates: List, 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, 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, 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, at: Long): List? { + 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, 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, 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, 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 diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index c24f66c..7b8b940 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index 364dd33..7355ac0 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -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) diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 0d5e98f..6cde602 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/AfterDarkReviewTest.kt b/android/app/src/test/java/tv/orbit/classic/AfterDarkReviewTest.kt index 52bc51f..783e4e0 100644 --- a/android/app/src/test/java/tv/orbit/classic/AfterDarkReviewTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/AfterDarkReviewTest.kt @@ -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() { diff --git a/android/app/src/test/java/tv/orbit/classic/BeerusPremiereReplayTest.kt b/android/app/src/test/java/tv/orbit/classic/BeerusPremiereReplayTest.kt index 810a725..4ae7074 100644 --- a/android/app/src/test/java/tv/orbit/classic/BeerusPremiereReplayTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/BeerusPremiereReplayTest.kt @@ -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= ./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, vortex: ChannelProgramming, avoid: ProgrammeClash, restartEvery: Int = 0): Pair, Map>> { 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>() + 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) } } diff --git a/android/app/src/test/java/tv/orbit/classic/BeerusPremiereTest.kt b/android/app/src/test/java/tv/orbit/classic/BeerusPremiereTest.kt new file mode 100644 index 0000000..51e1c85 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/BeerusPremiereTest.kt @@ -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, from: Long = at("2026-11-13", 6), until: Long = at("2026-12-07", 6), cursor: ScheduleCursor = ScheduleCursor(), + recent: Map = emptyMap(), parts: List = VortexDayParts) = + extendWeighted(pool, cursor, from, until, { TIER_SUPPORTING }, parts, zone, { _, _, _ -> false }, false, { false }, recent) + private fun evening(entries: List, 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 } }) + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt index 5810e3f..76cec2d 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt @@ -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) diff --git a/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt b/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt index 6232cdf..771cd3a 100644 --- a/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PresentationTest.kt @@ -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(), 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) }) } diff --git a/docs/VERIFICATION-0.99.16.md b/docs/VERIFICATION-0.99.16.md index 3f9123a..8f520f3 100644 --- a/docs/VERIFICATION-0.99.16.md +++ b/docs/VERIFICATION-0.99.16.md @@ -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. diff --git a/docs/VERIFICATION-0.99.17.md b/docs/VERIFICATION-0.99.17.md new file mode 100644 index 0000000..663f08e --- /dev/null +++ b/docs/VERIFICATION-0.99.17.md @@ -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. diff --git a/docs/replays/beerus-premiere-replay-0.99.17.txt b/docs/replays/beerus-premiere-replay-0.99.17.txt new file mode 100644 index 0000000..fde659a --- /dev/null +++ b/docs/replays/beerus-premiere-replay-0.99.17.txt @@ -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 diff --git a/docs/replays/replay-fingerprints-0.99.17.txt b/docs/replays/replay-fingerprints-0.99.17.txt new file mode 100644 index 0000000..9037730 --- /dev/null +++ b/docs/replays/replay-fingerprints-0.99.17.txt @@ -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