0.84.0: Paradox in three blocks with per-show mixed order
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Orbit TV 0.84.0: Paradox, "Expect the unexpected"
|
||||
|
||||
Includes 0.83.4.
|
||||
|
||||
- Paradox now has 17 titles in three blocks. Daytime, 06:00 to 18:00, runs standalones in a mixed order: Doctor Who (2005), Star Trek: TNG, The X-Files, The Twilight Zone, Stargate SG-1, Sliders, Warehouse 13 and Eureka. Prime time, 18:00 to 23:00, runs Buffy, Angel, Supernatural and Fringe in order. Late night, 23:00 to 06:00, runs Twin Peaks, The Outer Limits (1963 and 1995), Torchwood, Farscape and The X-Files.
|
||||
- Mixed order can now be set per show (`shuffleTitles`) as well as per channel. The X-Files is mixed everywhere for now, until standalone and arc episodes can be told apart.
|
||||
- Before, Paradox stopped after 3 shows.
|
||||
- Tests: per-show mixing keeps the other shows in order, and Paradox mixes only its standalones. The plan baselines are unchanged. 323 passed. Lint passed.
|
||||
|
||||
On the box: Paradox's list has changed, so its next round reads its pool again from Emby. Its 7 committed days stay as they are, so the new lineup appears as the plan rolls forward, unless Paul chooses to rebuild Paradox's plan.
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 101
|
||||
versionName = "0.83.4-one-window"
|
||||
versionCode = 102
|
||||
versionName = "0.84.0-paradox"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -28,7 +28,7 @@ internal val ChannelPresets = listOf(
|
||||
ChannelPreset(101, "Orbit 1", listOf("Frasier", "Cheers", "The Fresh Prince of Bel-Air", "Home Improvement", "Malcolm in the Middle", "Everybody Loves Raymond", "The Big Bang Theory", "Modern Family", "My Wife and Kids", "Sister, Sister", "Sabrina the Teenage Witch",
|
||||
"Saved by the Bell", "Boy Meets World", "The Wonder Years", "Full House", "Family Matters", "The Golden Girls", "Happy Days", "Last of the Summer Wine", "My Family", "Outnumbered", "Not Going Out", "Miranda", "Mrs. Brown's Boys", "Goodnight Sweetheart", "Bread", "Red Dwarf", "Young Sheldon", "The King of Queens", "Two and a Half Men"), smartOnly = true),
|
||||
ChannelPreset(201, "Terra", listOf("Blue Planet", "Planet Earth", "Frozen Planet", "Life", "Our Planet")),
|
||||
ChannelPreset(106, "Paradox", listOf("X-Files", "Buffy the Vampire Slayer", "Supernatural", "Fringe", "Doctor Who")),
|
||||
ChannelPreset(106, "Paradox", listOf("The X-Files", "Buffy the Vampire Slayer", "Supernatural", "Fringe", "Doctor Who (2005)")),
|
||||
ChannelPreset(104, "Orbit Gold", listOf("Only Fools and Horses", "Father Ted", "Blackadder", "The Black Adder", "Fawlty Towers", "Ghosts", "The Good Life", "The Green Green Grass",
|
||||
"The Vicar of Dibley", "Porridge", "Taskmaster", "8 Out of 10 Cats", "Dad's Army", "Yes Minister", "Keeping Up Appearances", "Open All Hours", "Rising Damp", "The Likely Lads", "Steptoe and Son", "Hi-de-Hi!", "'Allo 'Allo!",
|
||||
"Are You Being Served?", "Birds of a Feather", "Men Behaving Badly", "The IT Crowd", "Peep Show"), smartOnly = true),
|
||||
|
||||
@@ -21,11 +21,22 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se
|
||||
/** Leave out episodes with no English sound. Off where subtitled shows belong, like Vortex's anime. */
|
||||
val englishAudio: Boolean = true,
|
||||
/** Each show's episodes in a mixed order, all of them before any repeats. Everything else runs in season order. */
|
||||
val shuffleEpisodes: Boolean = false) {
|
||||
val shuffleEpisodes: Boolean = false,
|
||||
/** Shows played in a mixed order on a channel that otherwise runs in season order, such as Paradox's standalone adventures. */
|
||||
val shuffleTitles: Set<String> = emptySet()) {
|
||||
/** Whether a show plays its episodes in a mixed order here. */
|
||||
fun mixes(show: PoolSeries) = shuffleEpisodes || seriesKey(show.title) in shuffleTitles.mapTo(HashSet(), ::seriesKey)
|
||||
/** Changes when the list does, so the episode cache is read again. What's already committed stays. */
|
||||
val signature get() = "$CHANNEL_POOL_VERSION#${searches.joinToString("|")}#$seriesCount"
|
||||
}
|
||||
|
||||
/** Expect the unexpected: standalone adventures by day in a mixed order, cult drama in order in prime time, and the strange stuff late. */
|
||||
private val ParadoxStandalones = listOf("Doctor Who (2005)", "Star Trek: The Next Generation", "The X-Files", "The Twilight Zone", "Stargate SG-1", "Sliders", "Warehouse 13", "Eureka")
|
||||
private val ParadoxPrimeTime = listOf("Buffy the Vampire Slayer", "Angel", "Supernatural", "Fringe")
|
||||
private val ParadoxLate = listOf("Twin Peaks", "The Outer Limits", "The Outer Limits (1995)", "Torchwood", "Farscape", "The X-Files")
|
||||
private val ParadoxShows get() = (ParadoxStandalones + ParadoxPrimeTime + ParadoxLate).distinct()
|
||||
private val ParadoxDayParts get() = listOf(DayPart(6, 18, ParadoxStandalones), DayPart(18, 23, ParadoxPrimeTime), DayPart(23, 6, ParadoxLate))
|
||||
|
||||
/** Toonami-inspired: Paul's picks and the earlier shortlist, split into cartoons by day and anime from the evening. */
|
||||
private val VortexDaytime = listOf("Batman Beyond", "Batman: The Animated Series", "Superman: The Animated Series", "ThunderCats", "The Powerpuff Girls", "Jonny Quest",
|
||||
"Samurai Jack", "Justice League", "Justice League Unlimited", "Voltron", "Voltron: Defender of the Universe", "ReBoot", "Beast Wars: Transformers")
|
||||
@@ -61,7 +72,7 @@ internal val SmartChannels = listOf(
|
||||
"Hostile Planet", "Africa", "Wild China", "South Pacific", "Great Barrier Reef", "The Hunt", "The Life of Mammals", "The Life of Birds", "Nature's Great Events", "Green Planet", "Prehistoric Planet"),
|
||||
seriesCount = 20, finishTogether = true, exactTitles = true),
|
||||
ChannelProgramming(TOTS_NUMBER, "Tots", TotsShows, seriesCount = 40, finishTogether = true, exactTitles = true, dayParts = TotsDayParts, shuffleEpisodes = true),
|
||||
ChannelProgramming(106, "Paradox", ChannelPresets.first { it.number == 106 }.searches)) +
|
||||
ChannelProgramming(106, "Paradox", ParadoxShows, seriesCount = 20, finishTogether = true, exactTitles = true, dayParts = ParadoxDayParts, shuffleTitles = ParadoxStandalones.toSet())) +
|
||||
ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = if (it.films) it.searches.size else 60, finishTogether = !it.films, exactTitles = true,
|
||||
filmRestMs = if (it.films) 7 * 24 * 60 * 60_000L else null,
|
||||
suggestionRules = SmartChannelRules[it.number].orEmpty(), dayParts = SmartChannelParts[it.number].orEmpty()) }
|
||||
@@ -229,6 +240,8 @@ internal fun extendFilmSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor,
|
||||
/** Shows take turns, one episode each, back to back from [fromMs] until [untilMs] is covered. */
|
||||
internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false,
|
||||
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false,
|
||||
/** Shows to mix even when [shuffle] is off. */
|
||||
mixed: (PoolSeries) -> Boolean = { false },
|
||||
/** How many times the cursor's last show had already played in a row, so a run carries across a top-up. */
|
||||
runBefore: Int = 0,
|
||||
/** Off only in tests, to show that this rule is the one difference from planning before it existed. */
|
||||
@@ -242,9 +255,10 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
|
||||
val mixes = HashMap<String, List<PoolEpisode>>()
|
||||
// How many times in a row the last show has played, counted from the start of this extension.
|
||||
var run = if (cursor.lastSeriesId != null) runBefore else 0
|
||||
fun running(show: PoolSeries) = if (shuffle) mixes.getOrPut(show.id) { shuffledEpisodes(show) } else show.episodes
|
||||
fun isMixed(show: PoolSeries) = shuffle || mixed(show)
|
||||
fun running(show: PoolSeries) = if (isMixed(show)) mixes.getOrPut(show.id) { shuffledEpisodes(show) } else show.episodes
|
||||
fun following(show: PoolSeries): PoolEpisode {
|
||||
if (!shuffle) return nextEpisode(show, marks[show.id])
|
||||
if (!isMixed(show)) return nextEpisode(show, marks[show.id])
|
||||
val mix = running(show)
|
||||
return mix[(mix.indexOfFirst { it.mark == marks[show.id] } + 1) % mix.size]
|
||||
}
|
||||
@@ -278,7 +292,7 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
|
||||
picked != null -> "; next in turn"
|
||||
else -> "; fallback: every show in turn airs elsewhere then"
|
||||
})
|
||||
if (shuffle) append(", episode from its shuffled order")
|
||||
if (isMixed(series)) append(", episode from its shuffled order")
|
||||
if (restarted) append("; every show had finished, so all started again")
|
||||
if (waiting.isNotEmpty()) append("; deferred until the rest finish: ${someOf(waiting)}")
|
||||
if (elsewhere.isNotEmpty()) append("; deferred, airing elsewhere: ${someOf(elsewhere)}")
|
||||
@@ -315,13 +329,14 @@ internal fun airedCursor(base: ScheduleCursor, entries: List<ScheduleEntry>, now
|
||||
* With no usable plan it starts from now at the aired position.
|
||||
*/
|
||||
internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List<PoolSeries>?, nowMs: Long, filmRestMs: Long? = null, finishTogether: Boolean = false,
|
||||
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false, noThirdInARow: Boolean = true): ChannelPlan? {
|
||||
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }, shuffle: Boolean = false, noThirdInARow: Boolean = true,
|
||||
mixed: (PoolSeries) -> Boolean = { false }): ChannelPlan? {
|
||||
val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) }
|
||||
if (kept != null && kept.endMs >= nowMs + SCHEDULE_TOP_UP_MS) return kept
|
||||
if (pool.isNullOrEmpty()) return kept
|
||||
val from = if (kept != null && kept.endMs > nowMs) kept.endMs else nowMs
|
||||
val trailing = kept?.entries?.lastOrNull()?.let { tail -> kept.entries.asReversed().takeWhile { it.seriesId == tail.seriesId }.size } ?: 0
|
||||
val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, trailing, noThirdInARow)
|
||||
val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing, noThirdInARow)
|
||||
return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail)
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
|
||||
// Other channels' plans are only read if this one is actually about to grow.
|
||||
val others by lazy { clashesWith((SmartChannels.map { it.number }.filter { it != number } + DRIVE_IN_NUMBER).mapNotNull { store.loadPlan(userId, it) }) }
|
||||
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
|
||||
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
|
||||
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes, mixed = programming::mixes) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
|
||||
if (plan !== existing) store.savePlan(userId, number, plan)
|
||||
store.saveProgress(userId, number, ScheduleStore.SavedProgress(progress?.enabled ?: true, airedCursor(base, plan.entries, now)))
|
||||
session.rememberScheduledItems(plan.entries.map { it.media.item })
|
||||
|
||||
@@ -66,7 +66,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.83.4\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.84.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -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.83.4")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.84.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()) {
|
||||
|
||||
@@ -37,4 +37,21 @@ class RunLengthTest {
|
||||
assertEquals(listOf("big", "a"), plan.entries.take(2).map { it.seriesId })
|
||||
assertTrue(plan.entries.none { "early" in it.why })
|
||||
}
|
||||
|
||||
@Test fun aChannelCanMixSomeShowsAndKeepOthersInOrder() {
|
||||
val pool = listOf(show("standalone", 20), show("arc", 20))
|
||||
val plan = extendSchedule(pool, ScheduleCursor(), start, start + 40 * 10 * minute, finishTogether = true, mixed = { it.id == "standalone" })
|
||||
assertEquals((1..20).toList(), plan.entries.filter { it.seriesId == "arc" }.map { it.mark.episode })
|
||||
val mixed = plan.entries.filter { it.seriesId == "standalone" }.map { it.mark.episode }
|
||||
assertNotEquals((1..20).toList(), mixed)
|
||||
assertEquals((1..20).toList(), mixed.sorted())
|
||||
}
|
||||
|
||||
@Test fun paradoxMixesItsStandalonesOnly() {
|
||||
val paradox = SmartChannels.first { it.number == 106 }
|
||||
assertTrue(paradox.mixes(show("x", 1).copy(title = "The X-Files")))
|
||||
assertTrue(paradox.mixes(show("d", 1).copy(title = "Doctor Who (2005)")))
|
||||
assertFalse(paradox.mixes(show("b", 1).copy(title = "Buffy the Vampire Slayer")))
|
||||
assertFalse(paradox.mixes(show("s", 1).copy(title = "Supernatural")))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user