Add read-only metadata coverage check (0.17.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m51s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 45s

Counts how many sampled films, series and episodes carry each metadata
field, so the catalogue and preset design rests on measured data. Three
bounded reads on demand; counts only, nothing saved, and channels and
schedules are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 18:01:34 +00:00
co-authored by Claude Opus 5.5
parent a90d6f7204
commit 335bb2a754
9 changed files with 234 additions and 4 deletions
+15
View File
@@ -0,0 +1,15 @@
# Orbit TV 0.17.0 — metadata coverage check
Approved scope: the read-only coverage check in docs/roadmap.md. Includes the 0.16 tuning screen.
Flow: Menu → Connect Emby → Check metadata coverage → Check metadata coverage. One press sends three GET /Items requests (Movie, Series, Episode), each Limit=20, SortBy=Random, Fields=Genres,Overview,People,ProviderIds,Studios, images and user data disabled. At most 20 records per type are read, and foreign types, unsafe IDs and duplicates aren't counted. The screen shows per-type counts for genres, overview, year, content rating, external IDs, studios, cast and crew, air or release date, original title, tags, runtime, series name, season and episode number. A 15-second cooldown applies between runs, and a rate-limit, expired-login or permission reply stops the check.
Nothing is saved: results sit in screen state outside RememberedState, hold counts only, and are dropped when the session closes. Channels, schedules, saved setup and the playback allowlist are untouched. Saved data format is unchanged from 0.16.
Limits: Fields lists only options in Emby's documented getItems reference, so PremiereDate, OriginalTitle and tags are counted only if the provider returns them by default. A zero for those may mean not returned, not absent. Whether the provider honours SortBy=Random is unverified. Results are a sample, not a library total.
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 96 unit tests (87 existing, 9 new), zero failures or errors, no lint errors. New fixture tests cover request bounds and token placement, per-type counts, malformed fields, a server ignoring the limit, foreign and duplicate records, no library values in results, rate-limit and login stops, cancellation, and the session sample and playback allowlist staying unchanged. No private account, catalogue, media or device data was read by tools. Remote focus, screen fit and real provider results need the Xiaomi test.
Rollback: reinstall outputs/revision-0.16.0, or revert this commit.
Reference: https://dev.emby.media/reference/RestAPI/ItemsService/getItems.html
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 16
versionName = "0.16.0-tuning-screen"
versionCode = 17
versionName = "0.17.0-metadata-check"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -41,7 +41,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
connection.connectTimeout = 10_000
connection.readTimeout = 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.16.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.17.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -73,6 +73,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var darkMovies by remember { mutableStateOf<List<AfterDarkMovie>>(emptyList()) }
var approvedDark by remember { mutableStateOf<Set<String>>(emptySet()) }
var reviewingDark by remember { mutableStateOf(false) }
// Deliberately outside the saved setup: the coverage check changes nothing on this device.
var metadataCoverage by remember { mutableStateOf<List<MetadataCoverage>?>(null) }
var reviewingCoverage by remember { mutableStateOf(false) }
var afterDarkChannel by remember { mutableStateOf<SampleChannel?>(null) }
var toonamiChannel by remember { mutableStateOf<SampleChannel?>(null) }
var selectedNumber by remember { mutableIntStateOf(101) }
@@ -112,6 +115,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
seriesMatches = null; episodeSamples = null; reviewingSeries = false; approvedSeries = emptySet()
darkMovies = emptyList(); approvedDark = emptySet(); reviewingDark = false; afterDarkChannel = null
metadataCoverage = null; reviewingCoverage = false
playback = null; session = null; sample = null; channel = null; toonamiChannel = null; selectedNumber = 101; watchingChannel = false
onChannelAvailable(false)
sessionSlot.clear()?.let { active -> EmbyCleanup.run { active.close() } }
@@ -216,6 +220,27 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
return
}
if (!visible) return
if (reviewingCoverage) {
MetadataCoverageScreen(metadataCoverage, busy, !busy && session != null && now >= nextAttemptAt, message, modifier, onRun = {
val active = session
if (!busy && active != null && System.currentTimeMillis() >= nextAttemptAt) {
busy = true; message = null
nextAttemptAt = System.currentTimeMillis() + 15_000
job = scope.launch {
try {
metadataCoverage = withContext(Dispatchers.IO) {
val context = coroutineContext
active.checkMetadataCoverage { context.ensureActive() }
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "The metadata check could not finish. Try again later." }
finally { busy = false }
}
}
}, onBack = { job?.cancel(); busy = false; reviewingCoverage = false })
return
}
if (reviewingDark) {
AfterDarkReviewScreen(darkMovies, approvedDark, busy, message, modifier,
onGenre = { genre ->
@@ -436,6 +461,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = {
if (seriesMatches != null) reviewingSeries = true else checkMoreSeries()
})
OrbitButton("Check metadata coverage", Modifier.fillMaxWidth(),
enabled = !busy && session != null, onClick = { message = null; reviewingCoverage = true })
Spacer(Modifier.height(8.dp))
if (result.items.isEmpty()) Text("No movies or episodes were returned for this account.", color = Color.White, fontSize = 17.sp)
LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -147,6 +147,8 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
return channel
}
fun checkMetadataCoverage(checkActive: () -> Unit = {}): List<MetadataCoverage> =
auditMetadataCoverage(transport, userId, ::currentToken, checkActive)
fun checkAfterDarkMovies(genre: String): List<AfterDarkMovie> {
val found = discoverAfterDarkMovies(transport, userId, ::currentToken, genre)
currentToken()
@@ -0,0 +1,50 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
internal data class FieldCoverage(val label: String, val present: Int)
/** Counts only: a coverage result never holds a title, ID or other library value. */
internal data class MetadataCoverage(val type: String, val label: String, val checked: Int, val total: Long?, val fields: List<FieldCoverage>)
private const val AUDIT_LIMIT = 20
private val AuditTypes = listOf("Movie" to "Films", "Series" to "Series", "Episode" to "Episodes")
private fun JSONObject.hasText(key: String) = !isNull(key) && (opt(key) as? String)?.isNotBlank() == true
private fun JSONObject.hasItems(key: String) = (opt(key) as? JSONArray)?.let { it.length() > 0 } == true
private fun JSONObject.hasNumber(key: String) = (opt(key) as? Number)?.let { it.toLong() > 0 } == true
private val AuditFields: List<Triple<String, Set<String>, (JSONObject) -> Boolean>> = listOf(
Triple("Genres", setOf("Movie", "Series", "Episode")) { it.hasItems("Genres") },
Triple("Overview", setOf("Movie", "Series", "Episode")) { it.hasText("Overview") },
Triple("Year", setOf("Movie", "Series", "Episode")) { it.hasNumber("ProductionYear") },
Triple("Content rating", setOf("Movie", "Series", "Episode")) { it.hasText("OfficialRating") },
Triple("External IDs", setOf("Movie", "Series", "Episode")) { row -> (row.opt("ProviderIds") as? JSONObject)?.let { it.length() > 0 } == true },
Triple("Studios", setOf("Movie", "Series", "Episode")) { it.hasItems("Studios") },
Triple("Cast and crew", setOf("Movie", "Series", "Episode")) { it.hasItems("People") },
Triple("Air or release date", setOf("Movie", "Series", "Episode")) { it.hasText("PremiereDate") },
Triple("Original title", setOf("Movie", "Series", "Episode")) { it.hasText("OriginalTitle") },
Triple("Tags", setOf("Movie", "Series", "Episode")) { it.hasItems("TagItems") || it.hasItems("Tags") },
Triple("Runtime", setOf("Movie", "Episode")) { it.hasNumber("RunTimeTicks") },
Triple("Series name", setOf("Episode")) { it.hasText("SeriesName") },
Triple("Season number", setOf("Episode")) { (it.opt("ParentIndexNumber") as? Number) != null },
Triple("Episode number", setOf("Episode")) { (it.opt("IndexNumber") as? Number) != null })
internal val MetadataCoverageLabels: List<String> = AuditFields.map { it.first }
/** One explicit action: one random page of each type, at most twenty records, nothing retained. */
internal fun auditMetadataCoverage(transport: EmbyTransport, userId: String, token: () -> String,
checkActive: () -> Unit = {}): List<MetadataCoverage> {
require(EmbyClient.validId(userId))
return AuditTypes.map { (type, label) ->
checkActive()
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=$type&StartIndex=0&Limit=$AUDIT_LIMIT&SortBy=Random&Fields=Genres,Overview,People,ProviderIds,Studios&EnableImages=false&EnableUserData=false", token = token()))
EmbyClient.expectSuccess(reply.status, false)
val page = EmbyClient.parse(reply.body)
val rows = page.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return a metadata sample.")
val records = (0 until minOf(rows.length(), AUDIT_LIMIT)).mapNotNull { rows.optJSONObject(it) }
.filter { it.optString("Type") == type && EmbyClient.validId(it.optString("Id")) }.distinctBy { it.optString("Id") }
val total = if (page.has("TotalRecordCount")) page.optLong("TotalRecordCount", -1).takeIf { it >= 0 } else null
MetadataCoverage(type, label, records.size, total,
AuditFields.filter { type in it.second }.map { (name, _, test) -> FieldCoverage(name, records.count(test)) })
}
}
@@ -0,0 +1,48 @@
package tv.orbit.classic
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
@Composable
internal fun MetadataCoverageScreen(coverage: List<MetadataCoverage>?, busy: Boolean, canRun: Boolean, message: String?,
modifier: Modifier, onRun: () -> Unit, onBack: () -> Unit) {
BackHandler(onBack = onBack)
val backFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { backFocus.requestFocus() }
Column(modifier.fillMaxWidth().background(Navy).padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Metadata coverage", color = Yellow, fontSize = 26.sp)
Text("How many items in a small sample carry each detail. Read-only: no titles are shown and nothing is saved or changed.", color = Color.White, fontSize = 15.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OrbitButton(if (busy) "Checking…" else if (coverage == null) "Check metadata coverage" else "Check again", enabled = canRun, onClick = onRun)
OrbitButton("Back", Modifier.focusRequester(backFocus), onClick = onBack)
}
message?.let { Text(it, color = Yellow, fontSize = 15.sp, maxLines = 3) }
if (coverage != null) {
CoverageRow("Sample", coverage.map { sample -> "${sample.checked}${sample.total?.let { " of $it" }.orEmpty()}" }, Yellow)
CoverageRow("", coverage.map { it.label }, Yellow)
MetadataCoverageLabels.forEach { label ->
CoverageRow(label, coverage.map { sample -> sample.fields.firstOrNull { it.label == label }?.let { "${it.present}/${sample.checked}" } ?: "–" }, Color.White)
}
Text("This is a sample, not a library total. A zero can mean the provider didn't return that detail for this request.", color = Muted, fontSize = 13.sp)
}
}
}
@Composable
private fun CoverageRow(label: String, values: List<String>, color: Color) {
Row(Modifier.fillMaxWidth().background(Panel).padding(horizontal = 10.dp, vertical = 2.dp)) {
Text(label, Modifier.weight(1.4f), color = Muted, fontSize = 14.sp, maxLines = 1)
values.forEach { Text(it, Modifier.weight(1f), color = color, fontSize = 14.sp, maxLines = 1) }
}
}
@@ -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.16.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.17.0")
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,88 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class MetadataAuditTest {
private fun full(id: String, type: String) = JSONObject().put("Id", id).put("Type", type).put("Name", "Private fixture title")
.put("Genres", JSONArray().put("Horror")).put("Overview", "Private fixture overview").put("ProductionYear", 1999)
.put("OfficialRating", "18").put("ProviderIds", JSONObject().put("Imdb", "tt0")).put("Studios", JSONArray().put(JSONObject().put("Name", "Fixture")))
.put("People", JSONArray().put(JSONObject().put("Name", "Fixture"))).put("PremiereDate", "1999-01-01T00:00:00Z")
.put("OriginalTitle", "Fixture").put("TagItems", JSONArray().put(JSONObject().put("Name", "Fixture"))).put("RunTimeTicks", 54_000_000_000L)
.put("SeriesName", "Fixture").put("ParentIndexNumber", 0).put("IndexNumber", 1)
private fun page(items: JSONArray, total: Long? = null) = EmbyReply(200, JSONObject().put("Items", items).also { o -> total?.let { o.put("TotalRecordCount", it) } }.toString())
private fun typeOf(request: EmbyRequest) = request.path.substringAfter("IncludeItemTypes=").substringBefore('&')
private fun audit(transport: EmbyTransport, checkActive: () -> Unit = {}) = auditMetadataCoverage(transport, "fixture-user", { "fixture-token" }, checkActive)
private fun List<MetadataCoverage>.count(type: String, label: String) = first { it.type == type }.fields.first { it.label == label }.present
@Test fun oneActionSendsThreeBoundedReadsWithTheTokenOnlyInHeaders() {
val requests = mutableListOf<EmbyRequest>()
audit(EmbyTransport { r -> requests += r; page(JSONArray()) })
assertEquals(listOf("Movie", "Series", "Episode"), requests.map(::typeOf))
requests.forEach { r ->
assertEquals("GET", r.method); assertEquals("fixture-token", r.token)
assertTrue(r.path.contains("Limit=20&")); assertTrue(r.path.contains("SortBy=Random"))
assertTrue(r.path.contains("Fields=Genres,Overview,People,ProviderIds,Studios&"))
assertFalse(r.path.contains("fixture-token"))
}
}
@Test fun countsEachFieldForEachType() {
val result = audit(EmbyTransport { r -> val t = typeOf(r); page(JSONArray().put(full("one", t)).put(full("two", t).put("Genres", JSONArray())).put(JSONObject().put("Id", "bare").put("Type", t)), 900) })
val films = result.first { it.type == "Movie" }
assertEquals(3, films.checked); assertEquals(900L, films.total)
assertEquals(1, result.count("Movie", "Genres")); assertEquals(2, result.count("Series", "External IDs"))
assertEquals(2, result.count("Episode", "Season number"))
}
@Test fun episodeOnlyFieldsStayOffOtherTypes() {
val result = audit(EmbyTransport { page(JSONArray()) })
assertNull(result.first { it.type == "Movie" }.fields.firstOrNull { it.label == "Season number" })
assertNull(result.first { it.type == "Series" }.fields.firstOrNull { it.label == "Runtime" })
assertTrue(result.flatMap { it.fields }.map { it.label }.all { it in MetadataCoverageLabels })
}
@Test fun malformedFieldsCountAsMissingAndNeverThrow() {
val odd = JSONObject().put("Id", "odd").put("Type", "Movie").put("Genres", "Horror").put("ProviderIds", JSONArray())
.put("ProductionYear", "1999").put("OfficialRating", JSONObject.NULL).put("Overview", 7).put("Studios", JSONObject()).put("RunTimeTicks", -5)
val result = audit(EmbyTransport { r -> page(if (typeOf(r) == "Movie") JSONArray().put(odd) else JSONArray()) })
assertEquals(1, result.first().checked)
assertTrue(result.first().fields.all { it.present == 0 })
}
@Test fun ignoresRecordsBeyondTwentyForeignTypesUnsafeIdsAndDuplicates() {
val many = JSONArray(); repeat(50) { many.put(full("id-$it", "Movie")) }
assertEquals(20, audit(EmbyTransport { r -> page(if (typeOf(r) == "Movie") many else JSONArray()) }).first().checked)
val mixed = JSONArray().put(full("one", "Movie")).put(full("one", "Movie")).put(full("two", "Series")).put(full("../bad", "Movie")).put("text")
assertEquals(1, audit(EmbyTransport { r -> page(if (typeOf(r) == "Movie") mixed else JSONArray()) }).first().checked)
}
@Test fun resultsHoldCountsAndNoLibraryValues() {
val text = audit(EmbyTransport { r -> page(JSONArray().put(full("private-id", typeOf(r)))) }).toString()
assertFalse(text.contains("Private fixture")); assertFalse(text.contains("private-id")); assertFalse(text.contains("tt0"))
}
@Test fun rateLimitAndExpiredLoginStopWithoutFurtherRequests() {
for (status in listOf(429, 401, 403)) {
var calls = 0
try { audit(EmbyTransport { calls++; EmbyReply(status, "") }); fail("Status $status accepted") }
catch (failure: EmbyCheckException) { assertEquals(status, failure.httpStatus) }
assertEquals(1, calls)
}
}
@Test fun cancellationStopsBeforeTheNextRequest() {
var calls = 0
try {
audit(EmbyTransport { calls++; page(JSONArray()) }) { if (calls == 1) throw IllegalStateException("cancelled") }
fail("Cancellation ignored")
} catch (_: IllegalStateException) { }
assertEquals(1, calls)
}
@Test fun sessionCheckLeavesTheSampleAndSavedStateInputsAlone() {
val requests = mutableListOf<EmbyRequest>()
val sample = EmbySample(listOf(SampleItem("Fixture", "Movie", 90, "mkv", "one")), 1)
val session = EmbySampleSession(EmbyTransport { r -> requests += r; page(JSONArray().put(full("two", typeOf(r)))) }, "fixture-token", "fixture-user", sample)
val before = session.sample
assertEquals(3, session.checkMetadataCoverage().size)
assertSame(before, session.sample)
assertTrue(requests.all { it.method == "GET" })
try { session.prepare(SampleItem("Fixture", "Movie", 90, "mkv", "two")); fail("Audited record became playable") }
catch (_: EmbyCheckException) { }
}
}