0.108.4: Stage D evidence reader (read only, resumable), built on 0.108.3, not installed
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m7s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m25s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-04 23:06:11 +00:00
co-authored by Claude Opus 5.5
parent 2988ad245b
commit c8c976aa9d
8 changed files with 142 additions and 0 deletions
@@ -70,6 +70,28 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
Log.i("OrbitSchedule", "arrivals pages=${run.pages} read=${found.size} new=${arrivals.size} baseline=$baseline")
}
/**
* Stage D evidence, when asked for from adb: every film and series on a channel's list and every library title premiered in
* the last 60 days, read in batches and appended to the evidence file, skipping any already read. Read only.
*/
fun readEvidence(session: EmbySampleSession, userId: String, catalogue: CatalogueStore) {
if (!EvidenceRequest.take()) return
val today = java.time.LocalDate.now()
val ids = SmartChannels.flatMap { store.loadPool(userId, it.number)?.series.orEmpty().map { s -> s.id } } +
catalogue.releasedBetween(today.minusDays(NEWLY_RELEASED_DAYS).toString(), today.toString(), 2_000).map { it.id }
val file = store.evidenceFile()
val missing = evidenceMissing(file, ids).take(EVIDENCE_MAX_PER_RUN)
var read = 0; var failed = 0
for (batch in missing.chunked(EVIDENCE_BATCH)) {
try {
val lines = session.readEvidence(batch)
file.appendText(lines.joinToString("") { it.toString() + "\n" })
read += lines.size
} catch (failure: EmbyCheckException) { failed++; if (failed > 3) break }
}
Log.i("OrbitSchedule", "evidence asked=${ids.distinct().size} missing=${missing.size} read=$read failedBatches=$failed")
}
fun topUpDriveIn(userId: String, session: EmbySampleSession? = null): ChannelPlan? {
val now = clock()
val existing = store.loadPlan(userId, DRIVE_IN_NUMBER)
@@ -269,6 +269,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* Tried again next hour. */ }
// Stage C's light daily library refresh, in the background. A failure changes nothing and is tried next hour.
try { withContext(Dispatchers.IO) { scheduler.readEvidence(active, active.accountId, catalogue) } }
catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { android.util.Log.w("OrbitSchedule", "evidence failed=${failure.javaClass.simpleName}") }
try { withContext(Dispatchers.IO) { scheduler.refreshArrivals(active, active.accountId, catalogue) } }
catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { android.util.Log.w("OrbitSchedule", "arrivals failed=${failure.javaClass.simpleName}") }
@@ -244,6 +244,17 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
return films.associate { (film, person) -> film.itemId to checkEventFilm(film, byId[film.itemId], person, BoxDecoders::canDecode) }
}
/** Stage D evidence for [ids], read only, in one request: see [evidenceLine]. */
fun readEvidence(ids: List<String>): List<JSONObject> {
val valid = ids.filter(EmbyClient::validId)
if (valid.isEmpty()) return emptyList()
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Ids=${valid.joinToString(",")}&Fields=Overview,Tags,Genres,Studios,People,ProviderIds,ProductionYear,OfficialRating,CommunityRating,Taglines,ProductionLocations" +
"&EnableImages=false&EnableUserData=false", token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS))
EmbyClient.expectSuccess(reply.status, false)
val rows = EmbyClient.parse(reply.body).optJSONArray("Items")
return (0 until (rows?.length() ?: 0)).mapNotNull { rows!!.optJSONObject(it)?.let(::evidenceLine) }
}
internal class PoolRead(val series: List<PoolSeries>, val requests: Int,
/** Shows whose read failed this time and kept their copy from the previous pool instead. */
val carried: Int = 0)
@@ -0,0 +1,48 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Stage D, phase 1 (approved by Paul on 4 November 2026): read-only evidence for channel fit. For each library item asked for,
* its description, tags, genres, studios, leading cast and directors, ratings and provider IDs, read from Emby in small batches
* and kept as one JSON line each in evidence-0.jsonl beside the schedules. Resumable: an item already read is skipped, so a
* long read can stop and carry on. Nothing in the library or any schedule changes; the log carries counts only.
* Missing fields are kept as missing, never as negative evidence.
*/
internal object EvidenceRequest {
/** Set from adb with --ez orbit_read_evidence true; read once by the next background round. */
@Volatile var pending = false
fun take(): Boolean = pending.also { pending = false }
}
internal const val EVIDENCE_BATCH = 40
internal const val EVIDENCE_MAX_PER_RUN = 4_000
/** One item's evidence as kept: only what the provider gave, trimmed, with people limited to leads and directors. */
internal fun evidenceLine(row: JSONObject): JSONObject? {
val id = row.optString("Id").takeIf(EmbyClient::validId) ?: return null
fun strings(key: String, limit: Int) = row.optJSONArray(key)?.let { a -> (0 until minOf(a.length(), limit)).map { EmbyClient.clean(a.optString(it), 60) }.filter { it.isNotBlank() } }
val people = row.optJSONArray("People")?.let { a -> (0 until a.length()).mapNotNull { a.optJSONObject(it) } }.orEmpty()
val leads = people.filter { it.optString("Type") in setOf("Actor", "GuestStar") }.take(6).map { EmbyClient.clean(it.optString("Name"), 50) }
val directors = people.filter { it.optString("Type") == "Director" }.take(3).map { EmbyClient.clean(it.optString("Name"), 50) }
return JSONObject().put("id", id).put("type", row.optString("Type")).put("name", EmbyClient.clean(row.optString("Name"), 160))
.put("year", row.optInt("ProductionYear", 0).takeIf { it > 0 } ?: JSONObject.NULL)
.put("overview", row.optString("Overview").takeIf { it.isNotBlank() }?.let { EmbyClient.clean(it, 1_200) } ?: JSONObject.NULL)
.put("tags", strings("Tags", 30)?.let(::JSONArray) ?: JSONObject.NULL)
.put("genres", strings("Genres", 12)?.let(::JSONArray) ?: JSONObject.NULL)
.put("studios", row.optJSONArray("Studios")?.let { a -> JSONArray((0 until minOf(a.length(), 6)).map { EmbyClient.clean(a.optJSONObject(it)?.optString("Name").orEmpty(), 60) }) } ?: JSONObject.NULL)
.put("leads", if (people.isEmpty()) JSONObject.NULL else JSONArray(leads)).put("directors", if (people.isEmpty()) JSONObject.NULL else JSONArray(directors))
.put("rating", row.optString("OfficialRating").takeIf { it.isNotBlank() } ?: JSONObject.NULL)
.put("community", row.optDouble("CommunityRating").takeIf { !it.isNaN() } ?: JSONObject.NULL)
.put("taglines", strings("Taglines", 2)?.let(::JSONArray) ?: JSONObject.NULL)
.put("providerIds", row.optJSONObject("ProviderIds") ?: JSONObject.NULL)
.put("countries", strings("ProductionLocations", 4)?.let(::JSONArray) ?: JSONObject.NULL)
}
/** Which of [ids] the evidence file doesn't have yet. */
internal fun evidenceMissing(file: File, ids: List<String>): List<String> {
val have = if (file.exists()) file.useLines { lines -> lines.mapNotNull { runCatching { JSONObject(it).optString("id") }.getOrNull() }.toHashSet() } else emptySet()
return ids.distinct().filter { it !in have }
}
@@ -52,6 +52,7 @@ class MainActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (intent?.getBooleanExtra("orbit_intro_test", false) == true) IntroTrial.nextTune = true
intent?.getStringExtra("orbit_inspect_series")?.take(120)?.let { SeriesInspection.title = it }
if (intent?.getBooleanExtra("orbit_read_evidence", false) == true) EvidenceRequest.pending = true
// The hand-run playback test opens over Orbit, which stops in the background as it would for any other app, and comes back when it ends.
intent?.getStringExtra("orbit_play_test")?.takeIf(EmbyClient::validId)?.let { item ->
startActivity(android.content.Intent(this, PlaybackTestActivity::class.java).putExtra(PlaybackTestActivity.ITEM, item))
@@ -48,6 +48,8 @@ internal class ScheduleStore(private val directory: File) {
internal class SavedPool(val series: List<PoolSeries>, val fetchedAtMs: Long, val signature: String = "")
private fun file(kind: String, number: Int) = File(directory, "$kind-$number.json")
/** Stage D's evidence lines ([evidenceLine]), one item a line. */
fun evidenceFile() = File(directory, "evidence-0.jsonl")
/** Replaces in one step, keeping the last good copy. An interrupted write can't damage either. */
private fun write(target: File, root: JSONObject) {
@@ -0,0 +1,38 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.io.File
/** Stage D's evidence: only what the provider gave, missing kept as missing, and a read that can carry on where it stopped. */
class EvidenceReaderTest {
private fun row() = JSONObject().put("Id", "8070341").put("Type", "Series").put("Name", "Blue Planet III").put("ProductionYear", 2026)
.put("Overview", "The oceans, from the deep sea to the coral reefs.").put("Tags", JSONArray(listOf("ocean", "nature")))
.put("Genres", JSONArray(listOf("Documentary"))).put("Studios", JSONArray().put(JSONObject().put("Name", "BBC Studios")))
.put("People", JSONArray().put(JSONObject().put("Name", "David Attenborough").put("Type", "GuestStar")).put(JSONObject().put("Name", "A Producer").put("Type", "Producer")))
.put("ProviderIds", JSONObject().put("Tmdb", "1"))
@Test fun keepsWhatTheProviderGave() {
val line = evidenceLine(row())!!
assertEquals("Blue Planet III", line.getString("name"))
assertEquals("ocean", line.getJSONArray("tags").getString(0))
assertEquals("David Attenborough", line.getJSONArray("leads").getString(0))
assertEquals(0, line.getJSONArray("directors").length())
assertEquals("BBC Studios", line.getJSONArray("studios").getString(0))
}
@Test fun missingIsMissingNotNegative() {
val bare = evidenceLine(JSONObject().put("Id", "123").put("Type", "Movie").put("Name", "Something"))!!
for (key in listOf("overview", "tags", "genres", "leads", "directors", "rating", "year")) assertTrue(key, bare.isNull(key))
assertNull(evidenceLine(JSONObject().put("Id", "../bad")))
}
@Test fun aReadCarriesOnWhereItStopped() {
val file = File.createTempFile("evidence", ".jsonl").apply { deleteOnExit() }
file.writeText(evidenceLine(row()).toString() + "\n")
assertEquals(listOf("2", "3"), evidenceMissing(file, listOf("8070341", "2", "3", "2")))
assertEquals(listOf("1"), evidenceMissing(File(file.parent, "none-${System.nanoTime()}.jsonl"), listOf("1")))
}
}
+17
View File
@@ -0,0 +1,17 @@
# 0.108.4: Stage D evidence reader (phase 1, read only), built on the installed 0.108.3
Paul approved phase 1 on 4 November 2026 at 23:01. This is 0.108.3 plus one diagnostic capability. It changes no schedule, pool or library item. The same change is on main.
**What it does:**
- **Trigger:** `am start ... --ez orbit_read_evidence true`. The next background round reads Emby for every film and series on any channel's list, plus every library title premiered in the last 60 days.
- **Fields:** each item's description, tags, genres, studios, up to 6 leads, up to 3 directors, official and community ratings, taglines, production countries and provider IDs.
- **How it reads:** 40 items per request, at most 4,000 items a run.
- **Where it goes:** each item is appended as one JSON line to `no_backup/schedule/evidence-0.jsonl`. An item already there is skipped, so a run can stop and carry on.
- **Logging:** one line of counts only: asked, missing, read, failed batches.
- **Missing isn't negative:** a field the provider doesn't have is stored as missing (null), never as an empty value that would read as negative evidence.
## Checks
- 489 unit tests run, 0 failures; lint has no errors. `EvidenceReaderTest` adds 3 tests: it keeps what the provider gave, missing stays missing, and a read carries on where it stopped.
APK: `outputs/revision-0.108.4`, signed, versionCode 164. **Not installed.** It goes on in the background with Paul's OK, and is then run once with Orbit idle.