From daa3adbf71e86da34de63e091650cd21117378c0 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sun, 1 Nov 2026 23:42:35 +0000 Subject: [PATCH] Keep looking for a preset's shows until three have episodes (0.37.0) Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.37.0.md | 19 +++++++++++++++++++ android/app/build.gradle.kts | 4 ++-- .../java/tv/orbit/classic/ChannelPresets.kt | 10 +++++++--- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyPlaybackProtocol.kt | 14 ++++++++------ .../java/tv/orbit/classic/EpisodeDiscovery.kt | 8 ++++---- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../tv/orbit/classic/ChannelPresetsTest.kt | 12 ++++++++++++ 8 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 VERIFICATION-0.37.0.md diff --git a/VERIFICATION-0.37.0.md b/VERIFICATION-0.37.0.md new file mode 100644 index 0000000..cf40d23 --- /dev/null +++ b/VERIFICATION-0.37.0.md @@ -0,0 +1,19 @@ +# Orbit TV 0.37.0 verification + +Paul's Terra build found three shows, but Paradox stopped at two (Buffy and Supernatural). + +## Changed +- A show only counts towards a channel's three once it has episodes to schedule, so a shortfall moves on to the next show. +- Each show now reads up to 40 episodes and keeps the first five regular ones, in case specials sort first. +- A show found without usable episodes is now reported as not found. It used to vanish silently. +- The Paradox search is X-Files rather than The X-Files, in case the library files it differently. + +## Likely cause, not confirmed +Reading the code, The X-Files probably took one of the three places and was then dropped for having no regular episodes in its first five. That would also explain why the build never tried Fringe or Doctor Who. Paul's library wasn't inspected, so this is unproven until a rebuild. + +## Checked +- assembleDebug, testDebugUnitTest and lintDebug pass; 151 tests, 0 failures. +- APK signature verified with apksigner. + +## Not checked +- Not yet run on the box. Paradox needs rebuilding there to see what it finds. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 72f21aa..7c73331 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 = 37 - versionName = "0.36.0-paradox-terra" + versionCode = 38 + versionName = "0.37.0-preset-shows" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt b/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt index 5d7e814..1daf8e9 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt @@ -5,7 +5,7 @@ internal data class ChannelPreset(val number: Int, val name: String, val searche internal val ChannelPresets = listOf( ChannelPreset(140, "Terra", listOf("Blue Planet", "Planet Earth", "Frozen Planet", "Life", "Our Planet")), - ChannelPreset(152, "Paradox", listOf("The X-Files", "Buffy the Vampire Slayer", "Supernatural", "Fringe", "Doctor Who"))) + ChannelPreset(152, "Paradox", listOf("X-Files", "Buffy the Vampire Slayer", "Supernatural", "Fringe", "Doctor Who"))) /** What a build used, so the viewer can see what the library did and didn't have. */ internal class PresetBuild(val channel: SampleChannel, val shows: List, val missing: List) @@ -20,7 +20,11 @@ internal fun bestSeriesMatch(requested: String, candidates: List): List = groups.map { group -> - group.copy(episodes = group.episodes.filter { (it.season ?: 0) >= 1 && (it.episode ?: 0) >= 1 }.distinctBy { it.season to it.episode }) + group.copy(episodes = group.episodes.filter { (it.season ?: 0) >= 1 && (it.episode ?: 0) >= 1 }.distinctBy { it.season to it.episode } + .sortedWith(compareBy { it.season }.thenBy { it.episode }).take(5)) }.filter { it.episodes.isNotEmpty() } 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 e120d62..ae76a37 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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.36.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.37.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/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index b3f62ea..59ea503 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -158,17 +158,19 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v } /** Finds the preset's shows in the library and opens each episode once to confirm it plays. An episode that won't open is skipped. */ fun buildPresetChannel(preset: ChannelPreset, checkActive: () -> Unit = {}): PresetBuild { - val found = mutableListOf() + val groups = mutableListOf() val missing = mutableListOf() + // Only a show with episodes to schedule counts towards the three, so a shortfall moves on to the next show. for (search in preset.searches) { - if (found.size == 3) break + if (groups.size == 3) break checkActive() val match = bestSeriesMatch(search, searchSeries(transport, userId, ::currentToken, search).candidates) - if (match != null && found.none { it.id == match.id }) found += match else missing += search + val usable = match?.takeIf { series -> groups.none { it.series.id == series.id } }?.let { series -> + regularEpisodes(discoverSeriesEpisodes(transport, userId, ::currentToken, listOf(series), checkActive, PRESET_EPISODE_READ)).singleOrNull() + } + if (usable != null) groups += usable else missing += search } - if (found.isEmpty()) throw EmbyCheckException("None of ${preset.name}'s shows were found in your library.") - val groups = regularEpisodes(discoverSeriesEpisodes(transport, userId, ::currentToken, found, checkActive)) - if (groups.isEmpty()) throw EmbyCheckException("${preset.name}'s shows have no numbered episodes to schedule.") + if (groups.isEmpty()) throw EmbyCheckException("None of ${preset.name}'s shows were found with episodes to schedule.") val ordered = toonamiEpisodeOrder(groups) channelEpisodes = channelEpisodes + ordered.associateBy { it.id } val media = ordered.mapNotNull { item -> diff --git a/android/app/src/main/java/tv/orbit/classic/EpisodeDiscovery.kt b/android/app/src/main/java/tv/orbit/classic/EpisodeDiscovery.kt index cc0902c..f72f43f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EpisodeDiscovery.kt +++ b/android/app/src/main/java/tv/orbit/classic/EpisodeDiscovery.kt @@ -5,17 +5,17 @@ internal data class SeriesEpisodes(val series: SeriesCandidate, val episodes: Li /** Metadata only: three approved series, one page of five episodes per series. */ internal fun discoverSeriesEpisodes(transport: EmbyTransport, userId: String, token: () -> String, - approved: List, checkActive: () -> Unit = {}): List { + approved: List, checkActive: () -> Unit = {}, limit: Int = 5): List { require(EmbyClient.validId(userId)) require(approved.size in 1..3 && approved.map { it.id }.distinct().size == approved.size) require(approved.all { EmbyClient.validId(it.id) }) return approved.map { series -> checkActive() - val response = transport.execute(EmbyRequest("GET", "/Shows/${series.id}/Episodes?UserId=$userId&StartIndex=0&Limit=5&Fields=MediaStreams&EnableImages=false&EnableUserData=false", token = token())) + val response = transport.execute(EmbyRequest("GET", "/Shows/${series.id}/Episodes?UserId=$userId&StartIndex=0&Limit=$limit&Fields=MediaStreams&EnableImages=false&EnableUserData=false", token = token())) EmbyClient.expectSuccess(response.status, false) val body = EmbyClient.parse(response.body) val records = body.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return an episode sample.") - val episodes = (0 until minOf(records.length(), 5)).mapNotNull { index -> + val episodes = (0 until minOf(records.length(), limit)).mapNotNull { index -> val item = records.optJSONObject(index) ?: return@mapNotNull null val id = item.optString("Id") if (!EmbyClient.validId(id) || item.optString("Type") != "Episode") return@mapNotNull null @@ -26,7 +26,7 @@ internal fun discoverSeriesEpisodes(transport: EmbyTransport, userId: String, to item.optInt("IndexNumber", -1).takeIf { it >= 0 }, (item.optLong("RunTimeTicks", 0) / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS }, episodeMediaLabel(item)) }.distinctBy { it.id }.sortedWith(compareBy { it.season ?: Int.MAX_VALUE }.thenBy { it.episode ?: Int.MAX_VALUE }.thenBy { it.id }) - SeriesEpisodes(series, episodes, body.optLong("TotalRecordCount", records.length().toLong()) > 5) + SeriesEpisodes(series, episodes, body.optLong("TotalRecordCount", records.length().toLong()) > limit) } } 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 8f02b50..606de22 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.36.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.37.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/ChannelPresetsTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt index 6f11688..6e9d957 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt @@ -70,4 +70,16 @@ class ChannelPresetsTest { saved.getJSONArray("extra").getJSONObject(0).put("number", 999) assertThrows(Exception::class.java) { RememberedStateCodec.decode(saved.toString().toByteArray()) } } + + @Test + fun onlyTheFirstFiveRegularEpisodesAreKept() { + val show = series("a", "Fixture") + val episodes = listOf(EpisodeCandidate("s", "Special", 0, 1, 60_000)) + (8 downTo 1).map { EpisodeCandidate("e$it", "E$it", 1, it, 60_000) } + assertEquals(listOf("e1", "e2", "e3", "e4", "e5"), regularEpisodes(listOf(SeriesEpisodes(show, episodes, false))).single().episodes.map { it.id }) + } + + @Test + fun aSearchWithoutTheFindsTheFullTitle() { + assertEquals("a", bestSeriesMatch("X-Files", listOf(series("a", "The X-Files", 1993)))?.id) + } }