0.99.2: permanent clash checks (network test, real-pool replay assertion, clashes= on the box's log line)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m10s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m35s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-04 01:11:41 +00:00
co-authored by Claude Opus 5.5
parent 1d0defd778
commit 1a8e2647ab
8 changed files with 108 additions and 9 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 131
versionName = "0.99.1-catalogue-names"
versionCode = 132
versionName = "0.99.2-clash-checks"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -365,7 +365,8 @@ internal fun extendWeighted(pool: List<PoolSeries>, cursor: ScheduleCursor, from
// A show new to the block, or back in it, joins level with the block's lowest, not at nothing.
val floor = available.mapNotNull { tally[it.id] }.minOrNull() ?: 0.0
available.forEach { tally.putIfAbsent(it.id, floor) }
val clear = available.filter { !avoid(it, start, start + following(it).durationMs) }.ifEmpty { available }
val unclashed = available.filter { !avoid(it, start, start + following(it).durationMs) }
val clear = unclashed.ifEmpty { available }
val rules = listOf<(PoolSeries) -> Boolean>(
{ it.id != last }, { aired[following(it).id]?.let { at -> start - at < SAME_FILE_MS } != true }, { following(it).mark != running(it).first().mark || runStarted[it.id]?.let { at -> start - at < RUN_REST_MS } != true })
// Drop the softest rules first, only as far as needed to leave a choice.
@@ -375,7 +376,8 @@ internal fun extendWeighted(pool: List<PoolSeries>, cursor: ScheduleCursor, from
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 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 ""
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 "") +
if (unclashed.isEmpty()) "; fallback: every show here airs elsewhere then" 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)
@@ -8,8 +8,10 @@ internal const val POOL_REFRESH_MS = 7 * 24 * 60 * 60_000L
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,
/** 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"
val added: Int = 0, val deferred: Int = 0, val fallbacks: Int = 0,
/** Of the programmes just added: how many are on another channel at the same time. Should always be 0. */
val clashes: 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 clashes=$clashes"
}
internal class ChannelScheduler(private val store: ScheduleStore, private val clock: () -> Long = System::currentTimeMillis) {
@@ -117,7 +119,8 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
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,
added.size, added.count { "deferred" in it.why }, added.count { "fallback" in it.why })
added.size, added.count { "deferred" in it.why }, added.count { "fallback" in it.why },
if (added.isEmpty()) 0 else added.count { others(PoolSeries(it.seriesId, it.media.item.title.substringBefore(" / "), emptyList()), it.startMs, it.endMs) })
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.99.1\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.99.2\"")
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.1")
next.setRequestProperty("User-Agent", "OrbitTV/0.99.2")
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()) {
@@ -1,6 +1,7 @@
package tv.orbit.classic
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.File
@@ -87,6 +88,8 @@ class EditorialCorrectionsAuditTest {
fun d(x: Pair<Int, ScheduleEntry>) = "${names[x.first]} ${local(x.second.startMs).toLocalDateTime().withSecond(0).withNano(0)}${if (x.second.startMs in committedStarts.getValue(x.first)) " (committed)" else ""} [${x.second.why.take(90)}]"
out.appendLine(" ${a.second.media.item.title.substringBefore(" / ").take(40)}: ${d(a)} / ${d(b)}")
}
// The permanent check: nothing booked from now on is on another channel at the same time.
assertEquals("new bookings clashing", 0, clashes.count { (a, b) -> a.second.startMs !in committedStarts.getValue(a.first) || b.second.startMs !in committedStarts.getValue(b.first) })
// Titles in more than one film channel's pool: the ones that can clash.
val filmPools = channels.filter { it.filmRestMs != null }
val shared = filmPools.flatMap { p -> pools.getValue(p.number).filter { it.isFilm }.map { seriesKey(it.title) to p.number } }.groupBy({ it.first }, { it.second }).filterValues { it.size > 1 }
@@ -0,0 +1,68 @@
package tv.orbit.classic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** The same title never on two channels at once: channels topping up in turn, each avoiding the others' plans, as the box does. */
class NetworkClashTest {
private val zone = java.time.ZoneOffset.UTC
private val day = 24 * 60 * 60_000L
private val start = java.time.LocalDate.of(2026, 11, 10).atStartOfDay(zone).toInstant().toEpochMilli()
private fun film(title: String, minutes: Int) = PoolSeries("$title-id", title, listOf(PoolEpisode("$title-file", "src", EpisodeMark(1, 1), title, minutes * 60_000L)))
private fun series(title: String, episodes: Int, minutes: Int = 25) =
PoolSeries(title, title, (1..episodes).map { PoolEpisode("$title-$it", "src", EpisodeMark(1, it), "E$it", minutes * 60_000L) })
private fun network(channels: List<Triple<Int, List<PoolSeries>, ChannelProgramming>>): Map<Int, List<ScheduleEntry>> {
val plans = HashMap<Int, ChannelPlan?>()
val all = channels.associate { it.first to mutableListOf<ScheduleEntry>() }
var now = start
while (now < start + 28 * day) {
for ((number, pool, p) in channels) {
val others = clashesWith(plans.filterKeys { it != number }.values.filterNotNull())
val plan = topUpPlan(plans[number], plans[number]?.tail ?: ScheduleCursor(), pool, now, p.filmRestMs, p.finishTogether, p.dayParts, zone, others,
weightOf = p.tierWeights?.let { p::weightOf })!!
plans[number] = plan
plan.entries.filter { e -> all.getValue(number).none { it.startMs == e.startMs } }.forEach { all.getValue(number) += it }
}
now += 6 * 60 * 60_000L
}
return all
}
private fun overlaps(run: Map<Int, List<ScheduleEntry>>) = run.flatMap { (n, es) -> es.map { n to it } }.groupBy { seriesKey(it.second.media.item.title.substringBefore(" / ")) }.values.sumOf { list ->
list.indices.sumOf { i -> list.drop(i + 1).count { b -> b.first != list[i].first && list[i].second.startMs < b.second.endMs && b.second.startMs < list[i].second.endMs } }
}
@Test fun filmChannelsSharingFilmsNeverScreenOneTogether() {
// Two film channels, half their films shared, and small enough pools that both want the shared films often.
val shared = (1..10).map { film("Shared $it", 90 + 7 * it) }
val a = shared + (1..20).map { film("A $it", 95 + 3 * it) }; val b = shared + (1..20).map { film("B $it", 88 + 5 * it) }
fun films(n: Int) = ChannelProgramming(n, "Films $n", emptyList(), filmRestMs = 3 * day)
val run = network(listOf(Triple(1, a, films(1)), Triple(2, b, films(2))))
assertEquals(0, overlaps(run))
assertTrue(run.values.all { es -> es.zipWithNext().all { (x, y) -> x.endMs == y.startMs } })
}
@Test fun seriesChannelsOnEitherRuleNeverAirAShowTogether() {
// A show shared between an equal-turns channel and a weighted one.
val common = series("Common", 200)
val equal = ChannelProgramming(1, "Equal", emptyList(), finishTogether = true)
val weighted = ChannelProgramming(2, "Weighted", emptyList(), tierWeights = mapOf(seriesKey("Common") to TIER_CORE))
val run = network(listOf(Triple(1, listOf(common, series("X", 50), series("Y", 60)), equal), Triple(2, listOf(common, series("P", 80), series("Q", 70)), weighted)))
assertEquals(0, overlaps(run))
assertTrue(run.values.all { es -> es.any { it.seriesId == "Common" } })
}
@Test fun anUnavoidableClashIsNamedAndCounted() {
// One show only, on two channels each way round: something has to give, and the reason says so on either rule.
val only = series("Only", 100)
val weighted = ChannelProgramming(1, "Weighted", emptyList(), tierWeights = emptyMap()); val equal = ChannelProgramming(2, "Equal", emptyList())
for (order in listOf(listOf(Triple(1, listOf(only), weighted), Triple(2, listOf(only), equal)), listOf(Triple(2, listOf(only), equal), Triple(1, listOf(only), weighted)))) {
val run = network(order)
val second = order.last().first
assertTrue(run.getValue(second).any { "airs elsewhere then" in it.why })
}
assertTrue(SchedulerReport(1, 1, emptyList(), 0, 0, 0, 0, true, 0, clashes = 3).logLine(1).endsWith("clashes=3"))
}
}
+23
View File
@@ -0,0 +1,23 @@
# Orbit TV 0.99.2: permanent film-clash regression checks
This is the third isolated editorial correction (clash checks), from the proposal of 4 November 2026. It adds checks only. No scheduling rule changes.
## What's in it
- **NetworkClashTest** (runs with every build). Channels top up in turn every six hours for four weeks, each avoiding the others' plans, as the box does:
- Two film channels sharing ten films, with small pools that want them often, never screen one together.
- A show shared between an equal-turns channel and a weighted channel never airs on both at once, and still airs on each.
- When a clash can't be avoided (one show, two channels), the booking says so in its reason, on either rule.
- **The real-pool network replay** (EditorialCorrectionsAuditTest, hand-run on copies of the box's files) now fails if any new booking is on another channel at the same time.
- **On the box:** each top-up's log line now ends with `clashes=N`, counting how many of the programmes just added are on another channel at that moment. It's computed from the same plans the scheduler avoided, so it should always read 0. It's counts only, with no titles.
- **Weighted rotation:** when every show in a block airs elsewhere at that moment, the booking's reason now says "fallback: every show here airs elsewhere then", as equal turns and films already did. The choice itself is unchanged.
## Checks before install
- **405 unit tests pass**, including the hand-run replays on box copies from 4 November 00:40. Lint has no errors.
- **Real-pool network replay**, all channels together for four weeks: 0 new bookings clash. The only overlap is Shaun of the Dead on 7 November (BritX 14:19, After Dark 14:43). Both are committed bookings made before film channels avoided each other, and they stand under the never-rewrite rule. The coordinated cutover can drop one of them.
- **Fingerprints:** a four-week replay of all 30 channels is identical to 0.99.1.
## Rollback
Install 0.99.1. No saved data changes.