0.108.4: Stage D evidence reader (read only, resumable), built on 0.108.3, not installed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user