Fix import falsely finishing on a zero total (0.21.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m2s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 58s

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) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 19:55:43 +00:00
co-authored by Claude Opus 5.5
parent cddc2396d9
commit 6e2d3df225
7 changed files with 37 additions and 13 deletions
+9
View File
@@ -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.
+2 -2
View File
@@ -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 }
@@ -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
@@ -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() {
@@ -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)
@@ -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()) {
@@ -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<EmbyRequest>(); 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<EmbyRequest>(); val sink = MemorySink(); val server = library(120_000, requests)