From 0780e15dc21e99466fdfc4be6df53eebc2674cd8 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Wed, 4 Nov 2026 14:34:15 +0000 Subject: [PATCH] 0.99.14: read-only source diagnostics for the series inspection (Beerus check) Per-source codec, profile, resolution, audio and subtitle tracks, decoder verdict with reasons, completeness; safe labels only, nothing sensitive logged. Scheduling untouched: all 30 booking fingerprints identical to 0.99.13. Co-Authored-By: Claude Opus 5.5 (1M context) --- android/app/build.gradle.kts | 4 +- .../main/java/tv/orbit/classic/BoxDecoders.kt | 2 + .../java/tv/orbit/classic/ChannelScheduler.kt | 2 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyPlaybackProtocol.kt | 22 ++++--- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../tv/orbit/classic/SourceDiagnostics.kt | 58 +++++++++++++++++ .../tv/orbit/classic/SourceDiagnosticsTest.kt | 53 ++++++++++++++++ docs/VERIFICATION-0.99.14.md | 50 +++++++++++++++ docs/replays/replay-fingerprints-0.99.14.txt | 62 +++++++++++++++++++ 10 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/SourceDiagnostics.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/SourceDiagnosticsTest.kt create mode 100644 docs/VERIFICATION-0.99.14.md create mode 100644 docs/replays/replay-fingerprints-0.99.14.txt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f87cd5f..6c96ac9 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 = 143 - versionName = "0.99.13-story-grouping" + versionCode = 144 + versionName = "0.99.14-source-diagnostics" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/BoxDecoders.kt b/android/app/src/main/java/tv/orbit/classic/BoxDecoders.kt index fcfefce..7666f7b 100644 --- a/android/app/src/main/java/tv/orbit/classic/BoxDecoders.kt +++ b/android/app/src/main/java/tv/orbit/classic/BoxDecoders.kt @@ -37,4 +37,6 @@ internal object BoxDecoders { MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.filterNot { it.isEncoder }.flatMap { it.supportedTypes.asList() }.map { it.lowercase(java.util.Locale.ROOT) }.toSet() } fun canDecode(codec: String): Boolean = videoMimeFor(codec)?.let { it in types } ?: true + /** Whether the box has a decoder for this exact type, for diagnostics. */ + fun has(mime: String): Boolean = mime.lowercase(java.util.Locale.ROOT) in types } 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 69f7246..3558623 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -90,7 +90,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl } fun topUp(session: EmbySampleSession, userId: String, programming: ChannelProgramming, loop: SampleChannel, checkActive: () -> Unit = {}): SchedulerReport { - SeriesInspection.take()?.let { title -> runCatching { session.inspectSeries(title) }.getOrElse { listOf("inspect failed: ${it.javaClass.simpleName} ${(it as? EmbyCheckException)?.httpStatus} ${it.message?.take(160)}") }.forEach { Log.i("OrbitSchedule", it) } } + SeriesInspection.take()?.let { title -> runCatching { session.inspectSeries(title, store.loadFailed(userId)) }.getOrElse { listOf("inspect failed: ${it.javaClass.simpleName} ${(it as? EmbyCheckException)?.httpStatus} ${it.message?.take(160)}") }.forEach { Log.i("OrbitSchedule", it) } } val number = programming.number val now = clock() val cached = store.loadPool(userId, number) 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 5435845..ae504ca 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.13\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.99.14\"") 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/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index 8d8359e..364dd33 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -191,7 +191,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v * Read-only: what the library holds for one series, episode by episode, for checking how a show is stored before * scheduling it. Names, numbers, runtimes, sources and chapter names; no file paths. */ - fun inspectSeries(title: String): List { + fun inspectSeries(title: String, failed: Set = emptySet()): List { val match = searchSeries(transport, userId, ::currentToken, title).candidates.filter { seriesKey(it.title) == seriesKey(title) } if (match.isEmpty()) return listOf("inspect \"$title\": no series of that name") return match.flatMap { series -> @@ -200,13 +200,19 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v EmbyClient.expectSuccess(reply.status, false) val body = EmbyClient.parse(reply.body) val rows = body.optJSONArray("Items") ?: return@flatMap listOf("inspect series=${series.id}: no episode list") - listOf("inspect series=${series.id} year=${series.year} episodes=${body.optInt("TotalRecordCount", rows.length())}") + (0 until rows.length()).mapNotNull { rows.optJSONObject(it) }.map { e -> - val sources = e.optJSONArray("MediaSources") - val chapters = e.optJSONArray("Chapters") - val chapterNames = (0 until minOf(chapters?.length() ?: 0, 12)).joinToString("|") { EmbyClient.clean(chapters!!.optJSONObject(it)?.optString("Name").orEmpty(), 30) } - "inspect S${e.optInt("ParentIndexNumber", -1)}E${e.optInt("IndexNumber", -1)} type=${e.optString("Type")} min=${e.optLong("RunTimeTicks", 0) / 600_000_000} sources=${sources?.length() ?: 0}" + - " containers=${(0 until (sources?.length() ?: 0)).joinToString(",") { sources!!.optJSONObject(it)?.optString("Container").orEmpty() }} chapters=${chapters?.length() ?: 0} [$chapterNames] name=\"${EmbyClient.clean(e.optString("Name"), 80)}\"" - } + val episodes = (0 until rows.length()).mapNotNull { rows.optJSONObject(it) } + val playable = episodes.count { playableSource(it, BoxDecoders::canDecode) != null && it.optString("Id") !in failed } + val numbers = episodes.filter { it.optInt("ParentIndexNumber", -1) == 1 }.map { it.optInt("IndexNumber", -1) }.filter { it > 0 }.sorted() + val missing = numbers.lastOrNull()?.let { last -> (1..last).filter { it !in numbers } }.orEmpty() + listOf("inspect series=${series.id} year=${series.year} episodes=${body.optInt("TotalRecordCount", episodes.size)} playable=$playable season1=${numbers.firstOrNull() ?: 0}-${numbers.lastOrNull() ?: 0} missing=${missing.ifEmpty { listOf("none") }.joinToString(",")}") + + episodes.flatMap { e -> + val sources = e.optJSONArray("MediaSources") + val chapters = e.optJSONArray("Chapters") + val chapterNames = (0 until minOf(chapters?.length() ?: 0, 12)).joinToString("|") { EmbyClient.clean(chapters!!.optJSONObject(it)?.optString("Name").orEmpty(), 30) } + listOf("inspect S${e.optInt("ParentIndexNumber", -1)}E${e.optInt("IndexNumber", -1)} id=${e.optString("Id").takeIf(EmbyClient::validId) ?: "invalid"} type=${e.optString("Type")} location=${safeContainerLabel(e.optString("LocationType"))} min=${e.optLong("RunTimeTicks", 0) / 600_000_000} sources=${sources?.length() ?: 0}" + + " containers=${(0 until (sources?.length() ?: 0)).joinToString(",") { safeContainerLabel(sources!!.optJSONObject(it)?.optString("Container").orEmpty()) }} chapters=${chapters?.length() ?: 0} [$chapterNames] name=\"${EmbyClient.clean(e.optString("Name"), 80)}\"") + + (0 until minOf(sources?.length() ?: 0, 8)).mapNotNull { sources!!.optJSONObject(it) }.map { describeSource(it, e.optLong("RunTimeTicks", 0), BoxDecoders::canDecode, BoxDecoders::has, e.optString("Id") in failed) } + } } } 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 0c6b4b6..1ebffd7 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.13") + next.setRequestProperty("User-Agent", "OrbitTV/0.99.14") 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/main/java/tv/orbit/classic/SourceDiagnostics.kt b/android/app/src/main/java/tv/orbit/classic/SourceDiagnostics.kt new file mode 100644 index 0000000..eb47e2b --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/SourceDiagnostics.kt @@ -0,0 +1,58 @@ +package tv.orbit.classic + +import org.json.JSONObject + +/** + * One line describing a media source for the read-only inspection ([SeriesInspection]), approved by Paul on 4 November 2026 + * for checking Dragon Ball Super: Beerus before it's programmed. Diagnostics only: nothing here is used for scheduling, + * playback or presentation. Only short format labels, sizes, counts and language codes reach the log, never a path, URL, + * token or file name. The verdict is the one [playableSource] reaches, with the reasons spelled out; the audio decoder + * check is information only, as the app doesn't reject on it. + */ +internal fun describeSource(source: JSONObject, episodeTicks: Long, canDecode: (String) -> Boolean, hasDecoder: (String) -> Boolean, failedBefore: Boolean): String { + val streams = source.optJSONArray("MediaStreams")?.let { s -> (0 until minOf(s.length(), 32)).mapNotNull { s.optJSONObject(it) } }.orEmpty() + val container = safeContainerLabel(source.optString("Container")) + val id = source.optString("Id").takeIf(EmbyClient::validId) + val video = streams.firstOrNull { it.optString("Type") == "Video" } + val videoCodec = video?.optString("Codec")?.let(::safeContainerLabel) + val audio = streams.filter { it.optString("Type") == "Audio" } + val subtitles = streams.filter { it.optString("Type") == "Subtitle" } + val ticks = source.optLong("RunTimeTicks", 0).takeIf { it > 0 } ?: episodeTicks + val durationMs = ticks / 10_000 + val reasons = buildList { + if (supportedDirectContainer(source.optString("Container")) == null) add("container $container not played directly") + if (id == null) add("no usable source id") + if (videoCodec != null && !canDecode(videoCodec)) add("no $videoCodec decoder on this box") + if (durationMs !in 1_000..SampleChannel.MAX_DURATION_MS) add("runtime unknown or too long") + if (failedBefore) add("failed to play here before") + } + fun audioLabel(s: JSONObject) = listOfNotNull(safeContainerLabel(s.optString("Codec")), language(s), s.optInt("Channels", 0).takeIf { it > 0 }?.let { "${it}ch" }, + "default".takeIf { s.optBoolean("IsDefault") }, "no-decoder".takeIf { audioMimeFor(s.optString("Codec"))?.let(hasDecoder) == false }).joinToString(" ") + fun subtitleLabel(s: JSONObject) = listOfNotNull(safeContainerLabel(s.optString("Codec")), language(s), "external".takeIf { s.optBoolean("IsExternal") }, + "forced".takeIf { s.optBoolean("IsForced") }, "default".takeIf { s.optBoolean("IsDefault") }).joinToString(" ") + return "inspect source=${id ?: "invalid"} container=$container min=${durationMs / 60_000}" + + " video=${videoCodec ?: "none"}" + (video?.let { v -> " profile=${safeProfile(v.optString("Profile"))} ${v.optInt("Width")}x${v.optInt("Height")}" + + (v.optInt("BitDepth", 0).takeIf { it > 0 }?.let { " ${it}bit" } ?: "") + " decoder=${if (videoCodec?.let(canDecode) != false) "yes" else "no"}" } ?: "") + + " audio=${audio.size}[${audio.joinToString(", ", transform = ::audioLabel)}] english=${if (hasEnglishAudio(source)) "yes" else "no"}" + + " subs=${subtitles.size}[${subtitles.joinToString(", ", transform = ::subtitleLabel)}]" + + " verdict=" + if (reasons.isEmpty()) "playable" else "rejected(${reasons.joinToString("; ")})" +} + +private fun language(stream: JSONObject) = stream.optString("Language").trim().lowercase(java.util.Locale.ROOT).takeIf { it.length in 2..3 && it.all(Char::isLetter) } ?: "und" + +/** A codec profile such as "High" or "Main 10": letters, digits and spaces only, short. */ +private fun safeProfile(value: String) = value.trim().takeIf { it.length in 1..24 && it.all { ch -> ch.isLetterOrDigit() || ch == ' ' } }?.replace(' ', '_') ?: "unknown" + +/** What Emby calls a sound format, as the box's decoders name it, for the diagnostic only. Passthrough formats may play without one. */ +internal fun audioMimeFor(codec: String): String? = when (codec.trim().lowercase(java.util.Locale.ROOT)) { + "aac" -> "audio/mp4a-latm" + "ac3" -> "audio/ac3" + "eac3" -> "audio/eac3" + "dts", "dca" -> "audio/vnd.dts" + "truehd" -> "audio/true-hd" + "mp3" -> "audio/mpeg" + "opus" -> "audio/opus" + "vorbis" -> "audio/vorbis" + "flac" -> "audio/flac" + else -> null +} diff --git a/android/app/src/test/java/tv/orbit/classic/SourceDiagnosticsTest.kt b/android/app/src/test/java/tv/orbit/classic/SourceDiagnosticsTest.kt new file mode 100644 index 0000000..fda89d2 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/SourceDiagnosticsTest.kt @@ -0,0 +1,53 @@ +package tv.orbit.classic + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SourceDiagnosticsTest { + private val minute = 600_000_000L + private fun stream(vararg pairs: Pair) = JSONObject(pairs.toMap()) + private fun source(container: String, id: String = "8016760", ticks: Long = 23 * minute, vararg streams: JSONObject) = JSONObject(mapOf( + "Id" to id, "Container" to container, "RunTimeTicks" to ticks, "Path" to "/mnt/media/anime/Dragon Ball Super Beerus - S01E01.mkv", + "DirectStreamUrl" to "/videos/8016760/stream.mkv?api_key=SECRET", "MediaStreams" to JSONArray(streams.toList()))) + private val h264 = stream("Type" to "Video", "Codec" to "h264", "Profile" to "High", "Width" to 1920, "Height" to 1080, "BitDepth" to 8, "Title" to "1080p from /mnt/media") + private val jaAac = stream("Type" to "Audio", "Codec" to "aac", "Language" to "jpn", "Channels" to 2, "IsDefault" to true, "DisplayTitle" to "Japanese AAC") + private val enEac3 = stream("Type" to "Audio", "Codec" to "eac3", "Language" to "eng", "Channels" to 6) + private val enAss = stream("Type" to "Subtitle", "Codec" to "ass", "Language" to "eng", "IsDefault" to true, "DeliveryUrl" to "/Videos/1/Subtitles/2/Stream.ass?api_key=SECRET") + private val srtExternal = stream("Type" to "Subtitle", "Codec" to "srt", "Language" to "por", "IsExternal" to true, "Path" to "/mnt/media/subs.srt") + + private fun describe(s: JSONObject, canDecode: (String) -> Boolean = { true }, has: (String) -> Boolean = { true }, failed: Boolean = false) = describeSource(s, 0, canDecode, has, failed) + + @Test fun aPlayableSourceIsDescribedInFull() { + val line = describe(source("mkv", streams = *arrayOf(h264, jaAac, enEac3, enAss, srtExternal)), has = { it != "audio/eac3" }) + assertEquals("inspect source=8016760 container=mkv min=23 video=h264 profile=High 1920x1080 8bit decoder=yes " + + "audio=2[aac jpn 2ch default, eac3 eng 6ch no-decoder] english=yes subs=2[ass eng default, srt por external] verdict=playable", line) + } + + @Test fun rejectionsGiveTheirReasons() { + val line = describe(source("avi", id = "bad/id", ticks = 0, h264), canDecode = { it != "h264" }, failed = true) + assertTrue(line, line.contains("decoder=no")) + assertTrue(line, line.endsWith("verdict=rejected(container avi not played directly; no usable source id; no h264 decoder on this box; runtime unknown or too long; failed to play here before)")) + } + + @Test fun theVerdictMatchesWhatTheSchedulerAccepts() { + val cases = listOf(source("mkv", streams = *arrayOf(h264, jaAac)), source("avi", streams = *arrayOf(h264)), source("mkv", ticks = 0, streams = *arrayOf(h264)), + source("mp4", streams = *arrayOf(stream("Type" to "Video", "Codec" to "vc1")))) + val canDecode = { codec: String -> codec != "vc1" } + for (s in cases) { + val row = JSONObject(mapOf("MediaSources" to JSONArray(listOf(s)))) + assertEquals(s.toString(), playableSource(row, canDecode) != null, describe(s, canDecode).endsWith("verdict=playable")) + } + } + + @Test fun nothingSensitiveReachesTheLog() { + val hostile = stream("Type" to "Audio", "Codec" to "aac?api_key=SECRET", "Language" to "/mnt", "Profile" to "x") + val video = stream("Type" to "Video", "Codec" to "h264", "Profile" to "https://host/stream?api_key=SECRET") + val line = describe(source("mkv", streams = *arrayOf(video, hostile, enAss, srtExternal))) + for (secret in listOf("SECRET", "/mnt", "api_key", "http", "Beerus", "Stream.ass", "subs.srt", "1080p", "Japanese")) assertFalse("$secret in $line", line.contains(secret)) + assertTrue(line, line.contains("profile=unknown") && line.contains("audio=1[unknown und]")) + } +} diff --git a/docs/VERIFICATION-0.99.14.md b/docs/VERIFICATION-0.99.14.md new file mode 100644 index 0000000..692c383 --- /dev/null +++ b/docs/VERIFICATION-0.99.14.md @@ -0,0 +1,50 @@ +# Orbit TV 0.99.14: read-only source diagnostics (Beerus check) + +Paul approved building this on 4 November 2026 at 14:27. Installing it needs its own approval. The change is diagnostics only: it extends the existing hand-triggered `orbit_inspect_series` log, and nothing else. Scheduling, playback, presentation, pools and bookings are unchanged. + +## What's in it + +- **`describeSource`** (SourceDiagnostics.kt) writes one log line per media source of each inspected episode: + - **Source:** its id and container. + - **Runtime.** + - **Video:** codec, profile, resolution and bit depth, and whether the box has a decoder. + - **Audio:** every track, with codec, language, channels, the default flag, and a no-decoder flag where the box has no decoder for that type. + - **English sound:** yes or no. + - **Subtitles:** every track, with format, language and the external, forced and default flags. + - **Verdict:** playable, or rejected with each reason. The verdict uses the same tests the scheduler applies when it reads a pool (`playableSource`): + - a directly played container; + - a usable source id; + - a video decoder on the box; + - a runtime in range; + - plus the box's own record of files that failed before. +- **Per series**, the inspection now adds: + - the number of playable episodes; + - the season 1 episode range; + - any missing episode numbers; + - each episode's id and location type (a file, or a placeholder). +- **`BoxDecoders.has`:** reads the box's decoder list, for the audio flags. It's information only; the app doesn't reject on sound. +- **Safe by construction:** + - only short labels made of letters, digits, `-` and `_`, sizes, counts and 2- or 3-letter language codes are written; + - no path, URL, token, track title or file name is logged; + - anything else becomes "unknown" or "und". + - As before, the hook runs only when started by hand with the intent extra, once. +- **Version:** 0.99.14 (versionCode 144). + +## Checks before install + +- **440 unit tests pass** (2 skipped: hand-run replays that need input files). Lint has no errors. +- **Four new tests in SourceDiagnosticsTest:** + - A playable source is described in full. + - Each rejection reason is given. + - The verdict matches what the scheduler accepts, on playable, wrong-container, unknown-runtime and no-decoder sources. + - Nothing sensitive reaches the line. The test feeds it paths, stream URLs, `api_key` values, track titles and hostile codec, language and profile values. +- **Booking fingerprints:** the four-week replay of all 30 channels on 0.99.14 is identical to 0.99.13 on every channel, committed and four weeks (docs/replays/replay-fingerprints-0.99.14.txt). +- **The Stage A Paradox replay** still passes. + +## Use + +With the TV idle, the box gets one launch with `--es orbit_inspect_series 'Dragon Ball Super: Beerus'`, quoting the whole `am start` command so the title isn't split. Both library copies are reported. Nothing is booked, added to Vortex, changed or transcoded. + +## Rollback + +Install 0.99.13. Nothing is saved by this change. diff --git a/docs/replays/replay-fingerprints-0.99.14.txt b/docs/replays/replay-fingerprints-0.99.14.txt new file mode 100644 index 0000000..7c9fa9d --- /dev/null +++ b/docs/replays/replay-fingerprints-0.99.14.txt @@ -0,0 +1,62 @@ +--- 0.99.13 +101 committed=ceabf650e4810201 kept=true fourWeeks=e18874a80beb3a55 programmes=2177 +102 committed=8c81144d3b36e833 kept=true fourWeeks=9709a1ca2f5535ea programmes=1322 +103 committed=7ffea57b7e1cb472 kept=true fourWeeks=647399c086fdb2e4 programmes=1822 +104 committed=e126e9ffa3955107 kept=true fourWeeks=c76da7a762869ce3 programmes=1735 +105 committed=6cade544765d2ea1 kept=true fourWeeks=018a087fb33a0c60 programmes=2557 +106 committed=0288c16eee5a2421 kept=true fourWeeks=9bd2fb23de4d7feb programmes=1150 +107 committed=760efbb2924e4202 kept=true fourWeeks=f5647f529049aab8 programmes=1011 +201 committed=8285c8e353c30297 kept=true fourWeeks=1432d42080236f60 programmes=962 +202 committed=e5d035e62d63daab kept=true fourWeeks=f4e034a1c71ac493 programmes=1132 +203 committed=eda55bfc0c2a0322 kept=true fourWeeks=92598db1029e57a1 programmes=1138 +204 committed=34d70788b01a7f2b kept=true fourWeeks=7075e49beb136fdb programmes=1262 +301 committed=c52f04f716ebb7e6 kept=true fourWeeks=be7f588cb8f45b0b programmes=362 +302 committed=c8682948ae9da547 kept=true fourWeeks=b52216995141a77c programmes=442 +303 committed=69f9f7a1b0a600f7 kept=true fourWeeks=b78067d5fe62f8d5 programmes=419 +304 committed=00ffa5e31b47af35 kept=true fourWeeks=80a6083310a0a39f programmes=493 +305 committed=20bcd7b52807a154 kept=true fourWeeks=560742628cc8bdf3 programmes=503 +306 committed=8f0834ad01578303 kept=true fourWeeks=72af90f39e52b7ec programmes=492 +307 committed=ad3946c4d730d332 kept=true fourWeeks=c148014574309a44 programmes=441 +308 committed=5cd79b96711429dc kept=true fourWeeks=3a1336770d917f73 programmes=514 +309 committed=4a0bd1974ae8edb3 kept=true fourWeeks=10c076d0783c3c78 programmes=498 +401 committed=af07a051503088f4 kept=true fourWeeks=0afb306085ac669e programmes=880 +601 committed=7b828830b81e9e13 kept=true fourWeeks=ea118968a94a8ba9 programmes=2203 +602 committed=ae97602ec57c90a7 kept=true fourWeeks=5a0bcc83098512df programmes=2337 +603 committed=8f07d376dc34ec46 kept=true fourWeeks=869d0c295e7e7995 programmes=2316 +604 committed=67f6f095d1ec3ba8 kept=true fourWeeks=71fca98d5f2d0f6f programmes=3253 +605 committed=63d770ea8815b616 kept=true fourWeeks=b337a4f18b1f2c35 programmes=4508 +611 committed=57b262c3da28ad26 kept=true fourWeeks=40412f13bc54bd40 programmes=2754 +612 committed=8a5ace31156c9d43 kept=true fourWeeks=1675c47b4acc635e programmes=579 +613 committed=d6164e2905c0082f kept=true fourWeeks=db55ae486c4998d1 programmes=521 +614 committed=5c0ab156234c7836 kept=true fourWeeks=4c6f6d2a686b66ba programmes=3844 +--- 0.99.14 +101 committed=ceabf650e4810201 kept=true fourWeeks=e18874a80beb3a55 programmes=2177 +102 committed=8c81144d3b36e833 kept=true fourWeeks=9709a1ca2f5535ea programmes=1322 +103 committed=7ffea57b7e1cb472 kept=true fourWeeks=647399c086fdb2e4 programmes=1822 +104 committed=e126e9ffa3955107 kept=true fourWeeks=c76da7a762869ce3 programmes=1735 +105 committed=6cade544765d2ea1 kept=true fourWeeks=018a087fb33a0c60 programmes=2557 +106 committed=0288c16eee5a2421 kept=true fourWeeks=9bd2fb23de4d7feb programmes=1150 +107 committed=760efbb2924e4202 kept=true fourWeeks=f5647f529049aab8 programmes=1011 +201 committed=8285c8e353c30297 kept=true fourWeeks=1432d42080236f60 programmes=962 +202 committed=e5d035e62d63daab kept=true fourWeeks=f4e034a1c71ac493 programmes=1132 +203 committed=eda55bfc0c2a0322 kept=true fourWeeks=92598db1029e57a1 programmes=1138 +204 committed=34d70788b01a7f2b kept=true fourWeeks=7075e49beb136fdb programmes=1262 +301 committed=c52f04f716ebb7e6 kept=true fourWeeks=be7f588cb8f45b0b programmes=362 +302 committed=c8682948ae9da547 kept=true fourWeeks=b52216995141a77c programmes=442 +303 committed=69f9f7a1b0a600f7 kept=true fourWeeks=b78067d5fe62f8d5 programmes=419 +304 committed=00ffa5e31b47af35 kept=true fourWeeks=80a6083310a0a39f programmes=493 +305 committed=20bcd7b52807a154 kept=true fourWeeks=560742628cc8bdf3 programmes=503 +306 committed=8f0834ad01578303 kept=true fourWeeks=72af90f39e52b7ec programmes=492 +307 committed=ad3946c4d730d332 kept=true fourWeeks=c148014574309a44 programmes=441 +308 committed=5cd79b96711429dc kept=true fourWeeks=3a1336770d917f73 programmes=514 +309 committed=4a0bd1974ae8edb3 kept=true fourWeeks=10c076d0783c3c78 programmes=498 +401 committed=af07a051503088f4 kept=true fourWeeks=0afb306085ac669e programmes=880 +601 committed=7b828830b81e9e13 kept=true fourWeeks=ea118968a94a8ba9 programmes=2203 +602 committed=ae97602ec57c90a7 kept=true fourWeeks=5a0bcc83098512df programmes=2337 +603 committed=8f07d376dc34ec46 kept=true fourWeeks=869d0c295e7e7995 programmes=2316 +604 committed=67f6f095d1ec3ba8 kept=true fourWeeks=71fca98d5f2d0f6f programmes=3253 +605 committed=63d770ea8815b616 kept=true fourWeeks=b337a4f18b1f2c35 programmes=4508 +611 committed=57b262c3da28ad26 kept=true fourWeeks=40412f13bc54bd40 programmes=2754 +612 committed=8a5ace31156c9d43 kept=true fourWeeks=1675c47b4acc635e programmes=579 +613 committed=d6164e2905c0082f kept=true fourWeeks=db55ae486c4998d1 programmes=521 +614 committed=5c0ab156234c7836 kept=true fourWeeks=4c6f6d2a686b66ba programmes=3844