Revert "Ask the provider to convert programmes the box can't decode (0.60.0)"

This reverts commit 6a6335fced.
This commit is contained in:
2026-11-02 10:27:22 +00:00
parent 6a6335fced
commit 10424d3633
8 changed files with 11 additions and 72 deletions
+1 -1
View File
@@ -20,5 +20,5 @@ Orbit Three, Riot and Dark Files, and playback retries.
- assembleDebug, testDebugUnitTest and lintDebug pass; 241 tests, 0 failures.
## Not checked
- The player retry, as it needs the fault to happen again. Correction, same day: that fault wasn't a quick-zap clash. Reproduced on the box, Transformers S1 E1 fails every time with c2.android.mp3.decoder Error 0xe, so the retry can't help that file. 0.60 asks the provider to convert it instead.
- The player retry, as it needs the fault to happen again. The earlier fault's cause, a quick-zap decoder clash, is likely but not proven.
- Several days of real running, a show finishing on air, a clock change and Emby going offline on the box. Paul is now watching and noting issues.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 61
versionName = "0.60.0-convert-fallback"
versionCode = 60
versionName = "0.59.0-playback-retry"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -66,7 +66,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.60.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.59.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -40,7 +40,7 @@ private const val MAX_PLAYER_RETRIES = 2
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null,
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null, onCantDecode: (() -> Unit)? = null) {
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null) {
val context = LocalContext.current
var failure by remember { mutableStateOf<String?>(null) }
var tuningNumberVisible by remember { mutableStateOf(announce) }
@@ -63,7 +63,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory))
.setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply {
setMediaItem(channelMediaItem(plan))
if (scheduled != null && plan.convertedFromMs == null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
if (scheduled != null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
prepare(); playWhenReady = true
}
}
@@ -93,7 +93,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
LaunchedEffect(playerRetries) {
if (playerRetries > 0 && scheduled != null) {
delay(800)
if (plan.convertedFromMs == null) player.seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
player.seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
player.prepare()
}
}
@@ -115,15 +115,13 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
}
lifecycle.addObserver(observer)
// A player handed over from the picture is already on schedule and may never report ready again.
var joinedSchedule = runningPlayer != null || plan.convertedFromMs != null
var joinedSchedule = runningPlayer != null
if (player.playbackState == Player.STATE_READY) ready = true
playing = player.isPlaying
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
android.util.Log.w("OrbitSchedule", "channel=${scheduled?.channel?.number} playerError=${error.errorCodeName} retry=$playerRetries")
// A decoder can be taken away during a quick channel change, so a channel tries again before giving up.
// A decoder fault repeats on the same file, so one try, then ask the provider to convert it.
if (scheduled != null && plan.convertedFromMs == null && onCantDecode != null && playerRetries >= 1) { ready = false; onCantDecode(); return }
if (scheduled != null && playerRetries < MAX_PLAYER_RETRIES) {
ready = false
playerRetries++
@@ -140,12 +138,6 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
}
override fun onIsPlayingChanged(isPlaying: Boolean) { playing = isPlaying }
override fun onTracksChanged(tracks: Tracks) {
val video = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO }
// Sound with no picture: the box has no decoder for this file's video.
if (video.isNotEmpty() && video.none { it.isSupported }) {
android.util.Log.w("OrbitSchedule", "channel=${scheduled?.channel?.number} noVideoDecoder=${video.first().getTrackFormat(0).sampleMimeType}")
if (scheduled != null && plan.convertedFromMs == null) onCantDecode?.invoke()
}
if (subtitleIndex == null) {
val available = textTracks()
subtitleFor(subtitlePreference?.value, available.map { it.getTrackFormat(0).language.orEmpty() })?.let { chooseSubtitle(it, available) }
@@ -8,17 +8,7 @@ import java.net.URLEncoder
internal class PlaybackSubtitle(val url: String, val mime: String, val language: String?, val label: String)
internal class EmbyPlaybackPlan(val item: SampleItem, val url: String, val sourceId: String,
val playSessionId: String, val subtitles: List<PlaybackSubtitle>, val durationMs: Long? = item.durationMs,
/** Set when the provider is converting this for the box. The stream then starts here, and can't be seeked. */
val convertedFromMs: Long? = null)
/** The conversion address in a playback answer, if the provider offered one. */
internal fun convertedStreamPath(info: JSONObject): String? {
if (!info.isNull("ErrorCode") && !info.optString("ErrorCode").let { it.isBlank() || it == "0" || it == "None" }) return null
val sources = info.optJSONArray("MediaSources") ?: return null
return (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) }
.firstNotNullOfOrNull { source -> source.optString("TranscodingUrl").takeIf { it.isNotBlank() && it != "null" } }
}
val playSessionId: String, val subtitles: List<PlaybackSubtitle>, val durationMs: Long? = item.durationMs)
internal object PlaybackUrls {
private val origin = URI(HttpsEmbyTransport.SERVER)
@@ -92,25 +82,6 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
pause()
prepare(item, preferredSourceId = sourceId)
}
/** For a file this box can't decode: asks the provider to convert it, starting at [startMs] into the programme. Null if it won't. */
fun prepareConverted(item: SampleItem, sourceId: String, startMs: Long): EmbyPlaybackPlan? {
if (!EmbyClient.validId(item.id) || !EmbyClient.validId(sourceId)) return null
val request = JSONObject().put("UserId", userId).put("IsPlayback", true).put("MediaSourceId", sourceId)
.put("EnableDirectPlay", false).put("EnableDirectStream", false).put("EnableTranscoding", true).put("AutoOpenLiveStream", false)
.put("StartTimeTicks", startMs.coerceAtLeast(0) * 10_000).put("MaxStreamingBitrate", 8_000_000)
.put("DeviceProfile", JSONObject().put("Name", "Orbit TV conversion").put("MaxStreamingBitrate", 8_000_000)
.put("DirectPlayProfiles", JSONArray())
.put("TranscodingProfiles", JSONArray().put(JSONObject().put("Type", "Video").put("Container", "ts").put("Protocol", "http")
.put("VideoCodec", "h264").put("AudioCodec", "aac").put("Context", "Streaming"))))
val response = transport.execute(EmbyRequest("POST", "/Items/${item.id}/PlaybackInfo", request.toString(), currentToken(), PLAYBACK_INFO_TIMEOUT_MS))
if (response.status !in 200..299) return null
val info = EmbyClient.parse(response.body)
val path = convertedStreamPath(info) ?: return null
val url = try { mediaUrl(path) } catch (_: EmbyCheckException) { return null }
val playId = (if (info.isNull("PlaySessionId")) "" else info.optString("PlaySessionId")).ifBlank { java.util.UUID.randomUUID().toString() }
if (!EmbyClient.validId(playId)) return null
return EmbyPlaybackPlan(item, url, sourceId, playId, emptyList(), item.durationMs, startMs)
}
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.")
@@ -110,22 +110,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
var preparedSlot by remember(channel) { mutableStateOf(handedNow?.startMs) }
var problem by remember(channel) { mutableStateOf<String?>(null) }
var retryAttempt by remember(channel) { mutableIntStateOf(0) }
// The programme this box couldn't decode. Only that programme is converted, and it resets when the next one starts.
var convertFor by remember(channel) { mutableStateOf<Long?>(null) }
// A new wall-clock slot disposes the old player before opening its successor.
LaunchedEffect(convertFor) {
val slot = convertFor ?: return@LaunchedEffect
if (slot != current.startMs) return@LaunchedEffect
prepared = null; preparedSlot = null
val converted = withContext(Dispatchers.IO) {
try { session.prepareConverted(current.media.item, current.media.sourceId, current.offsetAt(now)) }
catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { null }
}
android.util.Log.i("OrbitSchedule", "channel=${channel.number} converted=${converted != null}")
if (converted != null) { preparedSlot = current.startMs; prepared = converted }
else problem = "This programme won't play on this box. Orbit moves on at ${clock(current.endMs)}."
}
LaunchedEffect(channel, current.startMs, retryAttempt) {
if (handed != null && prepared === handed.plan && preparedSlot == current.startMs) return@LaunchedEffect
prepared = null; preparedSlot = null; problem = null
@@ -147,12 +132,11 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
// Suppress the preceding plan immediately at a boundary while the effect schedules its replacement.
val plan = prepared?.takeIf { it.item.id == current.media.item.id && preparedSlot == current.startMs && problem == null }
if (plan != null) {
key(current.startMs, plan) {
key(current.startMs) {
val announce = remember { announcedChannel[0] != channel.number }
SideEffect { announcedChannel[0] = channel.number }
EmbyPlaybackDialog(session, plan, onClose,
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim(), announce = announce,
onCantDecode = if (convertFor == current.startMs || clockOffsetMs != 0L) null else ({ convertFor = current.startMs }),
onHandBack = { running ->
if (handoff != null && clockOffsetMs == 0L) { handoff.returned = LiveStream(channel.number, current.startMs, plan, running); true } else false
})
@@ -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.60.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.59.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()) {
@@ -177,12 +177,4 @@ class EmbyPlaybackTest {
assertTrue(profile.getString("Container").split(",").contains("3gp"))
}
@org.junit.Test fun aConversionAddressIsOnlyTakenWhenOffered() {
val offered = org.json.JSONObject().put("MediaSources", org.json.JSONArray().put(org.json.JSONObject().put("Id", "a")).put(org.json.JSONObject().put("TranscodingUrl", "/videos/x/stream.ts?a=1")))
org.junit.Assert.assertEquals("/videos/x/stream.ts?a=1", convertedStreamPath(offered))
org.junit.Assert.assertNull(convertedStreamPath(org.json.JSONObject().put("MediaSources", org.json.JSONArray().put(org.json.JSONObject().put("Id", "a")))))
org.junit.Assert.assertNull(convertedStreamPath(org.json.JSONObject().put("ErrorCode", "NotAllowed").put("MediaSources", org.json.JSONArray().put(org.json.JSONObject().put("TranscodingUrl", "/x")))))
org.junit.Assert.assertNull(convertedStreamPath(org.json.JSONObject()))
}
}