diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a76d0c9..58ffd2b 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 = 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 } 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 b675c91..77cc854 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -365,7 +365,8 @@ internal fun extendWeighted(pool: List, 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, 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 { 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) diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 7951c2e..58f9f0d 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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, 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 } 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 cada3ec..61f4b5b 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.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) 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 121aaf3..73d8e11 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.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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/EditorialCorrectionsAuditTest.kt b/android/app/src/test/java/tv/orbit/classic/EditorialCorrectionsAuditTest.kt index d2a9536..96acf8c 100644 --- a/android/app/src/test/java/tv/orbit/classic/EditorialCorrectionsAuditTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/EditorialCorrectionsAuditTest.kt @@ -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) = "${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 } diff --git a/android/app/src/test/java/tv/orbit/classic/NetworkClashTest.kt b/android/app/src/test/java/tv/orbit/classic/NetworkClashTest.kt new file mode 100644 index 0000000..4e2672e --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/NetworkClashTest.kt @@ -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, ChannelProgramming>>): Map> { + val plans = HashMap() + val all = channels.associate { it.first to mutableListOf() } + 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>) = 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")) + } +} diff --git a/docs/VERIFICATION-0.99.2.md b/docs/VERIFICATION-0.99.2.md new file mode 100644 index 0000000..02a0a09 --- /dev/null +++ b/docs/VERIFICATION-0.99.2.md @@ -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.