0.111.0 candidate: Paul's approved premiere dates as gated editorial slots, with editorial competition
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m13s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m41s

Avatar: Fire and Ash on Friday 27 November and Spider-Man: Brand New Day on
Friday 11 December, both 20:00 on Orbit Premiere (approved by Paul on 5
November 2026; Saturday 28 November stays the Arnie Triple Bill's). Each
debut is a fixed slot like Advent's doors, from the film verified on the box
by item, IMDb ID and decoder (read and logged also while booking is off).
Premiere carries the film from its debut (both keep Premiere eligibility)
and its genre home from the end of the window; until then no other channel
shows it. All gated by PREMIERE_BOOKING_ACTIVE, still off: approved dates
aren't permission to book.

Editorial competition (Paul): a debut never shares its evening's airtime
with another headline event (Drive-In events, Advent doors, Naughty List
nights, other premieres), even without a technical clash.

Release check (PremiereReplayTest, box copies of 5 November, premieres on):
both debuts booked on their evenings, no competing events, no showing
before its time, 193+ h between Premiere repeats, all 25 Advent doors and 6
Naughty nights booked, no overlaps or duplicate titles, committed plans
unchanged. Built on 0.108.11's features; versionCode 172.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 16:34:05 +00:00
co-authored by Claude Opus 5.5
parent 655b6585b2
commit 25e6d4f669
8 changed files with 275 additions and 15 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 160
versionName = "0.110.0-naughty-list"
versionCode = 172
versionName = "0.111.0-advent-premieres"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -145,6 +145,22 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
}
}
/** The approved premieres' films, verified by item, IMDb ID and decoder, read at most once a day while any window is still ahead. */
private fun premiereFilms(userId: String, session: EmbySampleSession, now: Long): List<PoolSeries> {
if (ApprovedDebuts.isEmpty()) return emptyList()
val signature = "premieres:" + ApprovedDebuts.joinToString(",") { it.film.itemId }
val saved = store.loadPool(userId, PREMIERE_POOL)?.takeIf { it.signature == signature }
if (saved != null && now - saved.fetchedAtMs < 24 * 60 * 60_000L) return saved.series
return try {
val checks = session.readFilmChecks(ApprovedDebuts.map { it.film to null })
Log.i("OrbitSchedule", "premiere films=${checks.size} active=$PREMIERE_BOOKING_ACTIVE " + checks.values.groupingBy { it.reason }.eachCount().entries.joinToString(" ") { "${it.key.replace(' ', '_')}=${it.value}" })
withoutFailed(checks.values.mapNotNull { it.film }, store.loadFailed(userId)).also { store.savePool(userId, PREMIERE_POOL, ScheduleStore.SavedPool(it, now, signature)) }
} catch (failure: EmbyCheckException) {
Log.w("OrbitSchedule", "premiere films failed=${failure.javaClass.simpleName} status=${failure.httpStatus}")
saved?.series.orEmpty()
}
}
/**
* The curated events' verified films, read at most once a day, and only when the Drive-In is about to book. A read that
* fails keeps the last verified list; with no session, the saved list is used as it is.
@@ -239,15 +255,20 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
clashesWithAirings((SmartChannels.map { it.number }.filter { it != number } + DRIVE_IN_NUMBER).flatMap { airingsOn(userId, it) })
.also { othersMs = android.os.SystemClock.elapsedRealtime() - began }
}
// The approved premieres' films, verified on the box (read only, also while booking is off, so the decoder check is known first).
val premieres = if (programming.filmRestMs != null) premiereFilms(userId, session, now).associateBy { it.id } else emptyMap()
// Advent on Orbit: this channel's doors as fixed slots, from films verified on the box; every film channel holds them back.
val fixed = (if (programming.filmRestMs != null && (AdventDoors.any { it.channel == number } || number == AFTER_DARK_NUMBER))
adventFilms(userId, session, now).associateBy { it.id }.let { films -> adventSlots(number, films) + naughtySlots(number, films) } else emptyList()) +
// Premiere's debuts, placed from the Director's Chair: fixed first showings that only append.
(if (number == PREMIERE_NUMBER) premiereSlots(loadPremiereRuns(store.premiereRunsFile()), pool?.series.orEmpty().associateBy { it.id }) else emptyList())
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
(if (number == PREMIERE_NUMBER) premiereSlots(loadPremiereRuns(store.premiereRunsFile()), pool?.series.orEmpty().associateBy { it.id }) else emptyList()) +
// Paul's approved premiere dates, from films verified on the box; nothing while premiere booking is off.
(if (number == PREMIERE_NUMBER && premieres.isNotEmpty()) approvedDebutSlots(premieres) else emptyList())
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it + premiereRotation(number, premieres, now).filter { p -> it.none { s -> s.id == p.id } }, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes, mixed = programming::mixes,
weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping,
fixed = fixed, held = seasonalHeld()) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
fixed = fixed, held = seasonalHeld().let { seasonal -> val premiere = premiereHeld(number); { f: PoolSeries, at: Long -> seasonal(f, at) || premiere(f, at) } })
?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
if (plan !== existing) plan.entries.filter { (it.why.startsWith(ADVENT_WHY) || it.why.startsWith(NAUGHTY_WHY)) && existing?.entries?.none { e -> e.startMs == it.startMs } != false }
.forEach { Log.i("OrbitSchedule", "channel=$number ${if (it.why.startsWith(ADVENT_WHY)) "advent door=" + it.why.removePrefix(ADVENT_WHY).substringBefore(';') else "naughty"} booked lateMin=${(it.startMs - (fixed.firstOrNull { f -> f.film.id == it.seriesId }?.startMs ?: it.startMs)) / 60_000}") }
if (plan !== existing) store.savePlan(userId, number, plan)
@@ -44,14 +44,15 @@ private fun savePremiereRuns(file: File, runs: List<PremiereRun>) {
/**
* The first Friday or Saturday at 20:00 after Premiere's committed plan ([planEndMs]), so the debut only ever appends; kept clear
* of [otherSlots] (Advent doors, other debuts). Null if none within three weeks.
* of [otherSlots] (Advent doors, other debuts) and of any evening another headline event competes for ([competingEvent]). Null if
* none within three weeks.
*/
internal fun debutSlot(planEndMs: Long, nowMs: Long, otherSlots: List<Long>, zone: ZoneId = ZoneId.systemDefault()): Long? {
var day = Instant.ofEpochMilli(maxOf(planEndMs, nowMs)).atZone(zone).toLocalDate()
repeat(21) {
if (day.dayOfWeek == DayOfWeek.FRIDAY || day.dayOfWeek == DayOfWeek.SATURDAY) {
val at = day.atTime(PREMIERE_DEBUT_AT).atZone(zone).toInstant().toEpochMilli()
if (at > planEndMs && at > nowMs && otherSlots.none { kotlin.math.abs(it - at) < DEBUT_GAP_MS }) return at
if (at > planEndMs && at > nowMs && otherSlots.none { kotlin.math.abs(it - at) < DEBUT_GAP_MS } && competingEvent(at, at + 3 * 60 * 60_000L, zone) == null) return at
}
day = day.plusDays(1)
}
@@ -104,3 +105,65 @@ internal fun graduatePremieres(store: ScheduleStore, userId: String, nowMs: Long
savePremiereRuns(store.premiereRunsFile(), runs.map { r -> if (due.any { it.id == r.id }) PremiereRun(r.id, r.title, r.home, r.debutMs, r.untilMs, true) else r })
setOf(PREMIERE_NUMBER) + due.map { it.home }
}
/**
* Paul's approved premiere dates (5 November 2026): each approved film's debut on Orbit Premiere, as an editorial fixed slot
* like Advent's doors, pinned by library item and IMDb ID and verified on the box (decoder included) before it can be booked.
* Approved dates aren't permission to book: nothing here books, holds or lists anything until [PREMIERE_BOOKING_ACTIVE] is
* switched on with Paul's approval.
*/
internal class ApprovedDebut(val call: PremiereCall, val film: EventFilm, val at: java.time.LocalDateTime, val windowMs: Long = PREMIERE_WINDOW_MS) {
fun startMs(zone: ZoneId) = at.atZone(zone).toInstant().toEpochMilli()
fun windowEndMs(zone: ZoneId) = startMs(zone) + windowMs
}
internal val ApprovedDebuts: List<ApprovedDebut> = listOf(
ApprovedDebut(PremiereCalls.single { it.itemId == "7789551" }, EventFilm("Avatar: Fire and Ash", 2025, "tt1757678", "7789551"), java.time.LocalDateTime.of(2026, 11, 27, 20, 0)),
ApprovedDebut(PremiereCalls.single { it.itemId == "8006401" }, EventFilm("Spider-Man: Brand New Day", 2026, "tt22084616", "8006401"), java.time.LocalDateTime.of(2026, 12, 11, 20, 0)))
/** Where the approved premieres' verified films are kept, beside the channels' own pools. Not a channel. */
internal const val PREMIERE_POOL = 9301
/** Premiere's approved debuts as fixed slots, for the films verified on the box ([films], by item). None while booking is off. */
internal fun approvedDebutSlots(films: Map<String, PoolSeries>, zone: ZoneId = ZoneId.systemDefault(), active: Boolean = PREMIERE_BOOKING_ACTIVE): List<FixedSlot> =
if (!active) emptyList() else ApprovedDebuts.mapNotNull { d ->
films[d.film.itemId]?.let { FixedSlot(d.startMs(zone), it, "$PREMIERE_WHY; approved by Paul on 5 November 2026", "") }
}
/**
* What the approved premieres add to [channel]'s rotation at [nowMs]: Premiere carries a film from its debut through its window
* (and after, when it keeps Premiere eligibility); its genre home carries it once the window ends. Nothing while booking is off.
*/
internal fun premiereRotation(channel: Int, films: Map<String, PoolSeries>, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(), active: Boolean = PREMIERE_BOOKING_ACTIVE): List<PoolSeries> =
if (!active) emptyList() else ApprovedDebuts.mapNotNull { d ->
val on = when (channel) {
PREMIERE_NUMBER -> nowMs >= d.startMs(zone) && (nowMs < d.windowEndMs(zone) || d.call.keepsPremiere)
d.call.home -> nowMs >= d.windowEndMs(zone)
else -> false
}
if (on) films[d.film.itemId] else null
}
/**
* Keeps each approved premiere's first run its own: on Premiere, its film stays out of rotation before its debut; on every other
* channel, until its window ends. The debut itself is a fixed slot, not rotation, so it isn't held.
*/
internal fun premiereHeld(channel: Int, zone: ZoneId = ZoneId.systemDefault()): (PoolSeries, Long) -> Boolean = { film, atMs ->
ApprovedDebuts.any { d -> (film.id == d.film.itemId || seriesKey(film.title) == seriesKey(d.film.title)) &&
atMs < if (channel == PREMIERE_NUMBER) d.startMs(zone) else d.windowEndMs(zone) }
}
/**
* Editorial competition (Paul, 5 November 2026): two programmes don't have to clash technically to compete for the same audience.
* Whether an evening slot from [startMs] to [endMs] shares its airtime with another headline event anywhere on Orbit: a Drive-In
* event, an Advent door, a Naughty List night or another approved premiere. Returns what it competes with, or null.
*/
internal fun competingEvent(startMs: Long, endMs: Long, zone: ZoneId = ZoneId.systemDefault(), except: String? = null): String? {
fun ms(t: java.time.LocalDateTime) = t.atZone(zone).toInstant().toEpochMilli()
val hour = 60 * 60_000L
val events = DriveInEvents.map { e -> Triple("the Drive-In's ${e.name}", ms(e.evening.atTime(e.hour, e.minute)), ms(e.evening.atTime(e.hour, e.minute)) + e.films.size * 2 * hour) } +
AdventDoors.map { d -> Triple("Advent's Door ${d.door}", ms(d.at), ms(d.at) + 2 * hour) } +
NaughtyList.map { n -> Triple("The Naughty List's ${n.film.title}", ms(n.at), ms(n.at) + 2 * hour) } +
ApprovedDebuts.filter { it.film.itemId != except }.map { d -> Triple("the premiere of ${d.film.title}", d.startMs(zone), d.startMs(zone) + 3 * hour) }
return events.firstOrNull { (_, s, e) -> s < endMs && startMs < e }?.first
}
@@ -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.110.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.111.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -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.110.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.111.0")
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()) {
@@ -47,14 +47,37 @@ class DirectorPremiereTest {
assertNotNull(report(OrbitStatus.Never, studios = listOf("CAPCOM")).premieres.singleOrNull())
}
@Test fun theDebutIsAFridayOrSaturdayNightAfterTheCommittedPlan() {
@Test fun theDebutIsAFridayOrSaturdayNightAfterTheCommittedPlanAndNoOtherHeadline() {
// Editorial competition: Hammer (Fri 13), Universal Monsters (Sat 14) and Shaw Brothers (Fri 20) own their evenings.
val debut = debutSlot(at(11, 12, 6), at(11, 5, 12), emptyList(), london)!!
val t = Instant.ofEpochMilli(debut).atZone(london)
assertEquals(DayOfWeek.FRIDAY, t.dayOfWeek); assertEquals(13, t.dayOfMonth); assertEquals(20, t.hour)
// Kept clear of another fixed slot that night: Saturday instead.
assertEquals(at(11, 14, 20), debutSlot(at(11, 12, 6), at(11, 5, 12), listOf(at(11, 13, 18)), london))
// Never inside the committed plan, even on a Friday.
assertEquals(at(11, 14, 20), debutSlot(at(11, 13, 21), at(11, 5, 12), emptyList(), london))
assertEquals(DayOfWeek.SATURDAY, t.dayOfWeek); assertEquals(21, t.dayOfMonth); assertEquals(20, t.hour)
assertNotNull(competingEvent(at(11, 13, 20), at(11, 13, 23), london)); assertNull(competingEvent(debut, debut + 3 * 3_600_000L, london))
// With Saturday 21 taken by a fixed slot, Avatar's approved evening (Fri 27) and the Arnie Triple Bill (Sat 28) fill the
// rest of the three weeks it looks ahead: no debut is offered, rather than one that competes.
assertNull(debutSlot(at(11, 12, 6), at(11, 5, 12), listOf(at(11, 21, 18)), london))
// Never inside the committed plan.
assertTrue(debutSlot(at(11, 21, 21), at(11, 5, 12), emptyList(), london)!! > at(11, 21, 21))
}
@Test fun approvedDatesAreTheirOwnEvenings() {
val avatar = ApprovedDebuts.single { it.film.itemId == "7789551" }; val spidey = ApprovedDebuts.single { it.film.itemId == "8006401" }
assertEquals(at(11, 27, 20), avatar.startMs(london)); assertEquals(at(12, 11, 20), spidey.startMs(london))
assertNull(competingEvent(avatar.startMs(london), avatar.startMs(london) + 197 * minute, london, except = avatar.film.itemId))
assertNull(competingEvent(spidey.startMs(london), spidey.startMs(london) + 145 * minute, london, except = spidey.film.itemId))
// Saturday 28 November stays the Arnie Triple Bill's.
assertTrue(competingEvent(at(11, 28, 20), at(11, 28, 22), london)!!.contains("Arnie"))
// Booking off: no slots, no rotation; the first run is still protected from other channels until the window ends.
val film = PoolSeries("7789551", "Avatar: Fire and Ash", emptyList())
assertTrue(approvedDebutSlots(mapOf("7789551" to film), london).isEmpty()); assertTrue(premiereRotation(303, mapOf("7789551" to film), at(12, 30, 12), london).isEmpty())
assertTrue(premiereHeld(303, london)(film, at(12, 20, 12))); assertFalse(premiereHeld(303, london)(film, at(12, 26, 12)))
assertFalse(premiereHeld(PREMIERE_NUMBER, london)(film, at(12, 1, 12)))
// Active: the debut is a slot, Premiere carries it from then on (it keeps Premiere eligibility), Sci-Fi after the window.
assertEquals(1, approvedDebutSlots(mapOf("7789551" to film), london, active = true).size)
assertTrue(premiereRotation(PREMIERE_NUMBER, mapOf("7789551" to film), at(11, 26, 12), london, active = true).isEmpty())
assertEquals(1, premiereRotation(PREMIERE_NUMBER, mapOf("7789551" to film), at(12, 1, 12), london, active = true).size)
assertTrue(premiereRotation(303, mapOf("7789551" to film), at(12, 20, 12), london, active = true).isEmpty())
assertEquals(1, premiereRotation(303, mapOf("7789551" to film), at(12, 26, 12), london, active = true).size)
}
@Test fun placingSetsADebutAndTheWindowMovesItHome() {
@@ -0,0 +1,123 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.File
import java.time.Instant
import java.time.ZoneId
/**
* The release check for Paul's approved premiere dates, run by hand on copies of the box's own files:
* ORBIT_REPLAY_DIR=<copies of the schedule folder, plus advent-films.json and premiere-films.json> ./gradlew :app:testDebugUnitTest --tests '*PremiereReplayTest*'
* Tops up every channel day by day to mid-January, as the box does, with Advent, The Naughty List and the approved premieres
* switched on (booking active, as it would be once approved), and once without the premieres. Writes premiere-checks.txt there:
* each debut as booked, every other showing of the two films on any channel, the editorial competition check, film rest on
* Premiere, committed plans, overlaps, Advent's doors, and what else changed against the run without premieres.
*/
class PremiereReplayTest {
private val dir = System.getenv("ORBIT_REPLAY_DIR")?.let(::File)
private val zone = ZoneId.of("Europe/London")
private fun plan(file: File): ChannelPlan? {
if (!file.exists()) return null
val root = JSONObject(file.readText()); val rows = root.getJSONArray("entries"); val t = root.getJSONObject("tail"); val marks = t.getJSONObject("marks")
val entries = (0 until rows.length()).map { i ->
val o = rows.getJSONObject(i); val d = o.getLong("d"); val n = o.getJSONArray("n")
ScheduleEntry(o.getString("s"), EpisodeMark(n.getInt(0), n.getInt(1)), ScheduledMedia(SampleItem(o.getString("t"), o.optString("k", "Episode"), d / 60_000, "", o.getString("i"), d), o.getString("c"), d),
o.getLong("a"), o.optString("w"), o.optString("p"))
}
return ChannelPlan(entries, ScheduleCursor(if (t.isNull("last")) null else t.getString("last"),
marks.keys().asSequence().associateWith { id -> marks.getJSONArray(id).let { EpisodeMark(it.getInt(0), it.getInt(1)) } },
t.optJSONObject("shown")?.let { s -> s.keys().asSequence().associateWith { s.getLong(it) } }.orEmpty(),
t.optJSONObject("blocks")?.let { b -> b.keys().asSequence().associateWith { b.getString(it) } }.orEmpty()))
}
private fun pool(file: File): List<PoolSeries> {
val rows = JSONObject(file.readText()).getJSONArray("series")
return (0 until rows.length()).map { i ->
val o = rows.getJSONObject(i); val eps = o.getJSONArray("episodes")
PoolSeries(o.getString("id"), o.getString("title"), (0 until eps.length()).map { j ->
val e = eps.getJSONArray(j); PoolEpisode(e.getString(0), e.getString(1), EpisodeMark(e.getInt(2), e.getInt(3)), e.getString(4), e.getLong(5))
}, o.optString("group"))
}
}
private fun films(name: String): Map<String, PoolSeries> {
val listed = JSONArray(File(dir, name).readText())
return (0 until listed.length()).map { listed.getJSONObject(it) }.associate { o ->
o.getString("id") to PoolSeries(o.getString("id"), o.getString("title"), listOf(PoolEpisode(o.getString("id"), "src", EpisodeMark(1, 1), o.getString("title"), o.getLong("ms"))))
}
}
private val end = java.time.LocalDate.of(2027, 1, 15).atStartOfDay(zone).toInstant().toEpochMilli()
private fun run(premieresOn: Boolean): Map<Int, List<ScheduleEntry>> {
val seasonal = films("advent-films.json"); val premieres = films("premiere-films.json")
val held = seasonalHeld(zone)
val channels = SmartChannels.sortedBy { it.number }.filter { File(dir, "schedule-${it.number}.json").exists() && File(dir, "episodes-${it.number}.json").exists() }
val plans = channels.associate { it.number to plan(File(dir, "schedule-${it.number}.json"))!! }.toMutableMap()
val pools = channels.associate { it.number to pool(File(dir, "episodes-${it.number}.json")) }
val all = plans.mapValues { it.value.entries.toMutableList() }.toMutableMap()
var now = System.getenv("ORBIT_REPLAY_NOW")?.toLong() ?: plans.values.minOf { it.entries.first().startMs }
while (now < end) {
for (programming in channels) {
val number = programming.number
val others = clashesWith(plans.filterKeys { it != number }.values.toList())
val fixed = (if (programming.filmRestMs != null) adventSlots(number, seasonal, zone) + naughtySlots(number, seasonal, zone) else emptyList()) +
(if (premieresOn && number == PREMIERE_NUMBER) approvedDebutSlots(premieres, zone, active = true) else emptyList())
val extra = if (premieresOn) premiereRotation(number, premieres, now, zone, active = true).filter { p -> pools[number]!!.none { it.id == p.id } } else emptyList()
val premiereHold = premiereHeld(number, zone)
val next = topUpPlan(plans[number], ScheduleCursor(), pools[number]!! + extra, now, programming.filmRestMs, programming.finishTogether, programming.dayParts, zone, others,
shuffle = programming.shuffleEpisodes, mixed = programming::mixes, weightOf = programming.tierWeights?.let { programming::weightOf },
adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping, fixed = fixed,
held = { f, at -> held(f, at) || (premieresOn && premiereHold(f, at)) }) ?: continue
plans[number] = next
next.entries.filter { e -> all[number]!!.none { it.startMs == e.startMs } }.forEach { all[number]!! += it }
}
now += 6 * 60 * 60_000L
}
return all
}
private fun at(ms: Long) = Instant.ofEpochMilli(ms).atZone(zone).let { "%s %02d:%02d".format(it.toLocalDate(), it.hour, it.minute) }
@Test fun releaseChecks() {
assumeTrue(dir != null && File(dir, "premiere-films.json").exists() && File(dir, "advent-films.json").exists())
val with = run(true); val without = run(false)
val out = StringBuilder()
for (d in ApprovedDebuts) {
val debut = d.startMs(zone)
val booked = with[PREMIERE_NUMBER].orEmpty().firstOrNull { it.why.startsWith(PREMIERE_WHY) && it.seriesId == d.film.itemId }
out.appendLine("${d.film.title}: approved ${at(debut)} on Orbit Premiere -> " +
if (booked == null) "NOT BOOKED" else "booked ${at(booked.startMs)} to ${at(booked.endMs)} (${(booked.startMs - debut) / 60_000} min late)")
out.appendLine(" competing headline events that evening: ${competingEvent(debut, debut + (booked?.media?.durationMs ?: 3 * 3_600_000L), zone, except = d.film.itemId) ?: "none"}")
val showings = with.flatMap { (n, es) -> es.filter { it.seriesId == d.film.itemId || seriesKey(it.media.item.title) == seriesKey(d.film.title) }.map { n to it } }.sortedBy { it.second.startMs }
showings.forEach { (n, e) -> out.appendLine(" $n ${at(e.startMs)} ${e.why.take(60)}") }
val early = showings.filter { (n, e) -> !e.why.startsWith(PREMIERE_WHY) && (e.startMs < debut || (n != PREMIERE_NUMBER && e.startMs < d.windowEndMs(zone))) }
out.appendLine(" showings before their time (on Premiere before the debut, elsewhere before the window ends): ${early.size}")
val onPremiere = showings.filter { it.first == PREMIERE_NUMBER }.map { it.second.startMs }
out.appendLine(" shortest gap between Premiere showings: ${onPremiere.zipWithNext { a, b -> (b - a) / 3_600_000 }.minOrNull()?.let { "$it h" } ?: "one showing only"}")
out.appendLine(" advent hold applies: ${seasonalHeld(zone)(PoolSeries(d.film.itemId, d.film.title, emptyList()), debut)}")
}
val overlaps = with.mapValues { (_, e) -> e.sortedBy { it.startMs }.zipWithNext().count { (a, b) -> a.endMs > b.startMs } }.filterValues { it > 0 }
val committed = SmartChannels.mapNotNull { p -> plan(File(dir, "schedule-${p.number}.json"))?.let { p.number to it } }
.count { (number, plan) -> with[number]?.take(plan.entries.size) != plan.entries }
val doors = AdventDoors.count { d -> with[d.channel].orEmpty().any { it.why.startsWith("$ADVENT_WHY${d.door};") } }
val doorsWithout = AdventDoors.count { d -> without[d.channel].orEmpty().any { it.why.startsWith("$ADVENT_WHY${d.door};") } }
val naughty = with[AFTER_DARK_NUMBER].orEmpty().count { it.why.startsWith(NAUGHTY_WHY) }
// Duplicate titles: the same title airing on two channels at once anywhere on the network.
val airings = with.flatMap { (n, es) -> es.map { n to it } }.groupBy { seriesKey(it.second.media.item.title) }
val dupes = airings.values.sumOf { list -> list.sortedBy { it.second.startMs }.zipWithNext().count { (a, b) -> a.first != b.first && a.second.endMs > b.second.startMs } }
val changed = with.keys.filter { with[it] != without[it] }.sorted()
out.appendLine()
out.appendLine("overlaps: $overlaps")
out.appendLine("committed plans changed: $committed")
out.appendLine("Advent doors booked: $doors of ${AdventDoors.size} (without the premieres: $doorsWithout); Naughty List nights booked: $naughty")
out.appendLine("same title on two channels at once: $dupes")
out.appendLine("channels whose bookings differ from the run without premieres: $changed")
File(dir, "premiere-checks.txt").writeText(out.toString())
println(out)
}
}
+30
View File
@@ -0,0 +1,30 @@
Avatar: Fire and Ash: approved 2026-11-27 20:00 on Orbit Premiere -> booked 2026-11-27 20:00 to 2026-11-27 23:18 (0 min late)
competing headline events that evening: none
301 2026-11-27 20:00 orbit premiere: debut; approved by Paul on 5 November 2026;
301 2026-12-05 21:47 whole pool; picked from 20 rested films, last shown 193h ago
301 2026-12-14 22:28 whole pool; picked from 22 rested films, last shown 216h ago
301 2026-12-24 00:08 whole pool; picked from 22 rested films, last shown 217h ago
303 2027-01-01 11:35 whole pool; picked from 18 rested films, not shown before
303 2027-01-08 13:13 whole pool; picked from 19 rested films, last shown 169h ago
301 2027-01-09 07:13 whole pool; picked from 19 rested films, last shown 391h ago
303 2027-01-17 09:31 whole pool; picked from 19 rested films, last shown 212h ago
301 2027-01-17 17:13 whole pool; picked from 20 rested films, last shown 202h ago
showings before their time (on Premiere before the debut, elsewhere before the window ends): 0
shortest gap between Premiere showings: 193 h
advent hold applies: false
Spider-Man: Brand New Day: approved 2026-12-11 20:00 on Orbit Premiere -> booked 2026-12-11 20:03 to 2026-12-11 22:28 (3 min late)
competing headline events that evening: none
301 2026-12-11 20:03 orbit premiere: debut; approved by Paul on 5 November 2026;
301 2026-12-21 12:18 whole pool; picked from 20 rested films, last shown 232h ago
301 2027-01-02 17:53 whole pool; picked from 21 rested films, last shown 293h ago
301 2027-01-14 04:36 whole pool; picked from 20 rested films, last shown 274h ago
302 2027-01-16 17:36 whole pool; picked from 19 rested films, not shown before
showings before their time (on Premiere before the debut, elsewhere before the window ends): 0
shortest gap between Premiere showings: 232 h
advent hold applies: false
overlaps: {}
committed plans changed: 0
Advent doors booked: 25 of 25 (without the premieres: 25); Naughty List nights booked: 6
same title on two channels at once: 0
channels whose bookings differ from the run without premieres: [301, 302, 303, 305, 306, 602, 603]