Leave out files the box can't decode, and remember ones that fail (0.61.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# Orbit TV 0.61.0 verification
|
||||
|
||||
Handling files this box can't play, without transcoding, which Paul's Emby server doesn't allow.
|
||||
|
||||
## Changed
|
||||
- 0.60's transcoding fallback is removed. It never reached the box.
|
||||
- When reading a show or film, the app compares each file's video format with the decoders on this box, and leaves out what it can't decode. Unknown formats are kept.
|
||||
- A file that fails again after one retry, or plays with no video decoder, is recorded and never planned again. The channel shows "This programme won't play on this box. Orbit moves on at..." until the next programme.
|
||||
- The small picture shows artwork instead of sound with no picture. A passing fault there doesn't record the file.
|
||||
- Each channel reads its list once more, to apply the format check.
|
||||
|
||||
## Checked
|
||||
- assembleDebug, testDebugUnitTest and lintDebug pass; 246 tests, 0 failures. Tests cover format matching, leaving out files and the failed list.
|
||||
- Reproduced on the box with 0.59: Transformers S1 E1 fails every time, c2.android.mp3.decoder Error 0xe.
|
||||
- APK signature verified with apksigner.
|
||||
|
||||
## Not checked
|
||||
- Not yet run on the box. Forensic Files' video format is unconfirmed, and this build logs it.
|
||||
- Programmes already in a plan stay there until the plan rolls on, so a bad file can still come up in the current week. A rebuild would clear them.
|
||||
- There's no way yet to give a failed file another chance.
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 60
|
||||
versionName = "0.59.0-playback-retry"
|
||||
versionCode = 62
|
||||
versionName = "0.61.0-unplayable-files"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import android.media.MediaCodecList
|
||||
|
||||
/** What Emby calls a video format, as the box's decoders name it. A format not listed here is given the benefit of the doubt. */
|
||||
internal fun videoMimeFor(codec: String): String? = when (codec.trim().lowercase(java.util.Locale.ROOT)) {
|
||||
"h264", "avc" -> "video/avc"
|
||||
"hevc", "h265" -> "video/hevc"
|
||||
"vp8" -> "video/x-vnd.on2.vp8"
|
||||
"vp9" -> "video/x-vnd.on2.vp9"
|
||||
"av1" -> "video/av01"
|
||||
"mpeg2video", "mpeg2" -> "video/mpeg2"
|
||||
"mpeg4", "msmpeg4v3", "divx", "xvid" -> "video/mp4v-es"
|
||||
"h263" -> "video/3gpp"
|
||||
"vc1", "wmv3" -> "video/wvc1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** The video format of a media source, if it says. */
|
||||
internal fun sourceVideoCodec(source: org.json.JSONObject): String? {
|
||||
val streams = source.optJSONArray("MediaStreams") ?: return null
|
||||
return (0 until minOf(streams.length(), 32)).mapNotNull { streams.optJSONObject(it) }
|
||||
.firstOrNull { it.optString("Type") == "Video" }?.optString("Codec")?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/** The decoders this box actually has, read once. */
|
||||
internal object BoxDecoders {
|
||||
private val types by lazy {
|
||||
MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.filterNot { it.isEncoder }.flatMap { it.supportedTypes.asList() }.map { it.lowercase(java.util.Locale.ROOT) }.toSet()
|
||||
}
|
||||
fun canDecode(codec: String): Boolean = videoMimeFor(codec)?.let { it in types } ?: true
|
||||
}
|
||||
@@ -99,7 +99,7 @@ internal data class ChannelPlan(val entries: List<ScheduleEntry>, val tail: Sche
|
||||
}
|
||||
|
||||
/** Bump when the way pools are read changes, so each channel reads its once again. */
|
||||
private const val CHANNEL_POOL_VERSION = 2
|
||||
private const val CHANNEL_POOL_VERSION = 3
|
||||
|
||||
internal const val SCHEDULE_AHEAD_MS = 7 * 24 * 60 * 60_000L
|
||||
internal const val SCHEDULE_TOP_UP_MS = 6 * 24 * 60 * 60_000L
|
||||
@@ -131,6 +131,10 @@ internal fun turnOrder(pool: List<PoolSeries>, last: String?): List<PoolSeries>
|
||||
return (0 until pool.size).map { pool[(after + it) % pool.size] }
|
||||
}
|
||||
|
||||
/** A pool without files known not to play. A show with nothing left drops out. */
|
||||
internal fun withoutFailed(pool: List<PoolSeries>, failed: Set<String>): List<PoolSeries> =
|
||||
if (failed.isEmpty()) pool else pool.map { show -> show.copy(episodes = show.episodes.filter { it.id !in failed }) }.filter { it.episodes.isNotEmpty() }
|
||||
|
||||
/** The next show after [last] in the rota that [eligible] allows. */
|
||||
internal fun nextInTurn(pool: List<PoolSeries>, last: String?, eligible: (PoolSeries) -> Boolean): PoolSeries {
|
||||
return turnOrder(pool, last).first(eligible)
|
||||
|
||||
@@ -26,6 +26,11 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
|
||||
store.saveProgress(userId, number, ScheduleStore.SavedProgress(enabled, progress?.aired ?: ScheduleCursor()))
|
||||
}
|
||||
|
||||
/** A file that wouldn't play. It stays in what's already planned, and is left out from here on. */
|
||||
fun markFailed(userId: String, itemId: String) {
|
||||
store.saveFailed(userId, store.loadFailed(userId) + itemId)
|
||||
}
|
||||
|
||||
/** Starts a channel that has no loop yet: reads its pool, then gives it a small fallback made from that pool. */
|
||||
fun seedLoop(session: EmbySampleSession, userId: String, programming: ChannelProgramming, checkActive: () -> Unit = {}): SampleChannel {
|
||||
val choices = store.loadChoices(userId, programming.number)
|
||||
@@ -69,7 +74,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
|
||||
val base = progress?.aired ?: ScheduleCursor()
|
||||
// Other channels' plans are only read if this one is actually about to grow.
|
||||
val others by lazy { clashesWith(SmartChannels.filter { it.number != number }.mapNotNull { store.loadPlan(userId, it.number) }) }
|
||||
val plan = topUpPlan(existing, base, pool?.series, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
|
||||
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
|
||||
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
|
||||
if (plan !== existing) store.savePlan(userId, number, plan)
|
||||
store.saveProgress(userId, number, ScheduleStore.SavedProgress(progress?.enabled ?: true, airedCursor(base, plan.entries, now)))
|
||||
|
||||
@@ -34,10 +34,10 @@ internal const val SUGGESTION_MAX_PAGES = 3
|
||||
internal class SuggestionRead(val suggestions: List<Suggestion>, val requests: Int)
|
||||
|
||||
/** The first playable copy of a title, as (source id, runtime). */
|
||||
internal fun playableSource(row: org.json.JSONObject): Pair<String, Long>? {
|
||||
internal fun playableSource(row: org.json.JSONObject, canDecode: (String) -> Boolean = { true }): Pair<String, Long>? {
|
||||
val sources = row.optJSONArray("MediaSources") ?: return null
|
||||
val source = (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) }
|
||||
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) } ?: return null
|
||||
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) && sourceVideoCodec(it)?.let(canDecode) != false } ?: return null
|
||||
val ticks = source.optLong("RunTimeTicks", 0).takeIf { it > 0 } ?: row.optLong("RunTimeTicks", 0)
|
||||
val duration = (ticks / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } ?: return null
|
||||
return source.getString("Id") to duration
|
||||
@@ -45,7 +45,7 @@ internal fun playableSource(row: org.json.JSONObject): Pair<String, Long>? {
|
||||
|
||||
/** Highest rated first, in bounded pages, one rule at a time. A title is never suggested twice, and a rejected one never again. */
|
||||
internal fun findSuggestions(transport: EmbyTransport, userId: String, token: () -> String, rules: List<SuggestionRule>,
|
||||
alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}): SuggestionRead {
|
||||
alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}, canDecode: (String) -> Boolean = { true }): SuggestionRead {
|
||||
require(EmbyClient.validId(userId))
|
||||
val found = LinkedHashMap<String, Suggestion>()
|
||||
val seen = mutableSetOf<String>()
|
||||
@@ -78,7 +78,7 @@ internal fun findSuggestions(transport: EmbyTransport, userId: String, token: ()
|
||||
if (score > rule.maxRating) continue
|
||||
if (row.optString("OfficialRating") in rule.excludeRatings) continue
|
||||
if (!seen.add("${seriesKey(title)}#$year")) continue
|
||||
val source = if (rule.type == "Movie") playableSource(row) ?: continue else null
|
||||
val source = if (rule.type == "Movie") playableSource(row, canDecode) ?: continue else null
|
||||
if (source != null && source.second < rule.minMinutes * 60_000L) continue
|
||||
val reason = "${rule.label}: ${rule.genres.joinToString(" and ")}, ${year ?: "year unknown"}, rated ${"%.1f".format(java.util.Locale.UK, score)}"
|
||||
found[id] = Suggestion(id, title, year, rule.type, rule.group, reason, source?.first, source?.second)
|
||||
|
||||
@@ -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.61.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -278,6 +278,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
val ready = !busy && !restoring && now >= nextAttemptAt && username.isNotBlank() && password.text.isNotEmpty()
|
||||
playback?.let { plan -> session?.let { active -> EmbyPlaybackDialog(active, plan, onClose = { playback = null }) } }
|
||||
val liveSession = session
|
||||
fun rememberUnplayable(itemId: String) {
|
||||
val active = session ?: return
|
||||
scope.launch(Dispatchers.IO) { runCatching { scheduler.markFailed(active.accountId, itemId) } }
|
||||
}
|
||||
val availableChannels = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray())
|
||||
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
|
||||
if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) {
|
||||
@@ -295,13 +299,13 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
val exists = availableChannels.any { it.number == number }
|
||||
if (exists && number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number; channelClockOffset = 0 }
|
||||
exists
|
||||
}, handoff = liveHandoff)
|
||||
}, handoff = liveHandoff, onUnplayable = ::rememberUnplayable)
|
||||
fun tune(number: Int, offset: Long) {
|
||||
if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number }
|
||||
if (offset == 0L) liveHandoff.handOver(number)
|
||||
channelClockOffset = offset; watchingChannel = true
|
||||
}
|
||||
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff) }
|
||||
val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff, onUnplayable = ::rememberUnplayable) }
|
||||
if (mainHome) HomeScreen(availableChannels, liveChannel, modifier, picture,
|
||||
onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onDriveIn = onDriveIn)
|
||||
else GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory.label, picture,
|
||||
|
||||
@@ -35,12 +35,13 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val MAX_PLAYER_RETRIES = 2
|
||||
/** A decoder fault normally repeats on the same file, so one more try is enough. */
|
||||
private const val MAX_PLAYER_RETRIES = 1
|
||||
|
||||
@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, onUnplayable: ((String) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
var tuningNumberVisible by remember { mutableStateOf(announce) }
|
||||
@@ -121,13 +122,14 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
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 can also be taken away by a quick channel change, so a channel tries once more before giving up.
|
||||
if (scheduled != null && playerRetries < MAX_PLAYER_RETRIES) {
|
||||
ready = false
|
||||
playerRetries++
|
||||
return
|
||||
}
|
||||
if (scheduled != null) {
|
||||
onUnplayable?.invoke(plan.item.id)
|
||||
failure = "This programme won't play on this box. Orbit moves on at ${clock(scheduled.programme.endMs)}."
|
||||
ready = false
|
||||
return
|
||||
@@ -138,6 +140,15 @@ 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 can't decode this file's video.
|
||||
if (scheduled != null && video.isNotEmpty() && video.none { it.isSupported }) {
|
||||
android.util.Log.w("OrbitSchedule", "channel=${scheduled.channel.number} noVideoDecoder=${video.first().getTrackFormat(0).sampleMimeType}")
|
||||
player.stop(); ready = false
|
||||
onUnplayable?.invoke(plan.item.id)
|
||||
failure = "This programme won't play on this box. Orbit moves on at ${clock(scheduled.programme.endMs)}."
|
||||
return
|
||||
}
|
||||
if (subtitleIndex == null) {
|
||||
val available = textTracks()
|
||||
subtitleFor(subtitlePreference?.value, available.map { it.getTrackFormat(0).language.orEmpty() })?.let { chooseSubtitle(it, available) }
|
||||
|
||||
@@ -196,7 +196,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
|
||||
if (pool.size >= programming.seriesCount) break
|
||||
checkActive()
|
||||
requests++
|
||||
val film = findFilm(transport, userId, ::currentToken, title) ?: continue
|
||||
val film = findFilm(transport, userId, ::currentToken, title, BoxDecoders::canDecode) ?: continue
|
||||
if (pool.none { it.id == film.id }) pool += film
|
||||
}
|
||||
} else for (search in programming.searches) {
|
||||
@@ -207,7 +207,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
|
||||
val match = bestSeriesMatch(search, if (programming.exactTitles) candidates.filter { seriesKey(it.title) == seriesKey(search) } else candidates) ?: continue
|
||||
// A second copy of a show in the library counts as the same show.
|
||||
if (pool.any { it.id == match.id || seriesKey(it.title) == seriesKey(match.title) }) continue
|
||||
val whole = readWholeSeries(transport, userId, ::currentToken, match, checkActive, onRequest = { requests++ })
|
||||
val whole = readWholeSeries(transport, userId, ::currentToken, match, checkActive, onRequest = { requests++ }, canDecode = BoxDecoders::canDecode)
|
||||
if (whole.episodes.isNotEmpty()) pool += whole
|
||||
}
|
||||
for (accepted in choices.accepted) {
|
||||
@@ -218,14 +218,14 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
|
||||
val duration = accepted.durationMs ?: continue
|
||||
pool += PoolSeries(accepted.id, accepted.title, listOf(PoolEpisode(accepted.id, source, EpisodeMark(1, 1), accepted.title, duration)), accepted.group)
|
||||
} else {
|
||||
val whole = readWholeSeries(transport, userId, ::currentToken, SeriesCandidate(accepted.id, accepted.title, accepted.year, ""), checkActive, onRequest = { requests++ })
|
||||
val whole = readWholeSeries(transport, userId, ::currentToken, SeriesCandidate(accepted.id, accepted.title, accepted.year, ""), checkActive, onRequest = { requests++ }, canDecode = BoxDecoders::canDecode)
|
||||
if (whole.episodes.isNotEmpty()) pool += whole.copy(group = accepted.group)
|
||||
}
|
||||
}
|
||||
return PoolRead(pool, requests)
|
||||
}
|
||||
fun suggestFor(rules: List<SuggestionRule>, alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}) =
|
||||
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive)
|
||||
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive, BoxDecoders::canDecode)
|
||||
fun fetchArtwork(path: String): ByteArray? = (transport as? HttpsEmbyTransport)?.fetchImage(path, currentToken())
|
||||
fun rememberScheduledItems(items: List<SampleItem>) { channelEpisodes = channelEpisodes + items.associateBy { it.id } }
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ internal const val WHOLE_SERIES_TIMEOUT_MS = 60_000
|
||||
|
||||
/** A whole series, read in bounded pages one at a time: regular numbered episodes with a playable source, once each, in order. */
|
||||
internal fun readWholeSeries(transport: EmbyTransport, userId: String, token: () -> String, series: SeriesCandidate,
|
||||
checkActive: () -> Unit = {}, onRequest: () -> Unit = {}): PoolSeries {
|
||||
checkActive: () -> Unit = {}, onRequest: () -> Unit = {}, canDecode: (String) -> Boolean = { true }): PoolSeries {
|
||||
require(EmbyClient.validId(userId) && EmbyClient.validId(series.id))
|
||||
val episodes = mutableListOf<PoolEpisode>()
|
||||
var start = 0
|
||||
@@ -68,28 +68,28 @@ internal fun readWholeSeries(transport: EmbyTransport, userId: String, token: ()
|
||||
EmbyClient.expectSuccess(reply.status, false)
|
||||
val body = EmbyClient.parse(reply.body)
|
||||
val rows = body.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return the episode list.")
|
||||
(0 until rows.length()).forEach { index -> rows.optJSONObject(index)?.let(::poolEpisode)?.let { episodes += it } }
|
||||
(0 until rows.length()).forEach { index -> rows.optJSONObject(index)?.let { poolEpisode(it, canDecode) }?.let { episodes += it } }
|
||||
start += rows.length()
|
||||
if (rows.length() == 0 || start >= body.optInt("TotalRecordCount", 0)) break
|
||||
}
|
||||
return PoolSeries(series.id, series.title, episodes.sortedBy { it.mark }.distinctBy { it.mark })
|
||||
}
|
||||
|
||||
private fun poolEpisode(item: org.json.JSONObject): PoolEpisode? {
|
||||
private fun poolEpisode(item: org.json.JSONObject, canDecode: (String) -> Boolean): PoolEpisode? {
|
||||
val id = item.optString("Id")
|
||||
val season = item.optInt("ParentIndexNumber", -1)
|
||||
val number = item.optInt("IndexNumber", -1)
|
||||
if (item.optString("Type") != "Episode" || !EmbyClient.validId(id) || season < 1 || number < 1) return null
|
||||
val sources = item.optJSONArray("MediaSources") ?: return null
|
||||
val source = (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) }
|
||||
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) } ?: return null
|
||||
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) && sourceVideoCodec(it)?.let(canDecode) != false } ?: return null
|
||||
val ticks = source.optLong("RunTimeTicks", 0).takeIf { it > 0 } ?: item.optLong("RunTimeTicks", 0)
|
||||
val duration = (ticks / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } ?: return null
|
||||
return PoolEpisode(id, source.getString("Id"), EpisodeMark(season, number), EmbyClient.clean(item.optString("Name"), 160).ifBlank { "Untitled episode" }, duration)
|
||||
}
|
||||
|
||||
/** One film by its exact title, oldest first, with a playable source. Near matches don't count, so a sequel never slips in. */
|
||||
internal fun findFilm(transport: EmbyTransport, userId: String, token: () -> String, title: String): PoolSeries? {
|
||||
internal fun findFilm(transport: EmbyTransport, userId: String, token: () -> String, title: String, canDecode: (String) -> Boolean = { true }): PoolSeries? {
|
||||
require(EmbyClient.validId(userId))
|
||||
val term = java.net.URLEncoder.encode(title, "UTF-8")
|
||||
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Movie&SearchTerm=$term&StartIndex=0&Limit=10&SortBy=SortName&SortOrder=Ascending&Fields=MediaSources&EnableImages=false&EnableUserData=false",
|
||||
@@ -105,7 +105,7 @@ internal fun findFilm(transport: EmbyTransport, userId: String, token: () -> Str
|
||||
if (!EmbyClient.validId(id)) return@firstNotNullOfOrNull null
|
||||
val sources = row.optJSONArray("MediaSources") ?: return@firstNotNullOfOrNull null
|
||||
val source = (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) }
|
||||
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) } ?: return@firstNotNullOfOrNull null
|
||||
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) && sourceVideoCodec(it)?.let(canDecode) != false } ?: return@firstNotNullOfOrNull null
|
||||
val ticks = source.optLong("RunTimeTicks", 0).takeIf { it > 0 } ?: row.optLong("RunTimeTicks", 0)
|
||||
val duration = (ticks / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } ?: return@firstNotNullOfOrNull null
|
||||
val name = EmbyClient.clean(row.optString("Name"), 160)
|
||||
|
||||
@@ -61,7 +61,7 @@ internal fun channelMediaItem(plan: EmbyPlaybackPlan): MediaItem = MediaItem.Bui
|
||||
* first frame and stays if the programme can't open. It holds one stream, so it stops when [live] goes false.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, live: Boolean, handoff: LiveHandoff) {
|
||||
internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, live: Boolean, handoff: LiveHandoff, onUnplayable: (String) -> Unit = {}) {
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) }
|
||||
DisposableEffect(lifecycle) {
|
||||
@@ -76,9 +76,11 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li
|
||||
LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } }
|
||||
val current = channel.at(now)
|
||||
var ready by remember(channel.number, current.startMs, live, foreground) { mutableStateOf(false) }
|
||||
// A file that can't play here leaves the artwork up until the next programme.
|
||||
var cantPlay by remember(channel.number, current.startMs) { mutableStateOf(false) }
|
||||
Box(Modifier.fillMaxSize().background(Panel)) {
|
||||
if (live && foreground) key(channel.number, current.startMs) {
|
||||
GuideLiveProgramme(session, channel.number, current, handoff, onReady = { ready = true })
|
||||
if (live && foreground && !cantPlay) key(channel.number, current.startMs) {
|
||||
GuideLiveProgramme(session, channel.number, current, handoff, onReady = { ready = true }, onCantPlay = { id -> cantPlay = true; ready = false; id?.let(onUnplayable) })
|
||||
}
|
||||
val branding = ChannelBrands.forNumber(channel.number)
|
||||
if (!ready) Box(Modifier.matchParentSize().background(Panel)) {
|
||||
@@ -89,7 +91,7 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit) {
|
||||
private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit, onCantPlay: (String?) -> Unit) {
|
||||
val returned = remember {
|
||||
val stream = handoff.returned
|
||||
handoff.returned = null
|
||||
@@ -105,12 +107,12 @@ private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, p
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (_: Exception) { /* The artwork stays. Tuning full screen still explains any problem. */ }
|
||||
}
|
||||
plan?.let { GuideLivePlayer(session, it, channelNumber, programme, handoff, onReady, returned?.claim()) }
|
||||
plan?.let { GuideLivePlayer(session, it, channelNumber, programme, handoff, onReady, onCantPlay, returned?.claim()) }
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@Composable
|
||||
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit, runningPlayer: ExoPlayer? = null) {
|
||||
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit, onCantPlay: (String?) -> Unit, runningPlayer: ExoPlayer? = null) {
|
||||
val context = LocalContext.current
|
||||
var ready by remember { mutableStateOf(false) }
|
||||
val player = remember(session, plan) {
|
||||
@@ -128,6 +130,18 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan,
|
||||
var joinedSchedule = runningPlayer != null
|
||||
if (player.playbackState == Player.STATE_READY) { ready = true; onReady() }
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
android.util.Log.w("OrbitSchedule", "picture channel=$channelNumber playerError=${error.errorCodeName}")
|
||||
// May just be a passing fault, so full screen gets to judge the file.
|
||||
onCantPlay(null)
|
||||
}
|
||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||
val video = tracks.groups.filter { it.type == androidx.media3.common.C.TRACK_TYPE_VIDEO }
|
||||
if (video.isNotEmpty() && video.none { it.isSupported }) {
|
||||
android.util.Log.w("OrbitSchedule", "picture channel=$channelNumber noVideoDecoder=${video.first().getTrackFormat(0).sampleMimeType}")
|
||||
onCantPlay(plan.item.id)
|
||||
}
|
||||
}
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_READY) {
|
||||
if (!joinedSchedule) {
|
||||
|
||||
@@ -90,7 +90,7 @@ internal fun scheduledRuntimeMatches(openedMs: Long?, scheduledMs: Long) = opene
|
||||
|
||||
@Composable
|
||||
internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long,
|
||||
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = null) {
|
||||
onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = null, onUnplayable: ((String) -> Unit)? = null) {
|
||||
// Outlives each programme and channel, and ends when the viewer leaves full screen.
|
||||
val subtitlePreference = remember { mutableStateOf<String?>(null) }
|
||||
var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) }
|
||||
@@ -136,7 +136,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
|
||||
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,
|
||||
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim(), announce = announce, onUnplayable = onUnplayable,
|
||||
onHandBack = { running ->
|
||||
if (handoff != null && clockOffsetMs == 0L) { handoff.returned = LiveStream(channel.number, current.startMs, plan, running); true } else false
|
||||
})
|
||||
|
||||
@@ -99,6 +99,14 @@ internal class ScheduleStore(private val directory: File) {
|
||||
}, root.getLong("fetched"), root.optString("signature"))
|
||||
}
|
||||
|
||||
/** Files that wouldn't play on this box, across every channel. Never planned again. */
|
||||
fun saveFailed(userId: String, ids: Set<String>) =
|
||||
write(file("failed", 0), root(userId, 0).put("items", JSONArray(ids.toList())))
|
||||
fun loadFailed(userId: String): Set<String> = read(file("failed", 0), userId, 0) { root ->
|
||||
val items = root.getJSONArray("items")
|
||||
(0 until items.length()).map { validId(items.getString(it)) }.toSet()
|
||||
}.orEmpty()
|
||||
|
||||
/** Your accepted and rejected suggestions. Kept apart from the schedule, so a rebuild or a damaged plan never loses them. */
|
||||
fun saveChoices(userId: String, number: Int, choices: ChannelChoices) {
|
||||
val accepted = JSONArray()
|
||||
|
||||
@@ -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.61.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()) {
|
||||
|
||||
@@ -481,4 +481,44 @@ class ChannelScheduleTest {
|
||||
val cells = guideCells(channel, start, start + 120 * minute)
|
||||
assertEquals(cells.drop(1), guideWatchableCells(cells, now).drop(1))
|
||||
}
|
||||
|
||||
private fun withVideo(id: String, number: Int, codec: String?) = episode(id, 1, number).also { row ->
|
||||
val source = row.getJSONArray("MediaSources").getJSONObject(0)
|
||||
if (codec != null) source.put("MediaStreams", JSONArray().put(JSONObject().put("Type", "Audio").put("Codec", "aac")).put(JSONObject().put("Type", "Video").put("Codec", codec)))
|
||||
}
|
||||
|
||||
@Test fun episodesTheBoxCantDecodeAreLeftOut() {
|
||||
val rows = JSONArray().put(withVideo("good", 1, "h264")).put(withVideo("bad", 2, "wmv3")).put(withVideo("unknown", 3, null))
|
||||
val transport = EmbyTransport { EmbyReply(200, JSONObject().put("Items", rows).put("TotalRecordCount", rows.length()).toString()) }
|
||||
val result = readWholeSeries(transport, user, { "t" }, series, canDecode = { it != "wmv3" })
|
||||
assertEquals(listOf("good", "unknown"), result.episodes.map { it.id })
|
||||
}
|
||||
|
||||
@Test fun formatNamesMapToDecoders() {
|
||||
assertEquals("video/avc", videoMimeFor("H264"))
|
||||
assertEquals("video/mp4v-es", videoMimeFor("mpeg4"))
|
||||
assertNull(videoMimeFor("something-new"))
|
||||
}
|
||||
|
||||
@Test fun aFailedFileIsLeftOutFromThenOn() {
|
||||
val store = store()
|
||||
store.saveFailed(user, setOf("a-1-1"))
|
||||
val left = withoutFailed(pool, store.loadFailed(user))
|
||||
assertFalse(left.flatMap { it.episodes }.any { it.id == "a-1-1" })
|
||||
assertEquals(pool.size, left.size)
|
||||
assertEquals(pool, withoutFailed(pool, emptySet()))
|
||||
}
|
||||
|
||||
@Test fun aShowWithNothingLeftDropsOut() {
|
||||
val only = listOf(show("z", 1, 1), show("y", 1, 1))
|
||||
assertEquals(listOf("y"), withoutFailed(only, setOf("z-1-1")).map { it.id })
|
||||
}
|
||||
|
||||
@Test fun failedFilesSurviveAndAreEmptyByDefault() {
|
||||
val store = store()
|
||||
assertTrue(store.loadFailed(user).isEmpty())
|
||||
store.saveFailed(user, setOf("a"))
|
||||
store.saveFailed(user, store.loadFailed(user) + "b")
|
||||
assertEquals(setOf("a", "b"), store.loadFailed(user))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user