Ask the provider to convert programmes the box can't decode (0.60.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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. The earlier fault's cause, a quick-zap decoder clash, is likely but not proven.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 60
|
||||
versionName = "0.59.0-playback-retry"
|
||||
versionCode = 61
|
||||
versionName = "0.60.0-convert-fallback"
|
||||
}
|
||||
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.59.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.60.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) {
|
||||
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) {
|
||||
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) seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
|
||||
if (scheduled != null && plan.convertedFromMs == 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)
|
||||
player.seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
|
||||
if (plan.convertedFromMs == null) player.seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
|
||||
player.prepare()
|
||||
}
|
||||
}
|
||||
@@ -115,13 +115,15 @@ 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
|
||||
var joinedSchedule = runningPlayer != null || plan.convertedFromMs != 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++
|
||||
@@ -138,6 +140,12 @@ 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,7 +8,17 @@ 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)
|
||||
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" } }
|
||||
}
|
||||
|
||||
internal object PlaybackUrls {
|
||||
private val origin = URI(HttpsEmbyTransport.SERVER)
|
||||
@@ -82,6 +92,25 @@ 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,7 +110,22 @@ 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
|
||||
@@ -132,11 +147,12 @@ 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) {
|
||||
key(current.startMs, plan) {
|
||||
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.59.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.60.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,4 +177,12 @@ 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()))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user