0.99.9: the confirmed After Dark, Horror, Evidence, Odyssey and Terra events are appointments; block eligibility worked out once per pool
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m34s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m29s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-04 11:10:18 +00:00
co-authored by Claude Opus 5.5
parent 4f6ffeae2e
commit 36c0907ec3
10 changed files with 374 additions and 20 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 138
versionName = "0.99.8-after-dark-daytime"
versionCode = 139
versionName = "0.99.9-event-appointments"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -213,7 +213,7 @@ private val TerraWider = listOf("Hostile Planet", "Wild China", "Wild Russia", "
private val TerraEarlierNames = listOf("Our Planet II", "Dynasties", "Dynasties II", "Africa", "South Pacific", "Great Barrier Reef")
internal val TerraShows get() = (TerraLandmarks + TerraWider + TerraEarlierNames).distinct()
internal val TerraDayParts get() = listOf(
DayPart(20, 22, TerraLandmarks, days = setOf(DayOfWeek.SUNDAY), key = "terra.sunday"),
DayPart(20, 22, TerraLandmarks, days = setOf(DayOfWeek.SUNDAY), onTime = true, key = "terra.sunday"),
DayPart(6, 18, TerraShows), DayPart(18, 23, TerraLandmarks), DayPart(23, 6, TerraShows))
/** Wild: people meeting nature and surviving it. The presenters and participants are the story. */
@@ -250,7 +250,7 @@ private val OdysseyJourneys = listOf("Civilisation", "Civilizations", "Civilisat
private val OdysseyAwaitingHome = listOf("Band of Brothers")
internal val OdysseyShows get() = (OdysseySpace + OdysseyEngineering + OdysseyDisasters + OdysseyJourneys + OdysseyAwaitingHome).distinct()
internal val OdysseyDayParts get() = listOf(
DayPart(14, 18, OdysseyJourneys, days = Weekend),
DayPart(14, 18, OdysseyJourneys, days = Weekend, onTime = true),
// Reviewed suggestions (the "all" group) join the daytime.
DayPart(6, 18, OdysseyEngineering + OdysseySpace - setOf("Horizon", "Through the Wormhole", "The Sky at Night"), group = "all"), DayPart(18, 23, OdysseySpace), DayPart(23, 6, OdysseyDisasters))
internal val OdysseyMixed get() = setOf("The Universe", "How the Universe Works", "Horizon", "Impossible Engineering", "Engineering the Impossible", "Extreme Engineering", "Megastructures",
@@ -276,7 +276,7 @@ private val EvidenceDark = listOf("Luther", "Line of Duty", "Happy Valley", "Bro
private val EvidenceSundayMystery = listOf("Inspector Morse", "Lewis", "A Touch of Frost", "Midsomer Murders", "Vera", "Columbo", "Sherlock")
internal val EvidenceShows get() = (EvidenceProcedurals + EvidenceDetectives + EvidenceDark).distinct()
internal val EvidenceDayParts get() = listOf(
DayPart(20, 22, EvidenceSundayMystery, days = setOf(DayOfWeek.SUNDAY)),
DayPart(20, 22, EvidenceSundayMystery, days = setOf(DayOfWeek.SUNDAY), onTime = true),
DayPart(6, 18, EvidenceProcedurals + listOf("Columbo"), group = "all"), DayPart(18, 21, EvidenceDetectives), DayPart(21, 1, EvidenceDark), DayPart(1, 6, EvidenceProcedurals))
/** Serial dramas tell a story, so they stay in order. */
internal val EvidenceMixed get() = EvidenceShows.toSet() - setOf("Line of Duty", "Broadchurch", "Happy Valley", "The Wire", "Dexter", "Luther", "Sherlock")
@@ -305,14 +305,16 @@ internal val CultClassics = listOf("The Rocky Horror Picture Show", "Repo Man",
* and the last film it starts runs to its natural end. When every film in it is resting, the one that has waited longest
* plays, as on any film channel; when none of its films are in the library at all, the channel's usual block carries on.
*/
internal val HorrorDayParts get() = listOf(DayPart(20, 2, SlasherSaturday, days = setOf(DayOfWeek.SATURDAY), key = "horror.slasher"))
internal val HorrorDayParts get() = listOf(DayPart(20, 2, SlasherSaturday, days = setOf(DayOfWeek.SATURDAY), onTime = true, key = "horror.slasher"))
/**
* After Dark's Saturday staple: one whole hosted episode, in order, running its full length however long. Its window opens at 20:00
* because the film before it is usually still running: starting from the first slot after 20:00 lands it around 21:00.
* After Dark's Saturday staple: one whole hosted episode, in order, running its full length however long. An appointment at 21:00
* since 4 November 2026: the film before it is chosen to finish in time (its window opened at 20:00 before, to land around 21:00).
*/
internal const val LAST_DRIVE_IN = "The Last Drive-in with Joe Bob Briggs"
internal val AfterDarkEvents get() = listOf(DayPart(21, 1, BMovieThrowbacks, days = setOf(DayOfWeek.FRIDAY), key = "afterdark.bmovie"), DayPart(20, 1, listOf(LAST_DRIVE_IN), days = setOf(DayOfWeek.SATURDAY), key = "afterdark.drivein"),
DayPart(21, 1, CultClassics, days = setOf(DayOfWeek.SUNDAY), key = "afterdark.cult"))
// All three are appointments (4 November 2026): the films before them are chosen to finish in time, so each night starts near 21:00.
internal val AfterDarkEvents get() = listOf(DayPart(21, 1, BMovieThrowbacks, days = setOf(DayOfWeek.FRIDAY), onTime = true, key = "afterdark.bmovie"),
DayPart(21, 1, listOf(LAST_DRIVE_IN), days = setOf(DayOfWeek.SATURDAY), onTime = true, key = "afterdark.drivein"),
DayPart(21, 1, CultClassics, days = setOf(DayOfWeek.SUNDAY), onTime = true, key = "afterdark.cult"))
/** Orbit Junior: British children's TV for about 5 to 11, with the classics at breakfast. Tots is the preferred home of modern preschool. */
private val JuniorBreakfast = listOf("Thomas the Tank Engine & Friends", "Thomas & Friends", "Postman Pat", "Brum", "Rosie and Jim", "Pingu", "Pingu in the City", "Button Moon",
@@ -277,16 +277,34 @@ internal fun dayPartChoice(pool: List<PoolSeries>, parts: List<DayPart>, startMs
dayPartFor(pool, parts, startMs, zone).let { it.first to it.second }
/** [dayPartChoice], with the part it came from, or null for the whole pool. */
/**
* Which of a pool's shows each block may use, worked out once per pool and block rather than for every programme planned:
* matching titles means normalising every name, which made planning take seconds on the box. Keyed by the very pool and
* block objects, so a different pool or a changed block is always worked out afresh.
*/
private object PartPools {
private val recent = ArrayDeque<Pair<List<PoolSeries>, java.util.IdentityHashMap<DayPart, List<PoolSeries>>>>()
@Synchronized fun of(pool: List<PoolSeries>, part: DayPart, allowed: () -> List<PoolSeries>): List<PoolSeries> {
val entry = recent.firstOrNull { it.first === pool } ?: (pool to java.util.IdentityHashMap<DayPart, List<PoolSeries>>()).also {
recent.addFirst(it)
if (recent.size > 8) recent.removeLast()
}
return entry.second.getOrPut(part, allowed)
}
}
internal fun dayPartFor(pool: List<PoolSeries>, parts: List<DayPart>, startMs: Long, zone: java.time.ZoneId): Triple<List<PoolSeries>, String, DayPart?> {
val local = java.time.Instant.ofEpochMilli(startMs).atZone(zone)
// An event with nothing to show gives way to the ordinary block for that time.
for (part in parts.filter { it.covers(local.hour, local.dayOfWeek) }) {
val name = (if (part.days.isNotEmpty()) "event " else "block ") + part.group.ifEmpty { "${part.fromHour}-${part.toHour}" }
if (part.titles.isEmpty() && part.group.isEmpty()) return Triple(pool, name, part)
val titles = part.titles.map(::seriesKey).toSet()
// A film is named with its year, as "Halloween (1978)"; the pool already holds that exact film under its plain title.
val films = part.titles.map { seriesKey(FilmIdentity.of(it).title) }.toSet()
val allowed = pool.filter { seriesKey(it.title) in titles || (it.isFilm && seriesKey(it.title) in films) || (part.group.isNotEmpty() && it.group == part.group) }
val allowed = PartPools.of(pool, part) {
val titles = part.titles.map(::seriesKey).toSet()
// A film is named with its year, as "Halloween (1978)"; the pool already holds that exact film under its plain title.
val films = part.titles.map { seriesKey(FilmIdentity.of(it).title) }.toSet()
pool.filter { seriesKey(it.title) in titles || (it.isFilm && seriesKey(it.title) in films) || (part.group.isNotEmpty() && it.group == part.group) }
}
if (allowed.isNotEmpty()) return Triple(allowed, name, part)
}
// A film saved for an event doesn't appear in everyday programming.
@@ -382,7 +400,19 @@ internal fun extendFilmSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor,
val films = available.filter { it.isFilm }.ifEmpty { pool.filter { it.isFilm } }.ifEmpty { available }
// Events keep the channel's rest; everyday blocks, when adaptive, rest by how long their own films last.
val rest = if (adaptiveRest && part?.days.isNullOrEmpty()) blockRest.getOrPut(part) { sharedAdaptiveRestMs(pool, dayParts, part, restMs) } else restMs
val (film, why) = chooseFilm(films, shown, start, rest, avoid)
var (film, why) = chooseFilm(films, shown, start, rest, avoid)
// Before an appointment, a rested film that leaves it on time, picked in the same seeded order as any other.
val appointment = if (part?.onTime == true) null else nextAppointment(start, zone, (ON_TIME_FILM_RUN_IN_MS / (60 * 60_000L)).toInt()) { at -> dayPartFor(pool, dayParts, at, zone) }
if (appointment != null) {
val clear = films.filter { f -> !avoid(f, start, start + f.episodes.first().durationMs) }
// Rested films in the same seeded order as any other pick; when none is rested, the longest waiting first, as the fallback would.
val rested = clear.filter { f -> shown[f.id]?.let { start - it >= rest } != false }.shuffled(java.util.Random(start))
.ifEmpty { clear.sortedBy { shown[it.id] ?: Long.MIN_VALUE } }
fitBeforeAppointment(appointment, start, film, rested, ON_TIME_FILM_RUN_IN_MS, followers = 2) { start + it.episodes.first().durationMs }?.let {
film = it
why = "$why; chosen to finish before the appointment at ${java.time.Instant.ofEpochMilli(appointment).atZone(zone).toLocalTime()}"
}
}
val copy = film.episodes.first()
val item = SampleItem(film.title, "Movie", copy.durationMs / 60_000, "", copy.id, copy.durationMs)
// Only a programme actually drawn from the event's own titles carries its presentation.
@@ -455,6 +485,55 @@ private const val ON_TIME_GRACE_MS = 20 * 60_000L
private const val ON_TIME_LAST_CALL_MS = 60 * 60_000L
/** How long before an appointment the programmes leading into it may be adjusted. */
private const val ON_TIME_RUN_IN_MS = 90 * 60_000L
/** The same for films, which are long: the last two or three before an appointment have to be chosen together for it to start in time. */
private const val ON_TIME_FILM_RUN_IN_MS = 6 * 60 * 60_000L
/**
* The next on-time appointment within [lookaheadHours] after [at]: the first hour boundary whose part is an
* appointment with something to show. Shared by the weighted and film planners; equal turns has its own, unchanged.
*/
internal fun nextAppointment(at: Long, zone: java.time.ZoneId, lookaheadHours: Int = ON_TIME_LOOKAHEAD_HOURS, partAt: (Long) -> Triple<List<PoolSeries>, String, DayPart?>): Long? {
val hour = 60 * 60_000L
val nextHour = java.time.Instant.ofEpochMilli(at).atZone(zone).truncatedTo(java.time.temporal.ChronoUnit.HOURS).plusHours(1).toInstant().toEpochMilli()
return (0 until lookaheadHours).map { nextHour + it * hour }.firstOrNull { boundary ->
val (shows, _, part) = partAt(boundary)
part?.onTime == true && shows.isNotEmpty()
}
}
/**
* Before an appointment, a programme that leaves it on time, as equal turns has done since PPV Sunday: one ending at the
* appointment or in its first [ON_TIME_GRACE_MS], or one leaving a gap another candidate closes. Only the last
* [runInMs] before the appointment is adjusted ([ON_TIME_RUN_IN_MS] for series, [ON_TIME_FILM_RUN_IN_MS] for films), nothing is cut and nothing is left empty. [candidates] are in the
* planner's own order of preference and already satisfy its rules. Null keeps [chosen].
*/
internal fun <T> fitBeforeAppointment(appointment: Long, start: Long, chosen: T, candidates: List<T>, runInMs: Long = ON_TIME_RUN_IN_MS,
/** How many more programmes may follow before the appointment: 1 for series, 2 for films, which are long. */
followers: Int = 1, ends: (T) -> Long): T? {
val latest = appointment + ON_TIME_GRACE_MS
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 > 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 }
return false
}
if (opensRun(chosen)) return null
val fits = candidates.filter { ends(it) <= latest }
// Best is one ending at the appointment, then one a single programme can follow in time, then any that opens a run.
return fits.firstOrNull { ends(it) >= appointment }
?: fits.firstOrNull { x -> ends(x) < appointment && candidates.any { it != x && ends(x) + length(it) in appointment..latest } }
?: fits.firstOrNull { opensRun(it) }
?: fits.firstOrNull().takeIf { ends(chosen) > latest }
// Failing that, at least something that ends while the appointment's window is still open, so it isn't missed.
?: candidates.firstOrNull { ends(it) < appointment + ON_TIME_LAST_CALL_MS }.takeIf { ends(chosen) >= appointment + ON_TIME_LAST_CALL_MS }
}
/** A series on a film channel airs at most once in this long, so a long event window still holds one episode. */
private const val SERIES_NIGHT_MS = 12 * 60 * 60_000L
@@ -530,14 +609,20 @@ internal fun extendWeighted(pool: List<PoolSeries>, cursor: ScheduleCursor, from
// A show held back by the 24-hour or restart rule is brought up level, so it comes back to its share without a burst.
val low = eligible.minOf { tally[it.id]!! }
clear.filter { it.id != last && it !in eligible }.forEach { tally[it.id] = maxOf(tally[it.id]!!, low) }
val series = eligible.minWith(compareBy<PoolSeries> { tally[it.id]!! }.thenBy { pool.indexOf(it) })
val byTally = compareBy<PoolSeries> { tally[it.id]!! }.thenBy { pool.indexOf(it) }
val preferred = eligible.minWith(byTally)
// 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 = appointment?.let { at -> fun ends(show: PoolSeries) = start + following(show).durationMs
fitBeforeAppointment(at, start, preferred, eligible.sortedWith(byTally), ends = ::ends) ?: fitBeforeAppointment(at, start, preferred, clear.sortedWith(byTally), ends = ::ends) }
val series = fitted ?: preferred
val episode = following(series)
val why = "$block; weighted: lowest tally for its tier (${"%.0f".format(tally[series.id])})" + (if (eligible.size < clear.size) ", others resting or just played" else "") +
when {
unclashed.isEmpty() -> "; fallback: every show here airs elsewhere then"
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 ""
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)
@@ -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.8\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.99.9\"")
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.99.8")
next.setRequestProperty("User-Agent", "OrbitTV/0.99.9")
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()) {
@@ -0,0 +1,47 @@
package tv.orbit.classic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.DayOfWeek
/** The confirmed events become appointments (4 November 2026): they start near their hour, with nothing cut and no gaps. */
class EventAppointmentsTest {
private val zone = java.time.ZoneId.of("Europe/London")
private val day = 24 * 60 * 60_000L
private val monday = java.time.LocalDate.of(2026, 11, 16).atStartOfDay(zone).toInstant().toEpochMilli()
private fun channel(n: Int) = SmartChannels.first { it.number == n }
@Test fun theFiveChannelsEventsAreAppointments() {
val after = channel(AFTER_DARK_NUMBER).dayParts.filter { it.key.startsWith("afterdark.") }
assertEquals(listOf(21, 21, 21), after.map { it.fromHour }); assertTrue(after.all { it.onTime })
assertTrue(channel(304).dayParts.single { it.key == "horror.slasher" }.let { it.onTime && it.fromHour == 20 })
assertTrue(channel(107).dayParts.single { it.days == setOf(DayOfWeek.SUNDAY) }.let { it.onTime && it.fromHour == 20 })
assertTrue(channel(202).dayParts.single { it.days.isNotEmpty() }.let { it.onTime && it.fromHour == 14 })
assertTrue(channel(201).dayParts.single { it.key == "terra.sunday" }.let { it.onTime && it.fromHour == 20 })
}
@Test fun aFilmEventStartsNearItsHourWithNothingCut() {
val films = (1..120).map { PoolSeries("f$it", "Film $it", listOf(PoolEpisode("f$it", "s", EpisodeMark(1, 1), "Film $it", (82L + it * 7 % 45) * 60_000L))) }
val parts = listOf(DayPart(21, 1, films.take(20).map { it.title }, days = setOf(DayOfWeek.FRIDAY), onTime = true, key = "test.event"), DayPart(0, 24))
val plan = extendFilmSchedule(films, ScheduleCursor(), monday, monday + 21 * day, 7 * day, parts, zone)
plan.entries.zipWithNext().forEach { (a, b) -> assertEquals(a.endMs, b.startMs) }
val starts = plan.entries.filter { it.presentation == "test.event" && java.time.Instant.ofEpochMilli(it.startMs).atZone(zone).hour >= 21 }.groupBy { java.time.Instant.ofEpochMilli(it.startMs).atZone(zone).toLocalDate() }
.map { (date, es) -> (es.minOf { it.startMs } - date.atTime(21, 0).atZone(zone).toInstant().toEpochMilli()) / 60_000 }
assertEquals(3, starts.size)
assertTrue("late by $starts", starts.all { it in 0..20 })
assertTrue(plan.entries.all { it.media.durationMs == films.first { f -> f.id == it.seriesId }.episodes.first().durationMs })
}
@Test fun aWeightedEventStartsNearItsHour() {
fun show(t: String, minutes: Int) = PoolSeries(t, t, (1..200).map { PoolEpisode("$t-$it", "s", EpisodeMark(1, it), "E$it", minutes * 60_000L) })
val pool = listOf(show("A", 47), show("B", 58), show("C", 89), show("Event", 95))
val parts = listOf(DayPart(20, 22, listOf("Event"), days = setOf(DayOfWeek.SUNDAY), onTime = true), DayPart(0, 24, listOf("A", "B", "C")))
val plan = extendWeighted(pool, ScheduleCursor(), monday, monday + 21 * day, { TIER_SUPPORTING }, parts, zone, { _, _, _ -> false }, false, { false })
plan.entries.zipWithNext().forEach { (a, b) -> assertEquals(a.endMs, b.startMs) }
val starts = plan.entries.filter { it.seriesId == "Event" }.groupBy { java.time.Instant.ofEpochMilli(it.startMs).atZone(zone).toLocalDate() }
.map { (date, es) -> (es.minOf { it.startMs } - date.atTime(20, 0).atZone(zone).toInstant().toEpochMilli()) / 60_000 }
assertEquals(3, starts.size)
assertTrue("late by $starts", starts.all { it in 0..20 })
}
}
@@ -25,7 +25,8 @@ class LastDriveInTest {
episodes.forEach { e ->
val start = local(e.startMs)
assertEquals(java.time.DayOfWeek.SATURDAY, start.dayOfWeek)
assertTrue("starts in the first slot after 20:00, so around 21:00: $start", start.hour in 20..21)
// An appointment at 21:00 since 4 November 2026: the film before it is chosen to finish in time.
assertTrue("starts at 21:00 or within its grace: $start", start.hour == 21 && start.minute <= 20)
assertEquals("played whole", joeBob.episodes[e.mark.episode - 1].durationMs, e.media.durationMs)
}
}
+57
View File
@@ -0,0 +1,57 @@
# Orbit TV 0.99.9: confirmed events start on time; faster planning
Paul approved the fix on 4 November 2026 at 10:33, as an isolated correction before the cutover. The change is to scheduling only. This release includes 0.99.8.
## What's in it
**Appointments.** The confirmed events become appointments, using the same mechanism as Ring X's weeknights and PPV Sunday:
| Event | Appointment |
|---|---|
| After Dark: B-Movie Throwbacks | Friday 21:00 |
| After Dark: The Last Drive-In with Joe Bob Briggs | Saturday 21:00 (its window used to open at 20:00, so it would land around 21:00) |
| After Dark: Cult Classics | Sunday 21:00 |
| Horror: Slasher Saturday | 20:00 |
| Evidence: Sunday Mystery | 20:00 |
| Odyssey: Journeys | Saturday and Sunday 14:00 |
| Terra: Sunday on Terra | 20:00 |
**How the planners fit them** (`fitBeforeAppointment` and `nextAppointment`, used by the weighted and film planners; equal turns already had this and is unchanged):
- In the run-in before an appointment, the planner picks, from what its rules already allow, a programme that ends at the appointment or in its first 20 minutes. Failing that, it picks one that a short run of others can follow so the appointment starts in time.
- **Weighted channels:** the run-in is 90 minutes, as before, and they choose in tally order.
- **Film channels:** the run-in is 6 hours and up to two more films may follow, because films are long. They choose among rested films, in the same seeded order as any pick. When none is rested, they choose among the longest waiting, as the existing fallback does.
- **Never weakened:** whole programmes always, no gaps, every rest, repeat, clash and episode-order rule kept. When nothing fits, the event starts late rather than break a rule.
**Faster planning, with no rule changed.** Each block's eligible shows are now worked out once per pool and block (`PartPools`), instead of normalising every title again for every programme planned. On the box this was the remaining cost of 4 to 18 seconds per top-up.
## Checks before install
- **422 unit tests pass**, including the hand-run replays. Lint has no errors.
- **New EventAppointmentsTest:**
- The seven events are appointments at the hours above.
- A film event and a weighted event both start within 20 minutes of their hour over three weeks, with no gaps and every film whole.
- **LastDriveInTest** now expects Joe Bob at 21:00, within its grace.
- **Four-week network replay** on box copies taken at 10:36. All channels book together, and 0.99.8 and 0.99.9 run on the same data (docs/replays/final-network-audit-0.99.9.md). The first programme of each event starts this many minutes after its hour, median (range):
| Event | 0.99.8 | 0.99.9 |
|---|---|---|
| After Dark B-Movie, Friday 21:00 | 62 (7 to 78) | **16 (1 to 16)** |
| After Dark Joe Bob, Saturday | 21:14 (20:12 to 21:35), window from 20:00 | **21:17 (21:03 to 21:52)**, appointment 21:00 |
| After Dark Cult, Sunday 21:00 | 62 (34 to 92) | **13 (4 to 102)** |
| Horror Slasher, Saturday 20:00 | 73 (46 to 122) | **6 (3 to 16)** |
| Evidence Sunday Mystery, 20:00 | 42 (8 to 67) | **10 (5 to 17)** |
| Odyssey Journeys, weekends 14:00 | 35 (15 to 57) | **15 (0 to 17)** |
| Terra Sunday, 20:00 | 33 (6 to 37) | **19 (6 to 37)** |
- **The two remaining late nights on After Dark** (Cult 102 and Joe Bob 52 minutes late) are Sundays and Saturdays when only a handful of daytime films are rested. No rested combination ends in time, so the event starts late rather than repeat a film early.
- **Joe Bob:** its median start is about the same as before, now aimed at 21:00 instead of the first slot after 20:00.
- **The full audit still passes every hard rule** on every channel: no gaps, no clashes, order and decks kept, nothing within 24 hours in weighted blocks, and committed bookings kept.
- **Fingerprints** (docs/replays/replay-fingerprints-0.99.9.txt):
- Every committed fingerprint is identical.
- Only the five channels with new appointments differ: 107, 201, 202, 304 and 305.
- The faster lookup changes nothing anywhere else.
- **Presentation replay:** committedKeptExactly=true.
## Rollback
Install 0.99.8. No saved data changes.
@@ -0,0 +1,81 @@
# Final network audit
Read-only, from copies of the box's files. Every channel booked four weeks beyond its committed plan, all channels together, each avoiding the others as the box does, with the app exactly as configured.
| Ch | Channel | Scheduler | Pool | List unmatched | Core / supp / surprise airtime | Never aired | Longest wait | Same file <24 h / <7 d | Twice running | Back / off deck | Gaps / outside block | Clashes | Keyed blocks booked |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 101 | Orbit 1 | weighted | 46 | 0 | 56% / 42% / 1% | 0 | 21.0 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 102 | Orbit 2 | weighted | 53 | 0 | 34% / 63% / 3% | 0 | 7.0 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | spooky 19, retro 226 |
| 103 | Orbit Three | weighted | 44 | 0 | 51% / 37% / 11% | 0 | 4.1 d | 0 / 219 | 0 | 0 / 0 | 0 / 0 | 0 | scottish 48 |
| 104 | Orbit Gold | weighted | 44 | 0 | 38% / 61% / 1% | 0 | 7.0 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | sunday 26 |
| 105 | Riot | equal turns | 49 | 0 | n/a | 2 | 28.0 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | stunts 20, laughs 213, afterhours 385 |
| 106 | Paradox | weighted | 18 | 0 | 47% / 50% / 3% | 0 | 1.9 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 107 | Evidence | weighted | 30 | 1 | 48% / 52% / 0% | 0 | 6.1 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 201 | Terra | equal turns | 39 | 4 | n/a | 0 | 26.9 d | 0 / 39 | 0 | 0 / 0 | 0 / 0 | 0 | sunday 9 |
| 202 | Odyssey | weighted | 43 | 11 | 69% / 31% / 0% | 1 | 28.0 d | 0 / 235 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 203 | Orbit Wild | weighted | 25 | 3 | 50% / 49% / 0% | 0 | 13.8 d | 0 / 60 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 204 | Dark Files | weighted + hybrid | 50 | 5 | 26% / 74% / 0% | 0 | 11.3 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 301 | Orbit Premiere | films, rest adaptive | 98 | 0 | n/a | 0 | n/a | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 302 | Orbit Action | films, rest 7 d | 104 | 4 | n/a | 0 | n/a | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 303 | Orbit Sci-Fi | films, rest 7 d | 99 | 1 | n/a | 0 | n/a | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 304 | Orbit Horror | films, rest 7 d | 118 | 2 | n/a | 0 | n/a | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | slasher 16 |
| 305 | After Dark | films, rest adaptive | 132 | 0 | n/a | 0 | n/a | 0 / 207 | 0 | 0 / 0 | 0 / 0 | 0 | bmovie 12, drivein 4, cult 8 |
| 306 | Orbit VHS | films, rest 7 d | 96 | 0 | n/a | 0 | n/a | 0 / 293 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 307 | Orbit Romance | films, rest 7 d | 98 | 0 | n/a | 0 | n/a | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 308 | Detour | films, rest 7 d | 88 | 3 | n/a | 0 | n/a | 0 / 318 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 309 | BritX | films, rest 7 d | 90 | 2 | n/a | 0 | n/a | 0 / 302 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 401 | Ring X | equal turns | 89 | 25 | n/a | 1 | 28.1 d | 0 / 1 | 0 | 0 / 0 | 0 / 0 | 0 | monday 24, tuesday 16, wednesday 15, thursday 16, friday 29, saturday 22, ppv 4 |
| 601 | Vortex | weighted + hybrid | 117 | 8 | 50% / 50% / 0% | 1 | 28.0 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | movie 16 |
| 602 | Heroes | weighted | 19 | 1 | 60% / 40% / 0% | 0 | 1.2 d | 0 / 91 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 603 | Orbit X | weighted | 21 | 3 | 64% / 36% / 0% | 0 | 2.0 d | 0 / 107 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 604 | Rewind | weighted + hybrid | 23 | 0 | 77% / 23% / 0% | 0 | 2.9 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 605 | ToonZ | weighted | 17 | 7 | 59% / 41% / 0% | 0 | 1.6 d | 0 / 963 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 611 | Orbit Junior | equal turns | 50 | 6 | n/a | 0 | 26.1 d | 0 / 0 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 612 | Wonder | films, rest 7 d | 94 | 2 | n/a | 0 | n/a | 0 / 365 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 613 | PixelZ | films, rest 7 d | 100 | 0 | n/a | 0 | n/a | 0 / 312 | 0 | 0 / 0 | 0 / 0 | 0 | none |
| 614 | Tots | weighted (part) | 36 | 0 | 0% / 100% / 0% | 0 | 9.9 d | 0 / 628 | 0 | 0 / 0 | 0 / 0 | 0 | none |
## Notes
- Orbit 1 event 18-20: 4 occurrences, first programme starts 5 to 21 min after 18:00, median 11
- Orbit 1 event 6-18: 8 occurrences, first programme starts 0 to 22 min after 6:00, median 14
- Orbit 2 orbit2.spooky: 4 occurrences, first programme starts 9 to 21 min after 17:00, median 18
- Orbit Three three.scottish: 8 occurrences, first programme starts 6 to 25 min after 21:00, median 22
- Orbit Gold gold.sunday: 4 occurrences, first programme starts 3 to 26 min after 18:00, median 24
- Orbit Gold event 17-19: 4 occurrences, first programme starts 2 to 21 min after 17:00, median 15
- Riot: never aired in four weeks: The Office, How I Met Your Mother
- Riot riot.stunts: 4 occurrences, first programme starts 0 to 26 min after 22:00, median 19
- Evidence event 20-22 (appointment): 4 occurrences, first programme starts 5 to 17 min after 20:00, median 10
- Terra terra.sunday (appointment): 4 occurrences, first programme starts 6 to 37 min after 20:00, median 19
- Odyssey: never aired in four weeks: Band of Brothers
- Odyssey event 14-18 (appointment): 8 occurrences, first programme starts 0 to 17 min after 14:00, median 15
- Orbit Wild event 22-6: 4 occurrences, first programme starts 16 to 31 min after 22:00, median 27
- Dark Files event 14-18: 8 occurrences, first programme starts 1 to 48 min after 14:00, median 28
- Orbit Premiere: 98 different films of 98; repeat gap median 8.7 d, shortest 7.7 d; fixed-order fallbacks 0
- Orbit Action: 104 different films of 104; repeat gap median 8.0 d, shortest 7.0 d; fixed-order fallbacks 0
- Orbit Sci-Fi: 99 different films of 99; repeat gap median 7.9 d, shortest 7.0 d; fixed-order fallbacks 0
- Orbit Horror: 118 different films of 118; repeat gap median 8.0 d, shortest 7.0 d; fixed-order fallbacks 0
- Orbit Horror horror.slasher (appointment): 4 occurrences, first programme starts 3 to 16 min after 20:00, median 6
- After Dark: 130 different films of 131; repeat gap median 5.7 d, shortest 5.0 d; fixed-order fallbacks 0
- After Dark afterdark.bmovie (appointment): 4 occurrences, first programme starts 1 to 16 min after 21:00, median 16
- After Dark afterdark.drivein (appointment): 4 occurrences, first programme starts 3 to 52 min after 21:00, median 17
- After Dark afterdark.cult (appointment): 4 occurrences, first programme starts 4 to 102 min after 21:00, median 13
- Orbit VHS: 96 different films of 96; repeat gap median 6.9 d, shortest 6.9 d; fixed-order fallbacks 329
- Orbit Romance: 98 different films of 98; repeat gap median 7.5 d, shortest 7.0 d; fixed-order fallbacks 0
- Detour: 88 different films of 88; repeat gap median 6.1 d, shortest 6.1 d; fixed-order fallbacks 359
- BritX: 90 different films of 90; repeat gap median 6.4 d, shortest 6.4 d; fixed-order fallbacks 350
- Ring X: never aired in four weeks: All Elite Wrestling: Battle of the Belts
- Ring X ringx.monday (appointment): 4 occurrences, first programme starts 2 to 30 min after 18:00, median 12
- Ring X ringx.tuesday (appointment): 4 occurrences, first programme starts 2 to 18 min after 18:00, median 7
- Ring X ringx.wednesday (appointment): 4 occurrences, first programme starts 8 to 19 min after 18:00, median 12
- Ring X ringx.thursday (appointment): 4 occurrences, first programme starts 1 to 12 min after 18:00, median 5
- Ring X ringx.friday (appointment): 4 occurrences, first programme starts 2 to 17 min after 18:00, median 15
- Ring X ringx.saturday (appointment): 4 occurrences, first programme starts 1 to 37 min after 18:00, median 10
- Ring X ringx.ppv (appointment): 4 occurrences, first programme starts 4 to 19 min after 19:00, median 18
- Vortex: never aired in four weeks: Now and Then, Here and There
- Vortex vortex.movie: 8 occurrences, first programme starts 0 to 19 min after 20:00, median 10
- Heroes event 7-11: 4 occurrences, first programme starts 1 to 21 min after 7:00, median 13
- Orbit X event 7-12: 4 occurrences, first programme starts 3 to 14 min after 7:00, median 12
- Orbit X event 15-18: 20 occurrences, first programme starts 1 to 53 min after 15:00, median 9
- Wonder: 94 different films of 94; repeat gap median 5.7 d, shortest 5.7 d; fixed-order fallbacks 409
- PixelZ: 100 different films of 100; repeat gap median 6.8 d, shortest 6.8 d; fixed-order fallbacks 315
@@ -0,0 +1,81 @@
# Four-week replay fingerprints, 0.99.8 against 0.99.9, from box copies taken 4 November 10:36
## 0.99.8
101 committed=f1138812bc6ff7ba kept=true fourWeeks=ae4fdd031ca3699d programmes=2187
102 committed=cbdd0627b45fb48f kept=true fourWeeks=7462a13a5dd6596c programmes=1330
103 committed=69847479df382ef5 kept=true fourWeeks=a71e6b76771a62d3 programmes=1849
104 committed=53b02b196f922fc4 kept=true fourWeeks=d98bc7acac06e1c4 programmes=1739
105 committed=a9f7df71efcb9993 kept=true fourWeeks=48fa56e0adebecea programmes=2492
106 committed=c6e05fe7b6b8119e kept=true fourWeeks=a4207fcc179d9c1a programmes=1159
107 committed=aa9a41cb3264d818 kept=true fourWeeks=347955a91cfeba8e programmes=980
201 committed=2aed72028682d722 kept=true fourWeeks=967a52b35c2ba1cb programmes=959
202 committed=9ee874c1bb9b0886 kept=true fourWeeks=59d42ffbd900d9f7 programmes=1106
203 committed=00a4f98982a478cc kept=true fourWeeks=6083a2b4f26d3da1 programmes=1147
204 committed=635537fa63101747 kept=true fourWeeks=ea829e3413e800d0 programmes=1261
301 committed=b1afc52c50cc8111 kept=true fourWeeks=571e54e33cd9a8c5 programmes=364
302 committed=042b4ed169971dda kept=true fourWeeks=4747ecb7df967f51 programmes=446
303 committed=e0583a7eb1a82100 kept=true fourWeeks=2e109220600f6caa programmes=422
304 committed=3d62105f6fce7296 kept=true fourWeeks=03e7a36d851730ba programmes=498
305 committed=fc22f2854413a97d kept=true fourWeeks=ce3a1f3581f8ce5f programmes=499
306 committed=1b8a03f70171fe70 kept=true fourWeeks=938686d5811c3359 programmes=494
307 committed=95087afee891c9ff kept=true fourWeeks=7c267a8cdbf693fa programmes=443
308 committed=caa2613998d76d0d kept=true fourWeeks=5a68309f856b533d programmes=503
309 committed=cfe8866691f75e6f kept=true fourWeeks=b07b7b278351653c programmes=501
401 committed=3cf4fb1d371b7111 kept=true fourWeeks=3bc3448b25a4d31a programmes=890
601 committed=4fa3874f1050ea8e kept=true fourWeeks=6cae5b0645d9246e programmes=2173
602 committed=40db88a06b18a725 kept=true fourWeeks=ca03d1caf376ce5d programmes=2336
603 committed=20977e7dbe935a4b kept=true fourWeeks=75276fe8f5d71198 programmes=2297
604 committed=53b9f9d63cf1acb9 kept=true fourWeeks=7f2a15991fc89c02 programmes=3127
605 committed=38961058a10f4eb4 kept=true fourWeeks=6cdc8d98d6e25ee5 programmes=4381
611 committed=5f5acfc6baefd51d kept=true fourWeeks=7e8f821f059471f8 programmes=3031
612 committed=21dc64eb844a9767 kept=true fourWeeks=08b5cba3b889649e programmes=577
613 committed=26acbbf36c46dff1 kept=true fourWeeks=8522346f6d9b3e7c programmes=525
614 committed=194d9777e6d58734 kept=true fourWeeks=57cdae7263ddd282 programmes=3788
## 0.99.9
101 committed=f1138812bc6ff7ba kept=true fourWeeks=ae4fdd031ca3699d programmes=2187
102 committed=cbdd0627b45fb48f kept=true fourWeeks=7462a13a5dd6596c programmes=1330
103 committed=69847479df382ef5 kept=true fourWeeks=a71e6b76771a62d3 programmes=1849
104 committed=53b02b196f922fc4 kept=true fourWeeks=d98bc7acac06e1c4 programmes=1739
105 committed=a9f7df71efcb9993 kept=true fourWeeks=48fa56e0adebecea programmes=2492
106 committed=c6e05fe7b6b8119e kept=true fourWeeks=a4207fcc179d9c1a programmes=1159
107 committed=aa9a41cb3264d818 kept=true fourWeeks=6474037969baa9c5 programmes=981
201 committed=2aed72028682d722 kept=true fourWeeks=8299dc2c05a9962a programmes=959
202 committed=9ee874c1bb9b0886 kept=true fourWeeks=69e9f8b4a7b92a94 programmes=1106
203 committed=00a4f98982a478cc kept=true fourWeeks=6083a2b4f26d3da1 programmes=1147
204 committed=635537fa63101747 kept=true fourWeeks=ea829e3413e800d0 programmes=1261
301 committed=b1afc52c50cc8111 kept=true fourWeeks=571e54e33cd9a8c5 programmes=364
302 committed=042b4ed169971dda kept=true fourWeeks=4747ecb7df967f51 programmes=446
303 committed=e0583a7eb1a82100 kept=true fourWeeks=2e109220600f6caa programmes=422
304 committed=3d62105f6fce7296 kept=true fourWeeks=aa8aa6d8118ea874 programmes=496
305 committed=fc22f2854413a97d kept=true fourWeeks=8f6b61c38c6ea2b8 programmes=497
306 committed=1b8a03f70171fe70 kept=true fourWeeks=938686d5811c3359 programmes=494
307 committed=95087afee891c9ff kept=true fourWeeks=7c267a8cdbf693fa programmes=443
308 committed=caa2613998d76d0d kept=true fourWeeks=5a68309f856b533d programmes=503
309 committed=cfe8866691f75e6f kept=true fourWeeks=b07b7b278351653c programmes=501
401 committed=3cf4fb1d371b7111 kept=true fourWeeks=3bc3448b25a4d31a programmes=890
601 committed=4fa3874f1050ea8e kept=true fourWeeks=6cae5b0645d9246e programmes=2173
602 committed=40db88a06b18a725 kept=true fourWeeks=ca03d1caf376ce5d programmes=2336
603 committed=20977e7dbe935a4b kept=true fourWeeks=75276fe8f5d71198 programmes=2297
604 committed=53b9f9d63cf1acb9 kept=true fourWeeks=7f2a15991fc89c02 programmes=3127
605 committed=38961058a10f4eb4 kept=true fourWeeks=6cdc8d98d6e25ee5 programmes=4381
611 committed=5f5acfc6baefd51d kept=true fourWeeks=7e8f821f059471f8 programmes=3031
612 committed=21dc64eb844a9767 kept=true fourWeeks=08b5cba3b889649e programmes=577
613 committed=26acbbf36c46dff1 kept=true fourWeeks=8522346f6d9b3e7c programmes=525
614 committed=194d9777e6d58734 kept=true fourWeeks=57cdae7263ddd282 programmes=3788
## Differences
7,9c7,9
< 107 committed=aa9a41cb3264d818 kept=true fourWeeks=347955a91cfeba8e programmes=980
< 201 committed=2aed72028682d722 kept=true fourWeeks=967a52b35c2ba1cb programmes=959
< 202 committed=9ee874c1bb9b0886 kept=true fourWeeks=59d42ffbd900d9f7 programmes=1106
---
> 107 committed=aa9a41cb3264d818 kept=true fourWeeks=6474037969baa9c5 programmes=981
> 201 committed=2aed72028682d722 kept=true fourWeeks=8299dc2c05a9962a programmes=959
> 202 committed=9ee874c1bb9b0886 kept=true fourWeeks=69e9f8b4a7b92a94 programmes=1106
15,16c15,16
< 304 committed=3d62105f6fce7296 kept=true fourWeeks=03e7a36d851730ba programmes=498
< 305 committed=fc22f2854413a97d kept=true fourWeeks=ce3a1f3581f8ce5f programmes=499
---
> 304 committed=3d62105f6fce7296 kept=true fourWeeks=aa8aa6d8118ea874 programmes=496
> 305 committed=fc22f2854413a97d kept=true fourWeeks=8f6b61c38c6ea2b8 programmes=497