Add Terra and Paradox, built from named shows in the library (0.36.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m52s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m23s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 23:34:40 +00:00
co-authored by Claude Opus 5.5
parent 021cfa2a64
commit 2dc57df85f
17 changed files with 215 additions and 31 deletions
+18
View File
@@ -0,0 +1,18 @@
# Orbit TV 0.36.0 verification
Two new channels, built by the app from Paul's library. Includes 0.35's bar fix and scrolling guide, which weren't installed on their own.
## Changed
- 140 Orbit Terra (Documentaries) and 152 Orbit Paradox (Entertainment), with their approved logos and backgrounds. The numbers come from the design and are placeholders.
- A build button for each in Connect Emby, behind the gear. It searches for the preset's shows (Terra: Blue Planet, Planet Earth, Frozen Planet, Life, Our Planet; Paradox: The X-Files, Buffy the Vampire Slayer, Supernatural, Fringe, Doctor Who), uses the first three found, takes up to five regular episodes of each, and opens each once to confirm it plays. It then reports what it used and what wasn't found.
- The saved settings file gains an optional list of extra channels. Files saved before this still open, and channels 101 to 103 aren't touched. The catalogue database doesn't change.
- Building a channel no longer forgets Vortex's episodes until the next restart, which would have stopped Vortex tuning.
## Checked
- assembleDebug, testDebugUnitTest and lintDebug pass; 149 tests, 0 failures. Tests cover show matching, episode filtering, saving and restoring the new channels, and opening an older saved file.
- APK signature verified with apksigner.
## Not checked
- Not yet run on the box. Which shows Paul's library has, and whether matching picks the right versions, can only be seen there.
- Going back to an older build after saving with Terra or Paradox selected would make that build reject the saved setup and ask for sign-in.
- These are proof channels: at most 15 episodes, looping. Rotation and rolling schedules are later roadmap work.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 36
versionName = "0.35.0-guide-scroll"
versionCode = 37
versionName = "0.36.0-paradox-terra"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -6,7 +6,9 @@ internal object ChannelBrands {
private val brands = mapOf(
101 to ChannelBranding("orbit-1", "Orbit 1", R.drawable.orbit_one_epg, R.drawable.orbit_one_background, R.drawable.orbit_one_bug, ChannelCategory.Entertainment),
102 to ChannelBranding("orbit-after-dark", "Orbit After Dark", R.drawable.orbit_after_dark_epg, R.drawable.orbit_after_dark_background, R.drawable.orbit_after_dark_bug, ChannelCategory.Movies),
103 to ChannelBranding("vortex", "Vortex", R.drawable.vortex_epg, R.drawable.vortex_background, R.drawable.vortex_bug, ChannelCategory.Entertainment))
103 to ChannelBranding("vortex", "Vortex", R.drawable.vortex_epg, R.drawable.vortex_background, R.drawable.vortex_bug, ChannelCategory.Entertainment),
140 to ChannelBranding("orbit-terra", "Orbit Terra", R.drawable.orbit_terra_epg, R.drawable.orbit_terra_background, R.drawable.orbit_terra_bug, ChannelCategory.Documentaries),
152 to ChannelBranding("paradox", "Orbit Paradox", R.drawable.paradox_epg, R.drawable.paradox_background, R.drawable.paradox_bug, ChannelCategory.Entertainment))
fun forNumber(number: Int): ChannelBranding? = brands[number]
/** Saved channels keep their original names; what the viewer sees comes from the brand. */
fun displayName(channel: SampleChannel) = brands[channel.number]?.displayName ?: channel.name
@@ -0,0 +1,26 @@
package tv.orbit.classic
/** A channel the app can build from named shows. Numbers follow the design and may change with the generic channel list. */
internal data class ChannelPreset(val number: Int, val name: String, val searches: List<String>)
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")))
/** 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>)
private fun seriesKey(title: String) = title.lowercase(java.util.Locale.ROOT).replace(Regex("[^a-z0-9 ]"), " ").trim().removePrefix("the ").replace(Regex(" +"), " ")
/** The same title, or else the title with something after it, such as Blue Planet II. Oldest first, so an original beats its revival. */
internal fun bestSeriesMatch(requested: String, candidates: List<SeriesCandidate>): SeriesCandidate? {
val key = seriesKey(requested)
val oldest = compareBy<SeriesCandidate> { it.year ?: Int.MAX_VALUE }
return candidates.filter { seriesKey(it.title) == key }.minWithOrNull(oldest)
?: 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. */
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 })
}.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.35.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.36.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -85,6 +85,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
var lastNumber by remember { mutableStateOf<Int?>(null) }
var afterDarkChannel by remember { mutableStateOf<SampleChannel?>(null) }
var toonamiChannel by remember { mutableStateOf<SampleChannel?>(null) }
var extraChannels by remember { mutableStateOf<List<SampleChannel>>(emptyList()) }
var selectedNumber by remember { mutableIntStateOf(101) }
var channel by remember { mutableStateOf<SampleChannel?>(null) }
var watchingChannel by remember { mutableStateOf(false) }
@@ -108,9 +109,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
fun clearPassword() { password.edit { replace(0, length, "") } }
fun applyChoices(saved: RememberedState) {
seriesMatches = saved.matches.takeIf { it.isNotEmpty() }; approvedSeries = saved.approved
episodeSamples = saved.episodes; channel = saved.orbitOne; toonamiChannel = saved.toonami
episodeSamples = saved.episodes; channel = saved.orbitOne; toonamiChannel = saved.toonami; extraChannels = saved.extra
darkMovies = saved.darkMovies; approvedDark = saved.approvedDark; afterDarkChannel = saved.afterDark
val available = availableSampleChannels(channel, afterDarkChannel, toonamiChannel)
val available = availableSampleChannels(channel, afterDarkChannel, toonamiChannel, *extraChannels.toTypedArray())
selectedNumber = available.firstOrNull { it.number == saved.selectedNumber }?.number ?: available.firstOrNull()?.number ?: 101
onChannelAvailable(available.isNotEmpty())
}
@@ -127,7 +128,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
seriesMatches = null; episodeSamples = null; reviewingSeries = false; approvedSeries = emptySet()
darkMovies = emptyList(); approvedDark = emptySet(); reviewingDark = false; afterDarkChannel = null
metadataCoverage = null; reviewingCoverage = false; reviewingCatalogue = false; catalogueSummary = null; catalogueRun = null; reviewingHealth = false; libraryHealth = null; lastNumber = null
playback = null; session = null; sample = null; channel = null; toonamiChannel = null; selectedNumber = 101; watchingChannel = false
playback = null; session = null; sample = null; channel = null; toonamiChannel = null; extraChannels = emptyList(); selectedNumber = 101; watchingChannel = false
onChannelAvailable(false)
sessionSlot.clear()?.let { active -> EmbyCleanup.run { active.close() } }
}
@@ -185,14 +186,14 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
message = "The saved connection could not be opened. Sign in again."
} finally { restoring = false; onRestoreChecked() }
}
LaunchedEffect(session, seriesMatches, approvedSeries, episodeSamples, channel, toonamiChannel, selectedNumber, restoring, afterDarkChannel, darkMovies, approvedDark) {
LaunchedEffect(session, seriesMatches, approvedSeries, episodeSamples, channel, toonamiChannel, extraChannels, selectedNumber, restoring, afterDarkChannel, darkMovies, approvedDark) {
val active = session
if (active != null && !restoring) {
setupSaved = false
delay(100)
try {
val saved = RememberedState(active.rememberedIdentity(), active.sample, seriesMatches.orEmpty(),
approvedSeries, episodeSamples, channel, toonamiChannel, selectedNumber, afterDarkChannel, darkMovies, approvedDark)
approvedSeries, episodeSamples, channel, toonamiChannel, selectedNumber, afterDarkChannel, darkMovies, approvedDark, extraChannels)
val ticket = saveTicket
if (withContext(Dispatchers.IO) { savedStore.save(saved, ticket) }) {
pendingSaved = saved; setupSaved = true
@@ -219,7 +220,7 @@ 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
val availableChannels = availableSampleChannels(channel, afterDarkChannel, toonamiChannel)
val availableChannels = availableSampleChannels(channel, afterDarkChannel, toonamiChannel, *extraChannels.toTypedArray())
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) {
if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset,
@@ -367,6 +368,27 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}, onBack = { job?.cancel(); busy = false; reviewingDark = false })
return
}
fun buildPreset(preset: ChannelPreset) {
val active = session ?: return
if (busy) return
busy = true; busyLabel = "Building ${preset.name}. This checks each episode, so it can take a few minutes…"; message = null
job = scope.launch {
try {
val built = withContext(Dispatchers.IO) {
val context = coroutineContext
active.buildPresetChannel(preset) { context.ensureActive() }
}
extraChannels = extraChannels.filter { it.number != preset.number } + built.channel
selectedNumber = preset.number
onChannelAvailable(true)
message = "${preset.name} is ready with ${built.channel.lineup.size} episodes from ${built.shows.joinToString()}." +
if (built.missing.isEmpty()) "" else " Not found: ${built.missing.joinToString()}."
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { message = failure.message }
catch (_: Exception) { message = "${preset.name} could not be built. Try again later." }
finally { busy = false }
}
}
fun checkMoreSeries() {
val active = session ?: return
if (busy) return
@@ -548,6 +570,11 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = {
if (seriesMatches != null) reviewingSeries = true else checkMoreSeries()
})
ChannelPresets.forEach { preset ->
val built = extraChannels.any { it.number == preset.number }
OrbitButton("${preset.number} ${preset.name} — ${if (built) "rebuild" else "build"}", Modifier.fillMaxWidth(),
enabled = !busy && session != null, onClick = { buildPreset(preset) })
}
OrbitButton("Check metadata coverage", Modifier.fillMaxWidth(),
enabled = !busy && session != null, onClick = { message = null; reviewingCoverage = true })
OrbitButton("Library import test", Modifier.fillMaxWidth(),
@@ -57,7 +57,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
sample = saved.sample
checkedEpisodes = saved.episodes.orEmpty()
candidateEpisodes = checkedEpisodes.flatMap { group -> group.episodes.map { episodeSampleItem(group.series, it) } }.associateBy { it.id }
channelEpisodes = saved.toonami?.lineup.orEmpty().associate { it.item.id to it.item }
channelEpisodes = (saved.toonami?.lineup.orEmpty() + saved.extra.flatMap { it.lineup }).associate { it.item.id to it.item }
darkCandidates = saved.darkMovies
darkLineup = saved.afterDark?.lineup.orEmpty().associate { it.item.id to it.item }
}
@@ -153,9 +153,33 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
}
checkActive(); currentToken()
val channel = SampleChannel(media, "Toonami", 103)
channelEpisodes = ordered.associateBy { it.id }
channelEpisodes = channelEpisodes + ordered.associateBy { it.id }
return channel
}
/** 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 missing = mutableListOf<String>()
for (search in preset.searches) {
if (found.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
}
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.")
val ordered = toonamiEpisodeOrder(groups)
channelEpisodes = channelEpisodes + ordered.associateBy { it.id }
val media = ordered.mapNotNull { item ->
checkActive()
val plan = try { prepare(item, isPlayback = false) } catch (_: EmbyCheckException) { return@mapNotNull null }
plan.durationMs?.takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS }?.let { ScheduledMedia(item, plan.sourceId, it) }
}
if (media.size < 2) throw EmbyCheckException("Too few of ${preset.name}'s episodes could be opened. Try again later.")
checkActive(); currentToken()
return PresetBuild(SampleChannel(media, preset.name, preset.number), groups.map { it.series.title }, missing)
}
fun checkMetadataCoverage(checkActive: () -> Unit = {}): List<MetadataCoverage> =
auditMetadataCoverage(transport, userId, ::currentToken, checkActive)
@@ -8,9 +8,9 @@ internal class RememberedIdentity(val userId: String, val token: String?, val de
internal class RememberedState(val identity: RememberedIdentity, val sample: EmbySample,
val matches: List<SeriesMatches>, val approved: Set<String>, val episodes: List<SeriesEpisodes>?,
val orbitOne: SampleChannel?, val toonami: SampleChannel?, val selectedNumber: Int,
val afterDark: SampleChannel? = null, val darkMovies: List<AfterDarkMovie> = emptyList(), val approvedDark: Set<String> = emptySet()) {
val afterDark: SampleChannel? = null, val darkMovies: List<AfterDarkMovie> = emptyList(), val approvedDark: Set<String> = emptySet(), val extra: List<SampleChannel> = emptyList()) {
fun withoutToken() = RememberedState(RememberedIdentity(identity.userId, null, identity.deviceId), sample,
matches, approved, episodes, orbitOne, toonami, selectedNumber, afterDark, darkMovies, approvedDark)
matches, approved, episodes, orbitOne, toonami, selectedNumber, afterDark, darkMovies, approvedDark, extra)
}
internal object RememberedStateCodec {
@@ -33,6 +33,7 @@ internal object RememberedStateCodec {
.put("darkMovies", arr(state.darkMovies.map { movie -> JSONObject().put("item", item(movie.item)).put("year", movie.year ?: JSONObject.NULL)
.put("rating", movie.rating).put("genres", JSONArray(movie.genres)) }))
.put("approvedDark", JSONArray(state.approvedDark.toList()))
.put("extra", arr(state.extra.map { channel(it) as JSONObject }))
.put("one", channel(state.orbitOne)).put("toonami", channel(state.toonami))
.put("episodes", state.episodes?.let { groups -> arr(groups.map { group -> JSONObject().put("series", candidate(group.series)).put("more", group.moreAvailable)
.put("items", arr(group.episodes.map { e -> JSONObject().put("id", e.id).put("title", e.title)
@@ -57,6 +58,9 @@ internal object RememberedStateCodec {
private fun readChannel(o: JSONObject, key: String, number: Int): SampleChannel? {
if (o.isNull(key)) return null
val c = o.getJSONObject(key); require(c.getInt("number") == number)
return readEntries(c, number)
}
private fun readEntries(c: JSONObject, number: Int): SampleChannel {
return SampleChannel(list(c, "entries", 15).map { m -> ScheduledMedia(readItem(m.getJSONObject("item")), id(m, "source"), requireNotNull(duration(m))) }, text(c, "name", 80, false), number)
}
fun decode(bytes: ByteArray): RememberedState {
@@ -91,7 +95,13 @@ internal object RememberedStateCodec {
require(darkApproval.length() <= 3)
val approvedDark = (0 until darkApproval.length()).map { darkApproval.getString(it) }.toSet()
require(approvedDark.all { value -> darkMovies.any { it.item.id == value } })
val selected = o.optInt("selectedChannel", o.getInt("selected")); require(selected in 101..103)
return RememberedState(identity, sample, matches, approved, episodes, one, toonami, selected, dark, darkMovies, approvedDark)
// Absent in files saved before presets existed.
val extra = if (!o.has("extra")) emptyList() else list(o, "extra", ChannelPresets.size).map { c ->
val number = c.getInt("number"); require(ChannelPresets.any { it.number == number })
readEntries(c, number)
}
require(extra.map { it.number }.distinct().size == extra.size)
val selected = o.optInt("selectedChannel", o.getInt("selected")); require(selected in 101..103 || ChannelPresets.any { it.number == selected })
return RememberedState(identity, sample, matches, approved, episodes, one, toonami, selected, dark, darkMovies, approvedDark, extra)
}
}
@@ -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.35.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.36.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()) {
@@ -17,23 +17,27 @@ internal fun discoverToonamiSeries(transport: EmbyTransport, userId: String, tok
require(startIndex in 0..ToonamiSearches.size)
return ToonamiSearches.drop(startIndex).take(5).map { requested ->
checkActive()
val term = URLEncoder.encode(requested, "UTF-8")
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Series&SearchTerm=$term&StartIndex=0&Limit=10&SortBy=SortName&SortOrder=Ascending&EnableImages=false&EnableUserData=false", token = token()))
EmbyClient.expectSuccess(reply.status, false)
val body = EmbyClient.parse(reply.body)
val items = body.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return series search results.")
val candidates = (0 until minOf(items.length(), 10)).mapNotNull { index ->
val item = items.optJSONObject(index) ?: return@mapNotNull null
val id = item.optString("Id")
val title = EmbyClient.clean(item.optString("Name"), 160)
if (item.optString("Type") != "Series" || !EmbyClient.validId(id) || title.isBlank()) return@mapNotNull null
SeriesCandidate(id, title, item.optInt("ProductionYear").takeIf { it in 1800..2200 },
EmbyClient.clean(item.optString("OfficialRating"), 40))
}.distinctBy { it.id }
SeriesMatches(requested, labelSeriesVariants(candidates), body.optLong("TotalRecordCount", items.length().toLong()) > 10)
searchSeries(transport, userId, token, requested)
}
}
internal fun searchSeries(transport: EmbyTransport, userId: String, token: () -> String, requested: String): SeriesMatches {
val term = URLEncoder.encode(requested, "UTF-8")
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Series&SearchTerm=$term&StartIndex=0&Limit=10&SortBy=SortName&SortOrder=Ascending&EnableImages=false&EnableUserData=false", token = token()))
EmbyClient.expectSuccess(reply.status, false)
val body = EmbyClient.parse(reply.body)
val items = body.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return series search results.")
val candidates = (0 until minOf(items.length(), 10)).mapNotNull { index ->
val item = items.optJSONObject(index) ?: return@mapNotNull null
val id = item.optString("Id")
val title = EmbyClient.clean(item.optString("Name"), 160)
if (item.optString("Type") != "Series" || !EmbyClient.validId(id) || title.isBlank()) return@mapNotNull null
SeriesCandidate(id, title, item.optInt("ProductionYear").takeIf { it in 1800..2200 },
EmbyClient.clean(item.optString("OfficialRating"), 40))
}.distinctBy { it.id }
return SeriesMatches(requested, labelSeriesVariants(candidates), body.optLong("TotalRecordCount", items.length().toLong()) > 10)
}
internal fun discoverToonamiSeries(transport: EmbyTransport, userId: String, token: () -> String): List<SeriesMatches> =
discoverToonamiSeries(transport, userId, token, 0)
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,73 @@
package tv.orbit.classic
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class ChannelPresetsTest {
private fun series(id: String, title: String, year: Int? = null) = SeriesCandidate(id, title, year, "")
@Test
fun anExactTitleBeatsALongerOne() {
val match = bestSeriesMatch("Blue Planet", listOf(series("a", "Blue Planet II", 2017), series("b", "The Blue Planet", 2001)))
assertEquals("b", match?.id)
}
@Test
fun theOriginalBeatsItsRevival() {
val match = bestSeriesMatch("The X-Files", listOf(series("a", "The X-Files", 2016), series("b", "The X-Files", 1993)))
assertEquals("b", match?.id)
}
@Test
fun aLongerTitleIsUsedWhenThereIsNoExactOne() {
assertEquals("a", bestSeriesMatch("Blue Planet", listOf(series("a", "Blue Planet II", 2017)))?.id)
}
@Test
fun anUnrelatedTitleIsNotAMatch() {
assertNull(bestSeriesMatch("Life", listOf(series("a", "Lifeboat"), series("b", "Modern Life"))))
assertNull(bestSeriesMatch("Fringe", emptyList()))
}
@Test
fun specialsAndDuplicateEpisodesAreLeftOut() {
val show = series("a", "Fixture")
val episodes = listOf(EpisodeCandidate("e1", "Special", 0, 1, 60_000), EpisodeCandidate("e2", "One", 1, 1, 60_000),
EpisodeCandidate("e3", "One again", 1, 1, 60_000), EpisodeCandidate("e4", "Unnumbered", null, null, 60_000))
assertEquals(listOf("e2"), regularEpisodes(listOf(SeriesEpisodes(show, episodes, false))).single().episodes.map { it.id })
}
@Test
fun aShowWithOnlySpecialsIsDropped() {
val show = series("a", "Fixture")
assertTrue(regularEpisodes(listOf(SeriesEpisodes(show, listOf(EpisodeCandidate("e1", "Special", 0, 1, 60_000)), false))).isEmpty())
}
private fun media(id: String) = ScheduledMedia(SampleItem("Show / S1 E1 / $id", "Episode", 1, "", id, 60_000), "source-$id", 60_000)
private fun state(extra: List<SampleChannel>, selected: Int) = RememberedState(RememberedIdentity("fixture-user", "fixture-token", "fixture-device"),
EmbySample(emptyList(), 0), emptyList(), emptySet(), null, null, null, selected, extra = extra)
@Test
fun presetChannelsSurviveASaveAndRestore() {
val terra = SampleChannel(listOf(media("a"), media("b")), "Terra", 140)
val restored = RememberedStateCodec.decode(RememberedStateCodec.encode(state(listOf(terra), 140)))
assertEquals(listOf(140), restored.extra.map { it.number })
assertEquals(140, restored.selectedNumber)
assertEquals(listOf("a", "b"), restored.extra.single().lineup.map { it.item.id })
}
@Test
fun aFileSavedBeforePresetsStillOpens() {
val saved = JSONObject(String(RememberedStateCodec.encode(state(emptyList(), 101))))
saved.remove("extra")
assertTrue(RememberedStateCodec.decode(saved.toString().toByteArray()).extra.isEmpty())
}
@Test
fun aChannelNumberThatIsNotAPresetIsRejected() {
val saved = JSONObject(String(RememberedStateCodec.encode(state(listOf(SampleChannel(listOf(media("a"), media("b")), "Terra", 140)), 101))))
saved.getJSONArray("extra").getJSONObject(0).put("number", 999)
assertThrows(Exception::class.java) { RememberedStateCodec.decode(saved.toString().toByteArray()) }
}
}