Give channel tuning more time and one retry (0.23.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m46s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m26s

Vortex failed to tune when the provider took over 15 seconds to answer.
Allows 45 seconds, retries once, and records the health check results.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 22:12:42 +00:00
co-authored by Claude Opus 5.5
parent 232ebfdc84
commit 80db5495e6
9 changed files with 61 additions and 7 deletions
+11
View File
@@ -0,0 +1,11 @@
# Orbit TV 0.23.0 — tuning retry
Reported on the box with 0.22: 103 Vortex showed Tuning, then that the provider took too long. The provider's public endpoint answered in about 0.2 seconds from the PC at the time. The likely cause is the per-title playback request exceeding the 15-second limit, which is unconfirmed, because tools can't see the box's requests.
Changes: playback information requests get 45 seconds. A timeout, dropped connection or 5xx gets one more try after 2 seconds when tuning; a refusal doesn't. The Retry button stays. The health check's timings now fit on screen.
Results from the 0.22 check on Paul's box: 132,545 titles, 127,091 different by external ID, 125,643 by title and year, 5,454 likely extra copies by ID, 1,258 with no ID. Full pass 42 seconds. Filters took 255 to 500 ms with no new indexes. Two timings were off screen. Tags are mostly provider housekeeping such as foreign, foreignautopath, new and favail.
Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed, with new tests for the longer wait, one retry, giving up and refusals. Real slow-provider behaviour needs the Xiaomi test.
Rollback: reinstall outputs/revision-0.22.0, or revert this commit. Data formats are unchanged.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 22
versionName = "0.22.0-library-health"
versionCode = 23
versionName = "0.23.0-tuning-retry"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -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.22.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.23.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -34,6 +34,8 @@ internal object PlaybackUrls {
}
}
internal const val PLAYBACK_INFO_TIMEOUT_MS = 45_000
/** A temporary session is owned by the sample screen, never by persistent storage. */
internal class EmbySampleSession(private val transport: EmbyTransport, private var token: String?,
private val userId: String, sample: EmbySample) {
@@ -71,6 +73,14 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
val auth = currentToken()
return if (PlaybackUrls.isProvider(URI(url))) mapOf("X-Emby-Token" to auth) else emptyMap()
}
/** Tuning shouldn't fail on one slow answer, so a timeout or dropped connection gets one more try. */
fun prepareForTuning(item: SampleItem, sourceId: String, pause: () -> Unit = {}): EmbyPlaybackPlan = try {
prepare(item, preferredSourceId = sourceId)
} catch (failure: EmbyCheckException) {
if (!failure.retryable) throw failure
pause()
prepare(item, preferredSourceId = sourceId)
}
fun prepare(item: SampleItem, isPlayback: Boolean = true, preferredSourceId: String? = null): EmbyPlaybackPlan {
if (preferredSourceId != null && !EmbyClient.validId(preferredSourceId)) throw EmbyCheckException("The channel source is no longer usable.")
if (item.id.isBlank() || sample.items.none { it.id == item.id } && candidateEpisodes[item.id] != item && channelEpisodes[item.id] != item && darkCandidates.none { it.item == item } && darkLineup[item.id] != item) throw EmbyCheckException("Choose a title from the current sample.")
@@ -83,7 +93,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
.put("SubtitleProfiles", JSONArray().put(JSONObject().put("Format", "srt").put("Method", "External"))
.put(JSONObject().put("Format", "vtt").put("Method", "External"))))
if (preferredSourceId != null) request.put("MediaSourceId", preferredSourceId)
val response = transport.execute(EmbyRequest("POST", "/Items/${item.id}/PlaybackInfo", request.toString(), currentToken()))
val response = transport.execute(EmbyRequest("POST", "/Items/${item.id}/PlaybackInfo", request.toString(), currentToken(), PLAYBACK_INFO_TIMEOUT_MS))
EmbyClient.expectSuccess(response.status, false)
val info = EmbyClient.parse(response.body)
if (!info.isNull("ErrorCode") && !info.optString("ErrorCode").let { it.isBlank() || it == "0" || it == "None" }) throw EmbyCheckException("The provider did not offer a playable source for this account.")
@@ -41,7 +41,7 @@ internal fun LibraryHealthScreen(health: CatalogueHealth?, busy: Boolean, messag
}
Column(Modifier.fillMaxWidth().background(Panel).padding(10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Full pass ${whole(h.scanMs)} ms • database ${whole(h.bytes / 1024)} KB", color = Color.White, fontSize = 15.sp)
h.timings.forEach { Text("${it.label}: ${whole(it.count)} found, ${whole(it.ms)} ms", color = Muted, fontSize = 14.sp) }
Text(h.timings.joinToString(" • ") { "${it.label}: ${whole(it.count)} in ${whole(it.ms)} ms" }, color = Muted, fontSize = 13.sp, maxLines = 3)
}
}
}
@@ -103,7 +103,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
try {
val plan = prepareMutex.withLock {
withContext(Dispatchers.IO) {
session.prepare(current.media.item, preferredSourceId = current.media.sourceId)
session.prepareForTuning(current.media.item, current.media.sourceId) { Thread.sleep(2_000) }
}
}
if (plan.durationMs == null || kotlin.math.abs(plan.durationMs - current.media.durationMs) > 2_000)
@@ -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.22.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.23.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()) {
@@ -39,4 +39,29 @@ class CatalogueStatsTest {
assertEquals(102, channelAfter(lineup, 101, 1)); assertEquals(101, channelAfter(lineup, 103, 1)); assertEquals(103, channelAfter(lineup, 101, -1))
assertEquals(999, channelAfter(lineup, 999, 1)); assertEquals(101, channelAfter(listOf(101), 101, 1))
}
private class SlowThenFine(val failures: Int, val failure: () -> EmbyReply) : EmbyTransport {
val requests = mutableListOf<EmbyRequest>()
override fun execute(request: EmbyRequest): EmbyReply {
requests += request
if (requests.size <= failures) return failure()
return EmbyReply(200, org.json.JSONObject().put("MediaSources", org.json.JSONArray().put(org.json.JSONObject().put("Id", "source").put("Container", "mkv").put("SupportsDirectStream", true))).toString())
}
}
private val item = SampleItem("Fixture", "Movie", 90, "mkv", "one")
private fun session(transport: EmbyTransport) = EmbySampleSession(transport, "fixture-token", "fixture-user", EmbySample(listOf(item), 1))
@Test fun tuningTriesOnceMoreAfterASlowAnswerWithALongerWait() {
val transport = SlowThenFine(1) { throw EmbyCheckException("The provider took too long to answer.", retryable = true) }
var pauses = 0
assertEquals("source", session(transport).prepareForTuning(item, "source") { pauses++ }.sourceId)
assertEquals(2, transport.requests.size); assertEquals(1, pauses)
assertTrue(transport.requests.all { it.readTimeoutMs == PLAYBACK_INFO_TIMEOUT_MS })
}
@Test fun tuningGivesUpAfterTwoTriesAndNeverRetriesARefusal() {
val slow = SlowThenFine(5) { throw EmbyCheckException("The provider took too long to answer.", retryable = true) }
try { session(slow).prepareForTuning(item, "source"); fail("Endless failure accepted") } catch (_: EmbyCheckException) { }
assertEquals(2, slow.requests.size)
val refused = SlowThenFine(5) { EmbyReply(403, "") }
try { session(refused).prepareForTuning(item, "source"); fail("Refusal accepted") } catch (_: EmbyCheckException) { }
assertEquals(1, refused.requests.size)
}
}
+8
View File
@@ -73,6 +73,14 @@ Paul's point, 1 November 2026: the library holds several copies of many titles (
- **Staying on the clock:** selection must be repeatable from a saved seed and the pool, so restarting the app rejoins the same programme. A window already published doesn't change when the library does.
- **Across channels:** a title can sit in several pools, but the same film shouldn't air on two channels at once unless that's intended.
## Health check results
Measured on the box, 1 November 2026: 132,545 titles, 127,091 different by external ID, 125,643 by title and year. Likely extra copies by ID are 5,454, about 4%, and 1,258 titles have no ID. Copies that Emby already merges as versions of one item wouldn't appear here, so remux and 4K copies may be hiding inside single entries.
Without new indexes, a one-off genre-and-decade filter took 255 to 500 ms and a full pass 42 seconds. That's fine for occasional review and matching runs, so link tables aren't urgent. They'd matter if filters run on every screen.
Genres are usable as they stand: Drama 50,541, Comedy 35,196, Documentary 21,821, Thriller 19,783, Action 17,424, Romance 17,257, Crime 15,401, Horror 12,777. Tags are mostly provider housekeeping (foreign, foreignautopath, new, favail, archive), so don't treat tags as programme descriptions without filtering. Curated lists and genres have to carry matching.
## Broadcast, not playlist
Paul's direction, 1 November 2026: at this scale Orbit must stay smart and feel like a TV broadcast, not a running playlist.