Add Terra and Paradox, built from named shows in the library (0.36.0)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user