diff --git a/VERIFICATION-0.20.0.md b/VERIFICATION-0.20.0.md new file mode 100644 index 0000000..c43db9b --- /dev/null +++ b/VERIFICATION-0.20.0.md @@ -0,0 +1,11 @@ +# Orbit TV 0.20.0 — import recovery + +Reported on the box: the full import stopped at position 65,200, and each later press failed about 20 seconds in with "could not be reached". The provider's public endpoint answered in about 0.2 seconds from the same connection, so a block on the connection looks unlikely. The app's 15-second read timeout was reported as "could not be reached", so the leading explanation is that deep pages are too slow to answer. That is unconfirmed: nothing recorded the real cause, and one bad record is also possible. + +Changes: catalogue pages get a 60-second read timeout. A timeout now reads "took too long", separately from a failed connection. A timeout, dropped connection or 5xx retries with a smaller page (200, 100, 50, 25) and waits of 5, 10, 15 and 20 seconds, then stops after five tries and names the position. Page size stays reduced for the rest of the run. Rate-limit, login and permission replies still stop at once. Only the first page asks the provider for a total (EnableTotalRecordCount=false afterwards, a parameter not in the published list). The screen reports retries, the slowest page and the page size. Database format is unchanged, so position 65,200 carries over. + +If this still fails, the next step is paging by name (NameStartsWithOrGreater with SortName) instead of by deep offset, which would restart the import. + +Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 112 unit tests (108 existing, 4 new), zero failures or errors, no lint errors. Fixture tests cover smaller retries with no missed titles, 5xx against refusal, giving up after five tries with finished pages kept, and first-page-only totals. A real slow provider can only be tested on the box. + +Rollback: reinstall outputs/revision-0.19.0, whose database format is the same, or revert this commit. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 79e6267..20e1ea4 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 = 19 - versionName = "0.19.0-full-import" + versionCode = 20 + versionName = "0.20.0-import-recovery" } 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 1d7bfec..bc35ab9 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt @@ -7,7 +7,10 @@ internal data class CatalogueRecord(val id: String, val type: String, val title: val genres: List, val studios: List, val providerIds: Map, val premiere: String, val runtimeMs: Long?, val originalTitle: String = "", val tags: List = emptyList()) internal class CataloguePage(val records: List, val rawCount: Int, val total: Long?) -internal data class CatalogueRun(val pages: Int, val records: Int, val finished: Boolean) +internal data class CatalogueRun(val pages: Int, val records: Int, val finished: Boolean) { + var pageSize = CATALOGUE_PAGE_SIZE + var slowestMs = 0L +} /** Each write is one transaction, so an interrupted import resumes from the last whole page. */ internal interface CatalogueSink { @@ -19,6 +22,10 @@ internal const val CATALOGUE_PAGE_SIZE = 200 internal const val CATALOGUE_PAGES_PER_RUN = 5 internal const val CATALOGUE_PAGES_FULL_RUN = 5_000 internal const val CATALOGUE_PAUSE_MS = 1_500L +internal const val CATALOGUE_MIN_PAGE_SIZE = 25 +internal const val CATALOGUE_MAX_ATTEMPTS = 5 +internal const val CATALOGUE_BACKOFF_MS = 5_000L +internal const val CATALOGUE_READ_TIMEOUT_MS = 60_000 private fun names(array: JSONArray?, limit: Int = 8): List = (0 until minOf(array?.length() ?: 0, 32)).mapNotNull { index -> when (val value = array?.opt(index)) { @@ -47,28 +54,52 @@ private fun catalogueRecord(row: JSONObject): CatalogueRecord? { } /** Oldest first, so titles added during an import land after the current position. */ -internal fun fetchCataloguePage(transport: EmbyTransport, userId: String, token: () -> String, start: Long): CataloguePage { - require(EmbyClient.validId(userId) && start >= 0) - val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Movie,Series&StartIndex=$start&Limit=$CATALOGUE_PAGE_SIZE&SortBy=DateCreated&SortOrder=Ascending&Fields=Genres,OfficialRating,OriginalTitle,PremiereDate,ProductionYear,ProviderIds,Studios,Tags&EnableImages=false&EnableUserData=false", token = token())) +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. + 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) val page = EmbyClient.parse(reply.body) val rows = page.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return a catalogue page.") - val count = minOf(rows.length(), CATALOGUE_PAGE_SIZE) - val records = (0 until count).mapNotNull { rows.optJSONObject(it)?.let(::catalogueRecord) }.distinctBy { it.id } + 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 - return CataloguePage(records, count, total) + return CataloguePage(records, rowCount, total) } -/** One explicit run, either five pages or the whole library. A rate-limit or login failure stops it; finished pages stay saved. */ +/** + * One explicit run, either five pages or the whole library. A rate-limit or login failure stops it at once. + * A slow or dropped page is retried with smaller pages and a growing wait, then the run stops. Finished pages stay saved. + */ internal fun importCatalogue(transport: EmbyTransport, userId: String, token: () -> String, sink: CatalogueSink, - maxPages: Int = CATALOGUE_PAGES_PER_RUN, pause: () -> Unit = {}, checkActive: () -> Unit = {}, onPage: () -> Unit = {}): CatalogueRun { + maxPages: Int = CATALOGUE_PAGES_PER_RUN, pause: () -> Unit = {}, checkActive: () -> Unit = {}, onPage: () -> Unit = {}, + backoff: (Int) -> Unit = {}, onRetry: (Int) -> Unit = {}): CatalogueRun { require(maxPages in 1..CATALOGUE_PAGES_FULL_RUN) var start = sink.position(userId) var pages = 0 var written = 0 + var size = CATALOGUE_PAGE_SIZE + var failures = 0 + var slowest = 0L + fun result(finished: Boolean) = CatalogueRun(pages, written, finished).also { it.pageSize = size; it.slowestMs = slowest } while (pages < maxPages) { checkActive() - val page = fetchCataloguePage(transport, userId, token, start) + val began = System.currentTimeMillis() + val page = try { + fetchCataloguePage(transport, userId, token, start, size, start == 0L) + } catch (failure: EmbyCheckException) { + if (!failure.retryable) throw failure + if (++failures >= CATALOGUE_MAX_ATTEMPTS) + throw EmbyCheckException("${failure.message} Stopped at position $start after $failures tries, the last asking for $size titles.", retryable = true) + size = maxOf(CATALOGUE_MIN_PAGE_SIZE, size / 2) + onRetry(size) + backoff(failures) + continue + } + failures = 0 + slowest = maxOf(slowest, System.currentTimeMillis() - began) checkActive() val next = start + page.rawCount val finished = page.rawCount == 0 || page.total?.let { next >= it } == true @@ -77,8 +108,8 @@ internal fun importCatalogue(transport: EmbyTransport, userId: String, token: () pages++ written += page.records.size onPage() - if (finished) return CatalogueRun(pages, written, true) + if (finished) return result(true) if (pages < maxPages) pause() } - return CatalogueRun(pages, written, false) + return result(false) } diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt index 7b7647c..4672002 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt @@ -33,7 +33,7 @@ internal fun CatalogueScreen(summary: CatalogueSummary?, busy: Boolean, canRun: } Column(modifier.fillMaxWidth().background(Navy).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Library import test", color = Yellow, fontSize = 26.sp) - Text("Saves film and series details on this box. Channels and schedules are not changed, and no episodes are read. Importing everything takes about half an hour.", color = Color.White, fontSize = 15.sp) + Text("Saves film and series details on this box. Channels and schedules are not changed, and no episodes are read. Importing everything can take a while, as later pages are slower.", color = Color.White, fontSize = 15.sp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OrbitButton(if (busy) "Importing…" else if (summary?.finished == true) "Import finished" else "Import everything", enabled = canRun && summary?.finished != true, onClick = { onRun(true) }) OrbitButton("Import next 1,000", enabled = canRun && summary?.finished != true, onClick = { onRun(false) }) 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 e6dbc6c..ca6d5a5 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -7,10 +7,10 @@ import java.net.URL import java.util.UUID import javax.net.ssl.HttpsURLConnection -internal class EmbyCheckException(message: String, val httpStatus: Int? = null) : Exception(message) +internal class EmbyCheckException(message: String, val httpStatus: Int? = null, val retryable: Boolean = false) : Exception(message) internal data class SampleItem(val title: String, val type: String, val minutes: Long?, val format: String, val id: String = "", val durationMs: Long? = minutes?.takeIf { it in 1..(Long.MAX_VALUE / 60_000) }?.times(60_000)) internal data class EmbySample(val items: List, val total: Long?) -internal class EmbyRequest(val method: String, val path: String, val body: String? = null, val token: String? = null) +internal class EmbyRequest(val method: String, val path: String, val body: String? = null, val token: String? = null, val readTimeoutMs: Int? = null) internal class EmbyReply(val status: Int, val body: String) internal fun interface EmbyTransport { fun execute(request: EmbyRequest): EmbyReply } @@ -39,9 +39,9 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.requestMethod = request.method connection.instanceFollowRedirects = false connection.connectTimeout = 10_000 - connection.readTimeout = if (request.path == "/Sessions/Logout") 5_000 else 15_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.19.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.20.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) @@ -57,7 +57,8 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI return EmbyReply(status, body) } catch (e: EmbyCheckException) { throw e } catch (_: javax.net.ssl.SSLException) { throw EmbyCheckException("The secure connection could not be verified. No insecure fallback was used.") } - catch (_: java.io.IOException) { throw EmbyCheckException("The provider could not be reached. Check your connection and try again.") } + catch (_: java.net.SocketTimeoutException) { throw EmbyCheckException("The provider took too long to answer.", retryable = true) } + catch (_: java.io.IOException) { throw EmbyCheckException("The provider could not be reached. Check your connection and try again.", retryable = true) } finally { connection.disconnect() } } } @@ -130,7 +131,7 @@ internal class EmbyClient(private val transport: EmbyTransport = HttpsEmbyTransp 403 -> throw EmbyCheckException("The provider did not permit this connection or library request.", status) 429 -> throw EmbyCheckException("The provider asked us to slow down. Wait before trying again.", status) in 300..399 -> throw EmbyCheckException("The provider redirected the request. The login was not forwarded to another address.") - else -> throw EmbyCheckException("The provider could not complete this check (HTTP $status).") + else -> throw EmbyCheckException("The provider could not complete this check (HTTP $status).", retryable = status >= 500) } } } 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 3227392..9deffd6 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -245,9 +245,12 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea active.importCatalogue(catalogue, if (everything) CATALOGUE_PAGES_FULL_RUN else CATALOGUE_PAGES_PER_RUN, pause = { Thread.sleep(CATALOGUE_PAUSE_MS) }, checkActive = { context.ensureActive() }, // The summary counts the whole table, so it refreshes every few pages, not on each one. - onPage = { if (++pages % 5 == 0) { val latest = catalogue.summary(); scope.launch { catalogueSummary = latest } } }) + onPage = { if (++pages % 5 == 0) { val latest = catalogue.summary(); scope.launch { catalogueSummary = latest; message = null } } }, + backoff = { attempt -> Thread.sleep(CATALOGUE_BACKOFF_MS * attempt) }, + onRetry = { size -> scope.launch { message = "The provider is slow here. Retrying with $size titles at a time…" } }) } - catalogueRun = "Last run: ${result.records} titles in ${result.pages} pages, ${(System.currentTimeMillis() - started) / 1000} seconds." + message = null + catalogueRun = "Last run: ${result.records} titles in ${result.pages} pages, ${(System.currentTimeMillis() - started) / 1000} seconds. Slowest page ${result.slowestMs / 1000} seconds, ${result.pageSize} titles per page." } catch (cancelled: CancellationException) { throw cancelled } catch (failure: EmbyCheckException) { message = failure.message + " Finished pages are saved." } catch (_: Exception) { message = "The import stopped. Finished pages are saved, and it can carry on later." } 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 0128255..8b7f3c8 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -149,8 +149,9 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v fun checkMetadataCoverage(checkActive: () -> Unit = {}): List = auditMetadataCoverage(transport, userId, ::currentToken, checkActive) - fun importCatalogue(sink: CatalogueSink, maxPages: Int = CATALOGUE_PAGES_PER_RUN, pause: () -> Unit = {}, checkActive: () -> Unit = {}, onPage: () -> Unit = {}): CatalogueRun = - importCatalogue(transport, userId, ::currentToken, sink, maxPages, pause, checkActive, onPage) + fun importCatalogue(sink: CatalogueSink, maxPages: Int = CATALOGUE_PAGES_PER_RUN, pause: () -> Unit = {}, checkActive: () -> Unit = {}, onPage: () -> Unit = {}, + backoff: (Int) -> Unit = {}, onRetry: (Int) -> Unit = {}): CatalogueRun = + importCatalogue(transport, userId, ::currentToken, sink, maxPages, pause, checkActive, onPage, backoff, onRetry) fun checkAfterDarkMovies(genre: String): List { val found = discoverAfterDarkMovies(transport, userId, ::currentToken, genre) currentToken() 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 9fcbb27..3c24087 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.19.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.20.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 0c5975b..c1b74c0 100644 --- a/android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt @@ -18,12 +18,14 @@ class CatalogueImportTest { } private fun row(id: String, type: String = "Movie") = JSONObject().put("Id", id).put("Type", type).put("Name", "Fixture $id") private fun startOf(request: EmbyRequest) = request.path.substringAfter("StartIndex=").substringBefore('&').toInt() + private fun limitOf(request: EmbyRequest) = request.path.substringAfter("Limit=").substringBefore('&').toInt() + private val slow = EmbyCheckException("The provider took too long to answer.", retryable = true) /** A fixture library of [size] rows, answering paging as a server would. */ private fun library(size: Int, requests: MutableList = mutableListOf()) = EmbyTransport { r -> requests += r val start = startOf(r) val rows = JSONArray() - for (i in start until minOf(start + CATALOGUE_PAGE_SIZE, size)) rows.put(row("id-$i", if (i % 5 == 0) "Series" else "Movie")) + 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()) } private fun run(transport: EmbyTransport, sink: CatalogueSink, user: String = "fixture-user", checkActive: () -> Unit = {}) = @@ -48,6 +50,37 @@ class CatalogueImportTest { assertTrue(sink.finished); assertEquals(132_544, sink.records.size) assertEquals(requests.map(::startOf), requests.map(::startOf).distinct()) } + @Test fun aSlowPageRetriesSmallerStaysSmallerAndMissesNothing() { + val requests = mutableListOf(); val sink = MemorySink(); val server = library(1000); val retries = mutableListOf(); val waits = mutableListOf() + var failures = 2 + val result = importCatalogue(EmbyTransport { r -> requests += r; if (startOf(r) == 200 && failures-- > 0) throw slow; server.execute(r) }, "fixture-user", { "fixture-token" }, sink, 10, backoff = { waits += it }, onRetry = { retries += it }) + assertEquals(listOf(100, 50), retries); assertEquals(listOf(1, 2), waits); assertEquals(50, result.pageSize) + assertEquals(listOf(200, 200, 100, 50), requests.map(::limitOf).take(4)); assertEquals(listOf(0, 200, 200, 200), requests.map(::startOf).take(4)) + assertEquals(10, result.pages); assertEquals(650L, sink.next) + assertTrue((0 until 650).all { "id-$it" in sink.records }) + } + @Test fun aServerErrorRetriesButRefusalDoesNot() { + val sink = MemorySink(); val server = library(1000); var failed = false + val result = importCatalogue(EmbyTransport { r -> if (!failed) { failed = true; EmbyReply(500, "") } else server.execute(r) }, "fixture-user", { "fixture-token" }, sink, 1) + assertEquals(1, result.pages); assertEquals(100, result.pageSize) + var calls = 0 + try { importCatalogue(EmbyTransport { calls++; EmbyReply(403, "") }, "fixture-user", { "fixture-token" }, MemorySink(), 1); fail("Refusal accepted") } catch (_: EmbyCheckException) { } + assertEquals(1, calls) + } + @Test fun givesUpAfterFiveTriesKeepsFinishedPagesAndSaysWhere() { + val sink = MemorySink(); val server = library(5000); val limits = mutableListOf() + try { + importCatalogue(EmbyTransport { r -> if (startOf(r) >= 400) { limits += limitOf(r); throw slow }; server.execute(r) }, "fixture-user", { "fixture-token" }, sink, 50) + fail("Endless failure accepted") + } 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() { + val requests = mutableListOf(); val sink = MemorySink(); val server = library(120_000, requests) + 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 }) + } @Test fun aLaterRunResumesWhereTheLastStopped() { val requests = mutableListOf(); val sink = MemorySink(); val server = library(120_000, requests) run(server, sink); requests.clear() diff --git a/design/channels/__pycache__/build-network-brands.cpython-314.pyc b/design/channels/__pycache__/build-network-brands.cpython-314.pyc new file mode 100644 index 0000000..e28721d Binary files /dev/null and b/design/channels/__pycache__/build-network-brands.cpython-314.pyc differ