0.111.0 candidate: Paul's approved premiere dates as gated editorial slots, with editorial competition
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user