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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String> {
|
||||
fun inspectSeries(title: String, failed: Set<String> = emptySet()): List<String> {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String, Any>) = 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]"))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user