diff --git a/VERIFICATION-0.83.1.md b/VERIFICATION-0.83.1.md new file mode 100644 index 0000000..b78d51e --- /dev/null +++ b/VERIFICATION-0.83.1.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 910f1ed..85c638e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index 229c14c..d883717 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -228,7 +228,11 @@ internal fun extendFilmSchedule(pool: List, cursor: ScheduleCursor, /** Shows take turns, one episode each, back to back from [fromMs] until [untilMs] is covered. */ internal fun extendSchedule(pool: List, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false, - dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false): ChannelPlan { + dayParts: List = 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, cursor: ScheduleCursor, from var last = cursor.lastSeriesId var start = fromMs val mixes = HashMap>() + // 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, 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, 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, now * With no usable plan it starts from now at the aired position. */ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List?, nowMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false, - dayParts: List = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false): ChannelPlan? { + dayParts: List = 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) } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 119830b..081dae0 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.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) diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 454556d..ea61452 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt index 88d5d13..f827ac5 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt @@ -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() { diff --git a/android/app/src/test/java/tv/orbit/classic/PlanBaselineTest.kt b/android/app/src/test/java/tv/orbit/classic/PlanBaselineTest.kt index 6b0f7d7..e5304be 100644 --- a/android/app/src/test/java/tv/orbit/classic/PlanBaselineTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PlanBaselineTest.kt @@ -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) + } } } diff --git a/android/app/src/test/java/tv/orbit/classic/RunLengthTest.kt b/android/app/src/test/java/tv/orbit/classic/RunLengthTest.kt new file mode 100644 index 0000000..ef34da1 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/RunLengthTest.kt @@ -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 }) + } +}