diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0230aca..480a53a 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 = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 0c9e779..ea19f92 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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 { + 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) diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt index f5e5983..cc44a69 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt @@ -44,14 +44,15 @@ private fun savePremiereRuns(file: File, runs: List) { /** * 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, 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 = 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, zone: ZoneId = ZoneId.systemDefault(), active: Boolean = PREMIERE_BOOKING_ACTIVE): List = + 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, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(), active: Boolean = PREMIERE_BOOKING_ACTIVE): List = + 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 +} 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 e696e2c..0ccdb20 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.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) 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 2c50423..7c43ca6 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.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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt b/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt index a981a3a..300d918 100644 --- a/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt @@ -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() { diff --git a/android/app/src/test/java/tv/orbit/classic/PremiereReplayTest.kt b/android/app/src/test/java/tv/orbit/classic/PremiereReplayTest.kt new file mode 100644 index 0000000..222c6e7 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/PremiereReplayTest.kt @@ -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= ./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 { + 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 { + 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> { + 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) + } +} diff --git a/docs/replays/premiere-checks-0.111.0.txt b/docs/replays/premiere-checks-0.111.0.txt new file mode 100644 index 0000000..5f16cb8 --- /dev/null +++ b/docs/replays/premiere-checks-0.111.0.txt @@ -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]