0.83.1: no third in a row while another show waits, proven against the 0.81.4 baselines
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 3m11s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m38s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 10:17:15 +00:00
co-authored by Claude Opus 5.5
parent 310d878ef3
commit 34ea521d62
8 changed files with 115 additions and 22 deletions
+17
View File
@@ -0,0 +1,17 @@
# Orbit TV 0.83.1: curtain intro (0.83.0) and no third in a row
## 0.83.0, Drive-In curtain intro
- The approved curtain film plays when you go into a Drive-In screening, over the tuning screen and the film's first moments. The film opens underneath at its live point and stays silent until the curtains finish, so neither the schedule nor the playback position changes.
- Back skips it at any time, and OK skips it once the film has loaded. It ends at once on any playback error, after 9 seconds whatever happens, when the app is left, or when the screening is closed. Each ending is noted in playback-notes.txt with its reason and time.
- Settings, Watching: Drive-In curtains (on by default) and Reduce motion (off by default; turns the intro off). The Watching panel now scrolls.
- `PresentationIntro.kt` holds the reusable parts: `IntroSource`, `IntroPlayback`, `rememberIntro` and `IntroOverlay`. A takeover supplies another `IntroSource`.
- `raw/drive_in_intro.mp4` is 380 KB, 7 s at 30 fps, -20 LUFS, 5 dB below the approved master so the fanfare sits nearer film level.
- Test hook: launching with the `orbit_intro_test` extra from adb plays the intro on the next channel tuned, once.
## 0.83.1, no third in a row
- With finish-together, a show never plays a third time in a row while another show in the block is only waiting for the round to finish. The show that has waited starts its next round early, and its reason says so.
- `PlanBaselineTest`: with the rule switched off, all nine 0.81.4 fingerprints still match. With it on, every plan is identical up to its first early restart, which only ever happens where the old plan had a third in a row.
- Fresh-week replay of every finish-together channel from the box's real files: only Tots changes. Its longest run falls from 83 to 2, from programme 650 onwards. Ring X's single-show event days are unchanged at 18. The other 16 channels are identical.
- Committed plans are untouched, so Monday 9 November's committed Tots run still airs unless Tots' plan is rebuilt.
Unit tests: 321 passed, 0 failed. Lint passed.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 97
versionName = "0.83.0-curtain-intro"
versionCode = 98
versionName = "0.83.1-no-third-in-a-row"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -228,7 +228,11 @@ internal fun extendFilmSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor,
/** Shows take turns, one episode each, back to back from [fromMs] until [untilMs] is covered. */
internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false,
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false): ChannelPlan {
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false,
/** How many times the cursor's last show had already played in a row, so a run carries across a top-up. */
runBefore: Int = 0,
/** Off only in tests, to show that this rule is the one difference from planning before it existed. */
noThirdInARow: Boolean = true): ChannelPlan {
require(pool.isNotEmpty() && pool.all { it.episodes.isNotEmpty() })
if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs, dayParts, zone, avoid)
val marks = cursor.marks.toMutableMap()
@@ -236,6 +240,8 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
var last = cursor.lastSeriesId
var start = fromMs
val mixes = HashMap<String, List<PoolEpisode>>()
// How many times in a row the last show has played, counted from the start of this extension.
var run = if (cursor.lastSeriesId != null) runBefore else 0
fun running(show: PoolSeries) = if (shuffle) mixes.getOrPut(show.id) { shuffledEpisodes(show) } else show.episodes
fun following(show: PoolSeries): PoolEpisode {
if (!shuffle) return nextEpisode(show, marks[show.id])
@@ -257,11 +263,21 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
else -> true
}
}
val series = picked ?: nextInTurn(pool, last, allowed)
val chosen = picked ?: nextInTurn(pool, last, allowed)
// Never a third in a row while another show in the block is only waiting for the round to finish. That one starts its next round early.
val early = if (!noThirdInARow || !finishTogether || chosen.id != last || run < 2) null else turnOrder(pool, last).firstOrNull { show ->
show.id != chosen.id && show in available && marks[show.id] == running(show).last().mark && !avoid(show, start, start + running(show).first().durationMs)
}
if (early != null) marks.remove(early.id)
val series = early ?: chosen
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")
append(when {
early != null -> "; started its next round early, so ${chosen.title} doesn't play a third time in a row"
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)}")
@@ -271,6 +287,7 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
val media = ScheduledMedia(item, episode.sourceId, episode.durationMs)
entries += ScheduleEntry(series.id, episode.mark, media, start, why)
marks[series.id] = episode.mark
run = if (series.id == last) run + 1 else 1
last = series.id
start += episode.durationMs
}
@@ -298,12 +315,13 @@ internal fun airedCursor(base: ScheduleCursor, entries: List<ScheduleEntry>, now
* With no usable plan it starts from now at the aired position.
*/
internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List<PoolSeries>?, nowMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false,
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false): ChannelPlan? {
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false, noThirdInARow: Boolean = true): ChannelPlan? {
val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) }
if (kept != null && kept.endMs >= nowMs + SCHEDULE_TOP_UP_MS) return kept
if (pool.isNullOrEmpty()) return kept
val from = if (kept != null && kept.endMs > nowMs) kept.endMs else nowMs
val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle)
val trailing = kept?.entries?.lastOrNull()?.let { tail -> kept.entries.asReversed().takeWhile { it.seriesId == tail.seriesId }.size } ?: 0
val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, trailing, noThirdInARow)
return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail)
}
@@ -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.83.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.83.1\"")
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.83.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.83.1")
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()) {
@@ -305,12 +305,13 @@ class ChannelScheduleTest {
assertEquals(entries.size, entries.map { it.mark }.distinct().size)
}
@Test fun aFinishedShowWaitsForTheOthers() {
@Test fun aFinishedShowWaitsButNeverForAThirdInARow() {
val short = show("s", 1, 2)
val long = show("l", 1, 6)
val entries = extendSchedule(listOf(short, long), ScheduleCursor(), now, now + 8 * 45 * minute, finishTogether = true).entries
assertEquals(listOf("s", "l", "s", "l", "l", "l", "l", "l"), entries.map { it.seriesId })
assertEquals(EpisodeMark(1, 6), entries.last().mark)
// The short show waits once, then starts its next round rather than let the long one run a third time.
assertEquals(listOf("s", "l", "s", "l", "l", "s", "l", "s"), entries.map { it.seriesId })
assertEquals((1..4).toList(), entries.filter { it.seriesId == "l" }.map { it.mark.episode })
}
@Test fun onceEveryShowHasFinishedTheyAllStartAgain() {
@@ -323,7 +324,11 @@ class ChannelScheduleTest {
val pool = listOf(show("s", 1, 2), show("l", 1, 300))
val first = topUpPlan(null, ScheduleCursor(), pool, now, finishTogether = true)!!
val later = topUpPlan(first, ScheduleCursor(), pool, now + 2 * day, finishTogether = true)!!
assertEquals(2, later.entries.count { it.seriesId == "s" } + first.entries.count { it.seriesId == "s" && it.endMs < now + day })
// The long show's place carries across the top-up, and so does the rule against a third in a row.
val longEpisodes = later.entries.filter { it.seriesId == "l" }.map { it.mark.episode }
assertEquals(longEpisodes.sorted(), longEpisodes)
val runs = later.entries.fold(Triple("", 0, 0)) { (prev, run, best), e -> val r = if (e.seriesId == prev) run + 1 else 1; Triple(e.seriesId, r, maxOf(best, r)) }.third
assertTrue(runs <= 2)
}
@Test fun takingTurnsSkipsOnlyWhatItMust() {
@@ -33,17 +33,17 @@ class PlanBaselineTest {
}
private val day = 24 * hour
private fun plans() = linkedMapOf(
private fun plans(rule: Boolean = true) = 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),
"finishTogether" to extendSchedule(shows, ScheduleCursor(), start, start + 4 * day, finishTogether = true, noThirdInARow = rule),
"dayParts" to extendSchedule(shows, ScheduleCursor(), start, start + 4 * day, finishTogether = true, dayParts = parts, zone = zone, noThirdInARow = rule),
"clash" to extendSchedule(shows, ScheduleCursor(), start, start + 3 * day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid, noThirdInARow = rule),
"everythingClashes" to extendSchedule(listOf(show("a", 2, 7, 22)), ScheduleCursor(), start, start + day, avoid = avoid, noThirdInARow = rule),
"shuffle" to extendSchedule(shows, ScheduleCursor(), start, start + 3 * day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid, shuffle = true, noThirdInARow = rule),
"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),
filmRestMs = day, dayParts = parts, zone = zone, avoid = avoid, noThirdInARow = rule),
"topUp" to topUpPlan(extendSchedule(shows, ScheduleCursor(), start, start + 2 * day), ScheduleCursor(), shows, start + day, finishTogether = true, dayParts = parts, zone = zone, avoid = avoid, noThirdInARow = rule),
)
@Test fun everyNewProgrammeSaysWhy() {
@@ -76,6 +76,19 @@ class PlanBaselineTest {
// 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) })
// With the no-third-in-a-row rule off, planning is exactly as it was in 0.81.4.
assertEquals(expected, plans(rule = false).mapValues { fingerprint(it.value) })
}
@Test fun theRuleOnlyChangesPlansFromItsFirstEarlyRestart() {
val before = plans(rule = false); val after = plans(rule = true)
for ((name, plan) in after) {
val old = before.getValue(name)!!.entries; val new = plan!!.entries
val firstEarly = new.indexOfFirst { "started its next round early" in it.why }
val same = if (firstEarly < 0) new.size else firstEarly
assertEquals(name, old.take(same).map { it.seriesId to it.mark }, new.take(same).map { it.seriesId to it.mark })
// And only where the old plan really had a third in a row at that point.
if (firstEarly >= 0) assertTrue(name, old.drop(firstEarly - 2).take(3).map { it.seriesId }.distinct().size == 1)
}
}
}
@@ -0,0 +1,40 @@
package tv.orbit.classic
import org.junit.Assert.*
import org.junit.Test
/** Finish-together once let one big show play dozens of times in a row while smaller ones waited. */
class RunLengthTest {
private val minute = 60_000L
private val start = 1_793_700_000_000L
private fun show(id: String, count: Int) = PoolSeries(id, "Show $id", (1..count).map { PoolEpisode("$id$it", "s", EpisodeMark(1, it), "E$it", 10 * minute) })
private fun longestRun(plan: ChannelPlan) = plan.entries.fold(Triple("", 0, 0)) { (prev, run, best), e -> val r = if (e.seriesId == prev) run + 1 else 1; Triple(e.seriesId, r, maxOf(best, r)) }.third
@Test fun aBigShowNeverPlaysThreeInARowWhileOthersWait() {
val pool = listOf(show("big", 40), show("a", 3), show("b", 4), show("c", 2))
for (shuffle in listOf(false, true)) {
val plan = extendSchedule(pool, ScheduleCursor(), start, start + 60 * 10 * minute, finishTogether = true, shuffle = shuffle)
assertTrue("longest run ${longestRun(plan)}", longestRun(plan) <= 2)
assertTrue(plan.entries.any { "started its next round early" in it.why })
}
}
@Test fun eachShowStillPlaysItsWholeRoundBeforeRepeatingAnEpisode() {
val pool = listOf(show("big", 40), show("a", 3))
val plan = extendSchedule(pool, ScheduleCursor(), start, start + 40 * 10 * minute, finishTogether = true)
val big = plan.entries.filter { it.seriesId == "big" }.map { it.mark.episode }
assertEquals(big.distinct(), big)
}
@Test fun aBlockWithOneShowIsUnchanged() {
val plan = extendSchedule(listOf(show("only", 30)), ScheduleCursor(), start, start + 20 * 10 * minute, finishTogether = true)
assertEquals(20, longestRun(plan))
}
@Test fun withoutFinishTogetherNothingChanges() {
val pool = listOf(show("big", 40), show("a", 3))
val plan = extendSchedule(pool, ScheduleCursor(), start, start + 20 * 10 * minute)
assertEquals(listOf("big", "a"), plan.entries.take(2).map { it.seriesId })
assertTrue(plan.entries.none { "early" in it.why })
}
}