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 c20b129d..eac128cb 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index e048c551..9bcc4891 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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}") } 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 46624c3c..30c0324a 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -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): List { + 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, val requests: Int, /** Shows whose read failed this time and kept their copy from the previous pool instead. */ val carried: Int = 0) diff --git a/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt b/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt new file mode 100644 index 00000000..f31a9caf --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt @@ -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): List { + 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 } +} diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index 90a6aa79..d5340f1f 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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)) diff --git a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt index f827382a..4552dac5 100644 --- a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt @@ -48,6 +48,8 @@ internal class ScheduleStore(private val directory: File) { internal class SavedPool(val series: List, 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) { diff --git a/android/app/src/test/java/tv/orbit/classic/EvidenceReaderTest.kt b/android/app/src/test/java/tv/orbit/classic/EvidenceReaderTest.kt new file mode 100644 index 00000000..f58beacd --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/EvidenceReaderTest.kt @@ -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"))) + } +} diff --git a/docs/VERIFICATION-0.108.4.md b/docs/VERIFICATION-0.108.4.md new file mode 100644 index 00000000..5f37fcc2 --- /dev/null +++ b/docs/VERIFICATION-0.108.4.md @@ -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.