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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user