Add read-only metadata coverage check (0.17.0)
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:
@@ -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
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user