Keep looking for a preset's shows until three have episodes (0.37.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m47s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m27s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 23:42:35 +00:00
co-authored by Claude Opus 5.5
parent 2dc57df85f
commit daa3adbf71
8 changed files with 54 additions and 17 deletions
+19
View File
@@ -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.
+2 -2
View File
@@ -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 }
@@ -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<String>, val missing: List<String>)
@@ -20,7 +20,11 @@ internal fun bestSeriesMatch(requested: String, candidates: List<SeriesCandidate
?: candidates.filter { seriesKey(it.title).startsWith("$key ") }.minWithOrNull(oldest)
}
/** Keeps numbered regular episodes, once each. A show with none is left out rather than failing the channel. */
/** How many episodes to read per show. Specials can sort first, so five wouldn't always reach season one. */
internal const val PRESET_EPISODE_READ = 40
/** The first five numbered regular episodes, once each. A show with none is left out rather than failing the channel. */
internal fun regularEpisodes(groups: List<SeriesEpisodes>): List<SeriesEpisodes> = 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<EpisodeCandidate> { it.season }.thenBy { it.episode }).take(5))
}.filter { it.episodes.isNotEmpty() }
@@ -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)
@@ -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<SeriesCandidate>()
val groups = mutableListOf<SeriesEpisodes>()
val missing = mutableListOf<String>()
// 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 ->
@@ -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<SeriesCandidate>, checkActive: () -> Unit = {}): List<SeriesEpisodes> {
approved: List<SeriesCandidate>, checkActive: () -> Unit = {}, limit: Int = 5): List<SeriesEpisodes> {
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<EpisodeCandidate> { 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)
}
}
@@ -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()) {
@@ -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)
}
}