From 6e2d3df225321c71aa9a092ab8b88ae55b03327b Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sun, 1 Nov 2026 19:55:43 +0000 Subject: [PATCH] Fix import falsely finishing on a zero total (0.21.0) Turning the count off makes the provider return a total of zero, which 0.20 took as the real total, so it called the import finished at 65,400. Ignores totals it didn't ask for, and repairs a box left in that state. Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.21.0.md | 9 +++++++++ android/app/build.gradle.kts | 4 ++-- .../java/tv/orbit/classic/CatalogueImport.kt | 13 ++++++++----- .../main/java/tv/orbit/classic/CatalogueStore.kt | 4 +++- .../src/main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../java/tv/orbit/classic/CatalogueImportTest.kt | 16 +++++++++++++--- 7 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 VERIFICATION-0.21.0.md diff --git a/VERIFICATION-0.21.0.md b/VERIFICATION-0.21.0.md new file mode 100644 index 0000000..550a783 --- /dev/null +++ b/VERIFICATION-0.21.0.md @@ -0,0 +1,9 @@ +# Orbit TV 0.21.0 — import position fix + +Reported on the box with 0.20: position 65,400 "of zero" with only Back selectable. Cause, a bug in 0.20: with EnableTotalRecordCount=false the provider appears to return a total of zero, and the import read that as the real total, marked itself finished and disabled its buttons. Nothing was lost. It also shows the page after 65,200 was answered, so 0.20's changes got past the earlier failure, though one page isn't enough to prove it. + +Changes: the first page of each run asks for the total; other pages ignore any total returned. A retry skips the count. A saved total of zero, and "finished" without a real total, are treated as not finished, so a box left in this state can carry on. + +Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 113 unit tests, zero failures or errors, no lint errors. The fixture now returns zero for a switched-off count, as the provider appears to. The saved-state repair is in CatalogueStore's SQLite code, which has no JVM test, so it needs the Xiaomi test. + +Rollback: reinstall outputs/revision-0.20.0 (same database format), or revert this commit. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 20e1ea4..fa84ff4 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 = 20 - versionName = "0.20.0-import-recovery" + versionCode = 21 + versionName = "0.21.0-import-position" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt index bc35ab9..915974a 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt @@ -57,7 +57,7 @@ private fun catalogueRecord(row: JSONObject): CatalogueRecord? { internal fun fetchCataloguePage(transport: EmbyTransport, userId: String, token: () -> String, start: Long, limit: Int = CATALOGUE_PAGE_SIZE, withTotal: Boolean = true): CataloguePage { require(EmbyClient.validId(userId) && start >= 0 && limit in 1..CATALOGUE_PAGE_SIZE) - // Counting the whole library is costly, so only the first page asks for the total. + // Counting the whole library is costly, so only the first page of a run asks for the total. val count = if (withTotal) "" else "&EnableTotalRecordCount=false" val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Movie,Series&StartIndex=$start&Limit=$limit&SortBy=DateCreated&SortOrder=Ascending&Fields=Genres,OfficialRating,OriginalTitle,PremiereDate,ProductionYear,ProviderIds,Studios,Tags&EnableImages=false&EnableUserData=false$count", token = token(), readTimeoutMs = CATALOGUE_READ_TIMEOUT_MS)) EmbyClient.expectSuccess(reply.status, false) @@ -65,7 +65,8 @@ internal fun fetchCataloguePage(transport: EmbyTransport, userId: String, token: val rows = page.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return a catalogue page.") val rowCount = minOf(rows.length(), limit) val records = (0 until rowCount).mapNotNull { rows.optJSONObject(it)?.let(::catalogueRecord) }.distinctBy { it.id } - val total = if (page.has("TotalRecordCount")) page.optLong("TotalRecordCount", -1).takeIf { it >= 0 } else null + // A count switched off still arrives, as zero, so it means nothing unless it was asked for. + val total = if (withTotal && page.has("TotalRecordCount")) page.optLong("TotalRecordCount", -1).takeIf { it >= 0 } else null return CataloguePage(records, rowCount, total) } @@ -83,12 +84,14 @@ internal fun importCatalogue(transport: EmbyTransport, userId: String, token: () var size = CATALOGUE_PAGE_SIZE var failures = 0 var slowest = 0L + var total: Long? = null fun result(finished: Boolean) = CatalogueRun(pages, written, finished).also { it.pageSize = size; it.slowestMs = slowest } while (pages < maxPages) { checkActive() val began = System.currentTimeMillis() val page = try { - fetchCataloguePage(transport, userId, token, start, size, start == 0L) + // A retry skips the count, in case the count is what's slow. + fetchCataloguePage(transport, userId, token, start, size, pages == 0 && failures == 0).also { if (it.total != null) total = it.total } } catch (failure: EmbyCheckException) { if (!failure.retryable) throw failure if (++failures >= CATALOGUE_MAX_ATTEMPTS) @@ -102,8 +105,8 @@ internal fun importCatalogue(transport: EmbyTransport, userId: String, token: () slowest = maxOf(slowest, System.currentTimeMillis() - began) checkActive() val next = start + page.rawCount - val finished = page.rawCount == 0 || page.total?.let { next >= it } == true - sink.write(userId, page.records, next, page.total, finished) + val finished = page.rawCount == 0 || total?.let { next >= it } == true + sink.write(userId, page.records, next, total, finished) start = next pages++ written += page.records.size diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt index 60a59a9..08e0ead 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt @@ -58,8 +58,10 @@ internal class CatalogueStore private constructor(context: Context) : CatalogueS @Synchronized fun summary(): CatalogueSummary { val db = readableDatabase fun count(where: String) = db.rawQuery("SELECT COUNT(*) FROM items WHERE $where", null).use { it.moveToFirst(); it.getLong(0) } + // 0.20 could save a total of zero and call the import finished. Neither is true, so it can carry on. + val total = meta(db, "total")?.toLongOrNull()?.takeIf { it > 0 } return CatalogueSummary(count("type = 'Movie'"), count("type = 'Series'"), meta(db, "next")?.toLongOrNull() ?: 0, - meta(db, "total")?.toLongOrNull(), meta(db, "finished") == "true", count("genres != ''"), count("provider_ids != '{}'"), + total, meta(db, "finished") == "true" && total != null, count("genres != ''"), count("provider_ids != '{}'"), count("year IS NOT NULL"), count("rating != ''"), count("premiere != ''"), count("tags != ''"), file.length()) } @Synchronized fun clear() { 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 ca6d5a5..c097b64 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 = request.readTimeoutMs ?: 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.20.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.21.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/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 3c24087..d71e104 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.20.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.21.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/CatalogueImportTest.kt b/android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt index c1b74c0..cd7036f 100644 --- a/android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt @@ -26,7 +26,8 @@ class CatalogueImportTest { val start = startOf(r) val rows = JSONArray() for (i in start until minOf(start + limitOf(r), size)) rows.put(row("id-$i", if (i % 5 == 0) "Series" else "Movie")) - EmbyReply(200, JSONObject().put("Items", rows).put("TotalRecordCount", size).toString()) + // As the real provider does: a count switched off still arrives, as zero. + EmbyReply(200, JSONObject().put("Items", rows).put("TotalRecordCount", if (r.path.contains("EnableTotalRecordCount=false")) 0 else size).toString()) } private fun run(transport: EmbyTransport, sink: CatalogueSink, user: String = "fixture-user", checkActive: () -> Unit = {}) = importCatalogue(transport, user, { "fixture-token" }, sink, checkActive = checkActive) @@ -75,11 +76,20 @@ class CatalogueImportTest { } catch (failure: EmbyCheckException) { assertTrue(failure.message!!.contains("position 400")) } assertEquals(listOf(200, 100, 50, 25, 25), limits); assertEquals(400L, sink.next); assertEquals(400, sink.records.size) } - @Test fun onlyTheFirstPageAsksForATotalAndLaterRunsKeepIt() { + @Test fun onlyTheFirstPageOfARunAsksForATotalAndAZeroCountIsNotTheEnd() { val requests = mutableListOf(); val sink = MemorySink(); val server = library(120_000, requests) - run(server, sink) + val first = run(server, sink) assertFalse(requests.first().path.contains("EnableTotalRecordCount")); assertTrue(requests.drop(1).all { it.path.contains("EnableTotalRecordCount=false") }) assertTrue(requests.all { it.readTimeoutMs == CATALOGUE_READ_TIMEOUT_MS }) + assertFalse(first.finished); assertFalse(sink.finished); assertEquals(120_000L, sink.total) + requests.clear() + assertFalse(run(server, sink).finished); assertEquals(2000L, sink.next) + assertFalse(requests.first().path.contains("EnableTotalRecordCount")); assertEquals(120_000L, sink.total) + } + @Test fun aRunWhoseCountWasSkippedStillFinishesOnAnEmptyPage() { + val sink = MemorySink(); val server = library(250); var failed = false + val result = importCatalogue(EmbyTransport { r -> if (!failed) { failed = true; throw slow }; server.execute(r) }, "fixture-user", { "fixture-token" }, sink, 50) + assertTrue(result.finished); assertTrue(sink.finished); assertEquals(250L, sink.next); assertNull(sink.total); assertEquals(250, sink.records.size) } @Test fun aLaterRunResumesWhereTheLastStopped() { val requests = mutableListOf(); val sink = MemorySink(); val server = library(120_000, requests)