0.82.0: Phase 1, a reason with each planned programme, with planning proven unchanged
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 3m14s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m49s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 09:01:18 +00:00
co-authored by Claude Opus 5.5
parent 99ffebfe47
commit e1d2b77da5
8 changed files with 172 additions and 25 deletions
+16
View File
@@ -0,0 +1,16 @@
# Orbit TV 0.82.0: selection reasons (Phase 1)
Each newly planned programme now keeps a short reason saying which block it came from, whether it was next in turn or a fallback, and which choices were passed over and why. Film channels record how long the film had rested and how many films it was picked from. The hourly `OrbitSchedule` log line now adds `added`, `deferred` and `fallbacks` for the programmes just planned.
Scheduling is unchanged:
- `PlanBaselineTest` fingerprints nine planning scenarios, taken from 0.81.4 before any change: turns, finish together, day parts, clashes, every choice clashing, shuffle, films, films with clashes, and a top-up. All nine match exactly.
- Choosing a film and the day-part pools go through the same code as before, with the reason worked out alongside.
Backward compatibility:
- The reason is an optional `w` field on each programme in `schedule-N.json`. Plans written before 0.82.0 load with an empty reason, as the test proves on a file with the field removed.
- 0.81.4 and earlier read named fields only, so they ignore `w`, and rolling back keeps every plan readable.
- Programmes already committed keep no reason and stay exactly as planned. Reasons appear from the next top-up onwards.
Unit tests: 313 passed, 0 failed. Lint passed.
On the box: see the install notes below.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 95
versionName = "0.81.4-drive-in-days"
versionCode = 96
versionName = "0.82.0-selection-reasons"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -106,7 +106,9 @@ internal data class ScheduleCursor(val lastSeriesId: String? = null, val marks:
/** When each film last started, for its rest period. */
val filmShown: Map<String, Long> = emptyMap())
internal data class ScheduleEntry(val seriesId: String, val mark: EpisodeMark, val media: ScheduledMedia, val startMs: Long) {
internal data class ScheduleEntry(val seriesId: String, val mark: EpisodeMark, val media: ScheduledMedia, val startMs: Long,
/** Why the planner chose this, for diagnostics only. Empty for programmes planned before reasons were kept. */
val why: String = "") {
val endMs get() = startMs + media.durationMs
}
@@ -133,19 +135,27 @@ internal fun shuffledEpisodes(series: PoolSeries): List<PoolEpisode> = series.ep
* What a programme starting at [startMs] may use, by the local hour then, so the clocks changing moves blocks with the wall clock.
* A block with nothing in the pool leaves the whole pool, so a channel never goes off air for lack of a title.
*/
internal fun dayPartPool(pool: List<PoolSeries>, parts: List<DayPart>, startMs: Long, zone: java.time.ZoneId): List<PoolSeries> {
internal fun dayPartPool(pool: List<PoolSeries>, parts: List<DayPart>, startMs: Long, zone: java.time.ZoneId): List<PoolSeries> =
dayPartChoice(pool, parts, startMs, zone).first
/** [dayPartPool], with a short name for the block it came from, for the reasons kept with each programme. */
internal fun dayPartChoice(pool: List<PoolSeries>, parts: List<DayPart>, startMs: Long, zone: java.time.ZoneId): Pair<List<PoolSeries>, String> {
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) }) {
if (part.titles.isEmpty() && part.group.isEmpty()) return pool
val name = (if (part.days.isNotEmpty()) "event " else "block ") + part.group.ifEmpty { "${part.fromHour}-${part.toHour}" }
if (part.titles.isEmpty() && part.group.isEmpty()) return pool to name
val titles = part.titles.map(::seriesKey).toSet()
val allowed = pool.filter { seriesKey(it.title) in titles || (part.group.isNotEmpty() && it.group == part.group) }
if (allowed.isNotEmpty()) return allowed
if (allowed.isNotEmpty()) return allowed to name
}
// A film saved for an event doesn't appear in everyday programming.
return pool.filter { !it.isFilm || pool.all(PoolSeries::isFilm) }.ifEmpty { pool }
return pool.filter { !it.isFilm || pool.all(PoolSeries::isFilm) }.ifEmpty { pool } to (if (parts.isEmpty()) "whole pool" else "no block, whole pool")
}
/** A few names and how many more, so a reason stays short. */
private fun someOf(names: List<String>) = names.take(3).joinToString(", ") + if (names.size > 3) " +${names.size - 3}" else ""
internal fun turnOrder(pool: List<PoolSeries>, last: String?): List<PoolSeries> {
val after = pool.indexOfFirst { it.id == last } + 1
return (0 until pool.size).map { pool[(after + it) % pool.size] }
@@ -173,11 +183,27 @@ internal fun clashesWith(plans: List<ChannelPlan>): ProgrammeClash {
* A film past its rest, chosen by a generator seeded from the start time so a plan can be explained and reproduced.
* If the pool is too small for the rest period, the film that has waited longest.
*/
internal fun nextFilm(pool: List<PoolSeries>, shown: Map<String, Long>, startMs: Long, restMs: Long, avoid: ProgrammeClash = { _, _, _ -> false }): PoolSeries {
val rested = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true }.let { films ->
films.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) }.ifEmpty { films } }
return if (rested.isNotEmpty()) rested[java.util.Random(startMs).nextInt(rested.size)]
else pool.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) }.ifEmpty { pool }.minBy { shown[it.id] ?: Long.MIN_VALUE }
internal fun nextFilm(pool: List<PoolSeries>, shown: Map<String, Long>, startMs: Long, restMs: Long, avoid: ProgrammeClash = { _, _, _ -> false }): PoolSeries =
chooseFilm(pool, shown, startMs, restMs, avoid).first
/** [nextFilm], with the reason for its choice. The choice itself is made exactly as before. */
internal fun chooseFilm(pool: List<PoolSeries>, shown: Map<String, Long>, startMs: Long, restMs: Long, avoid: ProgrammeClash = { _, _, _ -> false }): Pair<PoolSeries, String> {
val restedAll = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true }
val clear = restedAll.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) }
val rested = clear.ifEmpty { restedAll }
if (rested.isNotEmpty()) {
val film = rested[java.util.Random(startMs).nextInt(rested.size)]
val waited = shown[film.id]?.let { "last shown ${(startMs - it) / 3_600_000}h ago" } ?: "not shown before"
val deferred = restedAll.size - clear.size
return film to buildString {
append("picked from ${rested.size} rested films, $waited")
if (clear.isEmpty()) append("; fallback: every rested film airs elsewhere then")
else if (deferred > 0) append("; deferred $deferred airing elsewhere")
}
}
val unclashed = pool.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) }
val film = unclashed.ifEmpty { pool }.minBy { shown[it.id] ?: Long.MIN_VALUE }
return film to "fallback: no film past its rest, so the longest waiting" + if (unclashed.isEmpty()) ", even though it airs elsewhere then" else ""
}
/** Films by their rest period, back to back from [fromMs] until [untilMs] is covered. */
@@ -188,10 +214,11 @@ internal fun extendFilmSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor,
var last = cursor.lastSeriesId
var start = fromMs
while (start < untilMs) {
val film = nextFilm(dayPartPool(pool, dayParts, start, zone), shown, start, restMs, avoid)
val (available, block) = dayPartChoice(pool, dayParts, start, zone)
val (film, why) = chooseFilm(available, shown, start, restMs, avoid)
val copy = film.episodes.first()
val item = SampleItem(film.title, "Movie", copy.durationMs / 60_000, "", copy.id, copy.durationMs)
entries += ScheduleEntry(film.id, copy.mark, ScheduledMedia(item, copy.sourceId, copy.durationMs), start)
entries += ScheduleEntry(film.id, copy.mark, ScheduledMedia(item, copy.sourceId, copy.durationMs), start, "$block; $why")
shown[film.id] = start
last = film.id
start += copy.durationMs
@@ -216,16 +243,33 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
return mix[(mix.indexOfFirst { it.mark == marks[show.id] } + 1) % mix.size]
}
while (start < untilMs) {
val available = dayPartPool(pool, dayParts, start, zone)
val (available, block) = dayPartChoice(pool, dayParts, start, zone)
// Shows only wait for others in the same block, or a daytime show could stand idle for an evening one.
if (finishTogether && available.all { marks[it.id] == running(it).last().mark }) available.forEach { marks.remove(it.id) }
val restarted = finishTogether && available.all { marks[it.id] == running(it).last().mark }
if (restarted) available.forEach { marks.remove(it.id) }
val allowed = { show: PoolSeries -> show in available && (!finishTogether || marks[show.id] != running(show).last().mark) }
// Skips a show already on elsewhere, unless that would leave nothing to show.
val series = turnOrder(pool, last).firstOrNull { allowed(it) && !avoid(it, start, start + following(it).durationMs) } ?: nextInTurn(pool, last, allowed)
val waiting = mutableListOf<String>(); val elsewhere = mutableListOf<String>()
val picked = turnOrder(pool, last).firstOrNull { show ->
when {
!allowed(show) -> { if (show in available) waiting += show.title; false }
avoid(show, start, start + following(show).durationMs) -> { elsewhere += show.title; false }
else -> true
}
}
val series = picked ?: nextInTurn(pool, last, allowed)
val episode = following(series)
val why = buildString {
append(block)
append(if (picked != null) "; next in turn" else "; fallback: every show in turn airs elsewhere then")
if (shuffle) append(", episode from its shuffled order")
if (restarted) append("; every show had finished, so all started again")
if (waiting.isNotEmpty()) append("; deferred until the rest finish: ${someOf(waiting)}")
if (elsewhere.isNotEmpty()) append("; deferred, airing elsewhere: ${someOf(elsewhere)}")
}
val item = if (series.isFilm) SampleItem(series.title, "Movie", episode.durationMs / 60_000, "", episode.id, episode.durationMs) else scheduledItem(series.title, episode)
val media = ScheduledMedia(item, episode.sourceId, episode.durationMs)
entries += ScheduleEntry(series.id, episode.mark, media, start)
entries += ScheduleEntry(series.id, episode.mark, media, start, why)
marks[series.id] = episode.mark
last = series.id
start += episode.durationMs
@@ -6,8 +6,10 @@ internal const val POOL_REFRESH_MS = 7 * 24 * 60 * 60_000L
/** Only counts and times, so it's safe to log. Never titles, addresses or the account. */
internal class SchedulerReport(val programmes: Int, val episodes: Int, val shows: List<String>, val requests: Int,
val fetchMs: Long, val planMs: Long, val bytes: Long, val fromCache: Boolean, val endMs: Long) {
fun logLine(number: Int) = "channel=$number programmes=$programmes episodes=$episodes shows=${shows.size} requests=$requests fetchMs=$fetchMs planMs=$planMs bytes=$bytes cache=$fromCache"
val fetchMs: Long, val planMs: Long, val bytes: Long, val fromCache: Boolean, val endMs: Long,
/** Of the programmes just added: how many passed over another choice, and how many had to break a rule to fill the slot. */
val added: Int = 0, val deferred: Int = 0, val fallbacks: Int = 0) {
fun logLine(number: Int) = "channel=$number programmes=$programmes episodes=$episodes shows=${shows.size} requests=$requests fetchMs=$fetchMs planMs=$planMs bytes=$bytes cache=$fromCache added=$added deferred=$deferred fallbacks=$fallbacks"
}
internal class ChannelScheduler(private val store: ScheduleStore, private val clock: () -> Long = System::currentTimeMillis) {
@@ -110,8 +112,10 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
store.saveProgress(userId, number, ScheduleStore.SavedProgress(progress?.enabled ?: true, airedCursor(base, plan.entries, now)))
session.rememberScheduledItems(plan.entries.map { it.media.item })
val planMs = android.os.SystemClock.elapsedRealtime() - started
val added = if (plan === existing) emptyList() else plan.entries.filter { it.startMs >= (existing?.endMs ?: Long.MIN_VALUE) }
val report = SchedulerReport(plan.entries.size, pool?.series.orEmpty().sumOf { it.episodes.size }, pool?.series.orEmpty().map { it.title },
requests, fetchMs, planMs, store.sizes(number).values.sum(), requests == 0, plan.endMs)
requests, fetchMs, planMs, store.sizes(number).values.sum(), requests == 0, plan.endMs,
added.size, added.count { "deferred" in it.why }, added.count { "fallback" in it.why })
Log.i("OrbitSchedule", report.logLine(number))
return report
}
@@ -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.81.4\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.82.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -84,12 +84,14 @@ internal class ScheduleStore(private val directory: File) {
o.optJSONObject("shown")?.let { shown -> shown.keys().asSequence().associate { id -> validId(id) to shown.getLong(id) } }.orEmpty())
}
private fun validId(id: String) = id.also { require(EmbyClient.validId(it)) }
/** Reasons are diagnostics. A long one is cut rather than refused, so it can never cost a plan. */
private val MAX_REASON_CHARS = 400
private fun validDuration(ms: Long) = ms.also { require(it in 1_000..SampleChannel.MAX_DURATION_MS) }
fun savePlan(userId: String, number: Int, plan: ChannelPlan) {
val entries = JSONArray()
plan.entries.forEach { e -> entries.put(JSONObject().put("s", e.seriesId).put("n", JSONArray().put(e.mark.season).put(e.mark.episode))
.put("i", e.media.item.id).put("c", e.media.sourceId).put("t", e.media.item.title).put("d", e.media.durationMs).put("a", e.startMs).also { row -> if (e.media.item.type == "Movie") row.put("k", "Movie") }) }
.put("i", e.media.item.id).put("c", e.media.sourceId).put("t", e.media.item.title).put("d", e.media.durationMs).put("a", e.startMs).also { row -> if (e.media.item.type == "Movie") row.put("k", "Movie"); if (e.why.isNotEmpty()) row.put("w", e.why) }) }
write(file("schedule", number), root(userId, number).put("tail", encodeCursor(plan.tail)).put("entries", entries))
}
fun loadPlan(userId: String, number: Int): ChannelPlan? = read(file("schedule", number), userId, number) { root ->
@@ -99,7 +101,7 @@ internal class ScheduleStore(private val directory: File) {
val duration = validDuration(o.getLong("d"))
val mark = o.getJSONArray("n").let { EpisodeMark(it.getInt(0), it.getInt(1)) }
val item = SampleItem(o.getString("t"), o.optString("k", "Episode").also { require(it in listOf("Episode", "Movie")) }, duration / 60_000, "", validId(o.getString("i")), duration)
ScheduleEntry(validId(o.getString("s")), mark, ScheduledMedia(item, validId(o.getString("c")), duration), o.getLong("a"))
ScheduleEntry(validId(o.getString("s")), mark, ScheduledMedia(item, validId(o.getString("c")), duration), o.getLong("a"), o.optString("w").take(MAX_REASON_CHARS))
}
require(entries.zipWithNext().all { (first, second) -> first.endMs <= second.startMs })
ChannelPlan(entries, decodeCursor(root.getJSONObject("tail")))
@@ -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.81.4")
next.setRequestProperty("User-Agent", "OrbitTV/0.82.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()) {
@@ -0,0 +1,81 @@
package tv.orbit.classic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.ZoneId
/**
* Plans made before selection reasons existed, reduced to a fingerprint. Reasons must never change what gets planned,
* so these stay fixed: if one changes, scheduling behaviour has changed.
*/
class PlanBaselineTest {
private val minute = 60_000L
private val hour = 60 * minute
private val start = 1_793_700_000_000L
private val zone = ZoneId.of("Europe/London")
private fun show(id: String, seasons: Int, perSeason: Int, minutes: Long, group: String = "") = PoolSeries(id, "Show $id", (1..seasons).flatMap { season ->
(1..perSeason).map { n -> PoolEpisode("$id-$season-$n", "src-$id-$season-$n", EpisodeMark(season, n), "E$n", (minutes + (n % 3)) * minute) }
}, group)
private fun film(id: String, minutes: Long) = PoolSeries(id, "Film $id", listOf(PoolEpisode(id, "src-$id", EpisodeMark(1, 1), "Film $id", minutes * minute)))
private val shows = listOf(show("a", 2, 7, 22, "day"), show("b", 1, 5, 44, "day"), show("c", 3, 9, 11, "night"), show("d", 2, 4, 30, "night"), show("e", 1, 3, 25))
private val films = (1..18).map { film("f$it", 85L + it * 3) }
private val parts = listOf(DayPart(20, 22, group = "movie", days = setOf(java.time.DayOfWeek.FRIDAY)), DayPart(6, 18, group = "day"), DayPart(18, 6, group = "night"))
// Another channel's plan, so the clash check has something to avoid.
private val other = extendSchedule(listOf(show("a", 2, 7, 22), show("c", 3, 9, 11)), ScheduleCursor(), start + 3 * hour, start + 2 * 24 * hour)
private val avoid = clashesWith(listOf(other))
private fun fingerprint(plan: ChannelPlan?): String {
val text = plan!!.entries.joinToString("|") { "${it.seriesId}:${it.mark.season}.${it.mark.episode}@${(it.startMs - start) / 1000}" } + "#" + plan.tail.lastSeriesId + plan.tail.marks.toSortedMap() + plan.tail.filmShown.toSortedMap()
return java.security.MessageDigest.getInstance("SHA-256").digest(text.toByteArray()).joinToString("") { "%02x".format(it) }.take(16)
}
private val day = 24 * hour
private fun plans() = linkedMapOf(
"turns" to extendSchedule(shows, ScheduleCursor(), start, start + 3 * day),
"finishTogether" to extendSchedule(shows, ScheduleCursor(), start, start + 4 * day, finishTogether = true),
"dayParts" to extendSchedule(shows, ScheduleCursor(), start, start + 4 * day, finishTogether = true, dayParts = parts, zone = zone),
"clash" to extendSchedule(shows, ScheduleCursor(), start, start + 3 * day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid),
"everythingClashes" to extendSchedule(listOf(show("a", 2, 7, 22)), ScheduleCursor(), start, start + day, avoid = avoid),
"shuffle" to extendSchedule(shows, ScheduleCursor(), start, start + 3 * day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid, shuffle = true),
"films" to extendSchedule(films, ScheduleCursor(), start, start + 5 * day, filmRestMs = 2 * day),
"filmsClash" to extendSchedule(films + listOf(PoolSeries("a", "Show a", listOf(PoolEpisode("a", "src-a", EpisodeMark(1, 1), "Show a", 90 * minute)))), ScheduleCursor(), start, start + 3 * day,
filmRestMs = day, dayParts = parts, zone = zone, avoid = avoid),
"topUp" to topUpPlan(extendSchedule(shows, ScheduleCursor(), start, start + 2 * day), ScheduleCursor(), shows, start + day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid),
)
@Test fun everyNewProgrammeSaysWhy() {
plans().values.forEach { plan -> assertTrue(plan!!.entries.all { it.why.isNotBlank() }) }
val shows = plans().getValue("clash")!!.entries
assertTrue(shows.any { "deferred, airing elsewhere" in it.why })
assertTrue(shows.all { it.why.startsWith("block ") || it.why.startsWith("event ") })
}
@Test fun fallbacksAreNamed() {
assertTrue(plans().getValue("everythingClashes")!!.entries.any { "fallback: every show in turn airs elsewhere" in it.why })
assertTrue(plans().getValue("films")!!.entries.all { "rested" in it.why || "fallback" in it.why })
}
@Test fun reasonsSurviveASaveAndOldPlansStillRead() {
val folder = java.nio.file.Files.createTempDirectory("plans").toFile()
val store = ScheduleStore(folder)
val plan = plans().getValue("dayParts")!!
store.savePlan("u", 601, plan)
assertEquals(plan, store.loadPlan("u", 601))
// A file written before reasons existed: the same plan without the field.
val file = folder.listFiles()!!.first { it.name == "schedule-601.json" }
file.writeText(file.readText().replace(Regex(",\\\"w\\\":\\\"(?:[^\\\"\\\\]|\\\\.)*\\\""), ""))
assertTrue("w" !in file.readText().let { org.json.JSONObject(it).getJSONArray("entries").getJSONObject(0).keys().asSequence().toList() })
assertEquals(plan.entries.map { it.copy(why = "") }, store.loadPlan("u", 601)!!.entries)
folder.deleteRecursively()
}
@Test fun planningIsUnchanged() {
// Taken from 0.81.4, before reasons were recorded.
val expected = mapOf("turns" to "5cc4eee2cbfa29e6", "finishTogether" to "c357d2fae0d6a5df", "dayParts" to "764b7b7ac15a5b26", "clash" to "8d3c92c8c4beed8e",
"everythingClashes" to "9f38ee7d889140c5", "shuffle" to "cee9b485bc705af2", "films" to "2f4f2786280d4589", "filmsClash" to "7fe17b8f3746e164", "topUp" to "40ce5b5f8e0e8ed3")
assertEquals(expected, plans().mapValues { fingerprint(it.value) })
}
}