diff --git a/VERIFICATION-0.17.0.md b/VERIFICATION-0.17.0.md new file mode 100644 index 0000000..c9f906e --- /dev/null +++ b/VERIFICATION-0.17.0.md @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fdc1a20..cd4864d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index d4c4ed3..1ac4f06 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) 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 12b724d..d1a6965 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -73,6 +73,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var darkMovies by remember { mutableStateOf>(emptyList()) } var approvedDark by remember { mutableStateOf>(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?>(null) } + var reviewingCoverage by remember { mutableStateOf(false) } var afterDarkChannel by remember { mutableStateOf(null) } var toonamiChannel by remember { mutableStateOf(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)) { 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 d0090a6..f96e50c 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -147,6 +147,8 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v return channel } + fun checkMetadataCoverage(checkActive: () -> Unit = {}): List = + auditMetadataCoverage(transport, userId, ::currentToken, checkActive) fun checkAfterDarkMovies(genre: String): List { val found = discoverAfterDarkMovies(transport, userId, ::currentToken, genre) currentToken() diff --git a/android/app/src/main/java/tv/orbit/classic/MetadataAudit.kt b/android/app/src/main/java/tv/orbit/classic/MetadataAudit.kt new file mode 100644 index 0000000..01017d8 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/MetadataAudit.kt @@ -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) + +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, (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 = 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 { + 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)) }) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/MetadataCoverageScreen.kt b/android/app/src/main/java/tv/orbit/classic/MetadataCoverageScreen.kt new file mode 100644 index 0000000..6eb1f29 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/MetadataCoverageScreen.kt @@ -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?, 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, 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) } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 46f35c5..a084db1 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/MetadataAuditTest.kt b/android/app/src/test/java/tv/orbit/classic/MetadataAuditTest.kt new file mode 100644 index 0000000..eb06861 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/MetadataAuditTest.kt @@ -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.count(type: String, label: String) = first { it.type == type }.fields.first { it.label == label }.present + + @Test fun oneActionSendsThreeBoundedReadsWithTheTokenOnlyInHeaders() { + val requests = mutableListOf() + 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() + 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) { } + } +}