diff --git a/VERIFICATION-0.59.0.md b/VERIFICATION-0.59.0.md index 5c40039..537ecf0 100644 --- a/VERIFICATION-0.59.0.md +++ b/VERIFICATION-0.59.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 944d236..cd0f0b1 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 = 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 } 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 84e4d1d..abae2a1 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index 01c6ab4..e778278 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -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? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null) { + onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null, onCantDecode: (() -> Unit)? = null) { val context = LocalContext.current var failure by remember { mutableStateOf(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) } 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 9e41b5e..e1446e2 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -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, val durationMs: Long? = item.durationMs) + val playSessionId: String, val subtitles: List, 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.") diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 85e4cdc..2f75d90 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -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(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(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 }) 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 52cc41f..b793b88 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.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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/EmbyPlaybackTest.kt b/android/app/src/test/java/tv/orbit/classic/EmbyPlaybackTest.kt index 1fd0bf6..8867b41 100644 --- a/android/app/src/test/java/tv/orbit/classic/EmbyPlaybackTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/EmbyPlaybackTest.kt @@ -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())) + } }