diff --git a/VERIFICATION-0.36.0.md b/VERIFICATION-0.36.0.md new file mode 100644 index 0000000..8fea698 --- /dev/null +++ b/VERIFICATION-0.36.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5ad43ae..72f21aa 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 = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt index dcf77ab..fe13e84 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelBranding.kt @@ -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 diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt b/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt new file mode 100644 index 0000000..5d7e814 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt @@ -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) + +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, val missing: List) + +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? { + val key = seriesKey(requested) + val oldest = compareBy { 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): List = 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() } 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 0c8759c..e120d62 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.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) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 13d9860..9d13536 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -85,6 +85,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var lastNumber by remember { mutableStateOf(null) } var afterDarkChannel by remember { mutableStateOf(null) } var toonamiChannel by remember { mutableStateOf(null) } + var extraChannels by remember { mutableStateOf>(emptyList()) } var selectedNumber by remember { mutableIntStateOf(101) } var channel by remember { mutableStateOf(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(), 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 7179a5e..b3f62ea 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -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() + val missing = mutableListOf() + 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 = auditMetadataCoverage(transport, userId, ::currentToken, checkActive) diff --git a/android/app/src/main/java/tv/orbit/classic/RememberedState.kt b/android/app/src/main/java/tv/orbit/classic/RememberedState.kt index 307bf38..a926400 100644 --- a/android/app/src/main/java/tv/orbit/classic/RememberedState.kt +++ b/android/app/src/main/java/tv/orbit/classic/RememberedState.kt @@ -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, val approved: Set, val episodes: List?, val orbitOne: SampleChannel?, val toonami: SampleChannel?, val selectedNumber: Int, - val afterDark: SampleChannel? = null, val darkMovies: List = emptyList(), val approvedDark: Set = emptySet()) { + val afterDark: SampleChannel? = null, val darkMovies: List = emptyList(), val approvedDark: Set = emptySet(), val extra: List = 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) } } 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 4a9a15d..8f02b50 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.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()) { diff --git a/android/app/src/main/java/tv/orbit/classic/SeriesDiscovery.kt b/android/app/src/main/java/tv/orbit/classic/SeriesDiscovery.kt index f2391ee..d445f2b 100644 --- a/android/app/src/main/java/tv/orbit/classic/SeriesDiscovery.kt +++ b/android/app/src/main/java/tv/orbit/classic/SeriesDiscovery.kt @@ -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 = discoverToonamiSeries(transport, userId, token, 0) diff --git a/android/app/src/main/res/drawable-nodpi/orbit_terra_background.webp b/android/app/src/main/res/drawable-nodpi/orbit_terra_background.webp new file mode 100644 index 0000000..d6adf73 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_terra_background.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_terra_bug.webp b/android/app/src/main/res/drawable-nodpi/orbit_terra_bug.webp new file mode 100644 index 0000000..074c13c Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_terra_bug.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_terra_epg.webp b/android/app/src/main/res/drawable-nodpi/orbit_terra_epg.webp new file mode 100644 index 0000000..8dea00c Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_terra_epg.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/paradox_background.webp b/android/app/src/main/res/drawable-nodpi/paradox_background.webp new file mode 100644 index 0000000..f453d2d Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/paradox_background.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/paradox_bug.webp b/android/app/src/main/res/drawable-nodpi/paradox_bug.webp new file mode 100644 index 0000000..9ae4b46 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/paradox_bug.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/paradox_epg.webp b/android/app/src/main/res/drawable-nodpi/paradox_epg.webp new file mode 100644 index 0000000..0bb857d Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/paradox_epg.webp differ diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt new file mode 100644 index 0000000..6f11688 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/ChannelPresetsTest.kt @@ -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, 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()) } + } +}