Stop a show airing on two channels at once; add ToonZ, Rewind and Orbit X (0.50.0 in testing)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 50
|
||||
versionName = "0.49.0-three-channels"
|
||||
versionCode = 51
|
||||
versionName = "0.50.0-six-channels"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -8,7 +8,10 @@ internal object ChannelBrands {
|
||||
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),
|
||||
111 to ChannelBranding("orbit-gold", "Orbit Gold", R.drawable.orbit_gold_epg, R.drawable.orbit_gold_background, R.drawable.orbit_gold_bug, ChannelCategory.Comedy),
|
||||
121 to ChannelBranding("heroes", "Orbit Heroes", R.drawable.heroes_epg, R.drawable.heroes_background, R.drawable.heroes_bug, ChannelCategory.Entertainment),
|
||||
121 to ChannelBranding("heroes", "Orbit Heroes", R.drawable.heroes_epg, R.drawable.heroes_background, R.drawable.heroes_bug, ChannelCategory.Cartoons),
|
||||
122 to ChannelBranding("toonz", "Orbit ToonZ", R.drawable.toonz_epg, R.drawable.toonz_background, R.drawable.toonz_bug, ChannelCategory.Cartoons),
|
||||
123 to ChannelBranding("rewind", "Orbit Rewind", R.drawable.rewind_epg, R.drawable.rewind_background, R.drawable.rewind_bug, ChannelCategory.Cartoons),
|
||||
124 to ChannelBranding("orbit-x", "Orbit X", R.drawable.orbit_x_epg, R.drawable.orbit_x_background, R.drawable.orbit_x_bug, ChannelCategory.Cartoons),
|
||||
141 to ChannelBranding("evidence", "Orbit Evidence", R.drawable.evidence_epg, R.drawable.evidence_background, R.drawable.evidence_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))
|
||||
|
||||
@@ -9,12 +9,24 @@ internal val ChannelPresets = listOf(
|
||||
ChannelPreset(140, "Terra", listOf("Blue Planet", "Planet Earth", "Frozen Planet", "Life", "Our Planet")),
|
||||
ChannelPreset(152, "Paradox", listOf("X-Files", "Buffy the Vampire Slayer", "Supernatural", "Fringe", "Doctor Who")),
|
||||
ChannelPreset(111, "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"), smartOnly = true),
|
||||
"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),
|
||||
ChannelPreset(121, "Heroes", listOf("Batman: The Animated Series", "Superman: The Animated Series", "Spider-Man: The Animated Series", "X-Men: The Animated Series", "Batman Beyond",
|
||||
"Spider-Man Unlimited", "Justice League", "Justice League Unlimited", "Teen Titans", "Static Shock", "The Spectacular Spider-Man", "Young Justice",
|
||||
"Batman: The Brave and the Bold", "Iron Man: Armored Adventures", "The Avengers: Earth's Mightiest Heroes"), smartOnly = true),
|
||||
"Batman: The Brave and the Bold", "Iron Man: Armored Adventures", "The Avengers: Earth's Mightiest Heroes", "The Batman", "Fantastic Four", "Silver Surfer",
|
||||
"Spider-Man and His Amazing Friends", "Ben 10"), smartOnly = true),
|
||||
ChannelPreset(122, "ToonZ", listOf("Looney Tunes", "The Looney Tunes Show", "Merrie Melodies", "The Bugs Bunny Show", "Tom and Jerry", "The Tom and Jerry Show", "The Flintstones", "The Jetsons",
|
||||
"Popeye the Sailor", "Popeye", "Top Cat", "The Yogi Bear Show", "Wacky Races", "Scooby-Doo, Where Are You!", "The Pink Panther Show", "Woody Woodpecker", "Dastardly and Muttley in Their Flying Machines",
|
||||
"The Huckleberry Hound Show", "Hong Kong Phooey", "Danger Mouse", "Count Duckula", "Bananaman"), smartOnly = true),
|
||||
ChannelPreset(123, "Rewind", listOf("Dexter's Laboratory", "Johnny Bravo", "What's New, Scooby-Doo?", "Courage the Cowardly Dog", "Ed, Edd n Eddy", "Ed Edd n Eddy", "Recess", "The Powerpuff Girls", "Cow and Chicken",
|
||||
"I Am Weasel", "The Grim Adventures of Billy & Mandy", "Codename: Kids Next Door", "Foster's Home for Imaginary Friends", "Hey Arnold!", "Rugrats", "Doug", "CatDog", "Rocko's Modern Life",
|
||||
"Animaniacs", "Pinky and the Brain", "The Wild Thornberrys", "SpongeBob SquarePants"), smartOnly = true),
|
||||
ChannelPreset(124, "Orbit X", listOf("Digimon: Digital Monsters", "Digimon Adventure", "Gargoyles", "The Transformers", "He-Man and the Masters of the Universe", "ThunderCats", "Biker Mice from Mars",
|
||||
"Mighty Morphin Power Rangers", "G.I. Joe: A Real American Hero", "Teenage Mutant Ninja Turtles", "Street Sharks", "SWAT Kats: The Radical Squadron", "Captain Planet and the Planeteers", "M.A.S.K.",
|
||||
"Exosquad", "The Real Ghostbusters", "Inspector Gadget", "She-Ra: Princess of Power", "Bucky O'Hare and the Toad Wars", "Visionaries: Knights of the Magical Light"), smartOnly = true),
|
||||
ChannelPreset(141, "Evidence", listOf("CSI: Crime Scene Investigation", "NCIS", "CSI: Miami", "CSI: NY", "Law & Order", "Law & Order: Special Victims Unit", "Criminal Minds", "Bones",
|
||||
"Castle", "The Mentalist", "Monk", "Columbo", "Midsomer Murders", "Vera", "Luther", "Line of Duty", "Taggart", "Silent Witness", "Waking the Dead", "A Touch of Frost"), smartOnly = true))
|
||||
"Castle", "The Mentalist", "Monk", "Columbo", "Midsomer Murders", "Vera", "Luther", "Line of Duty", "Taggart", "Silent Witness", "Waking the Dead", "A Touch of Frost", "Dexter", "Sherlock", "Broadchurch", "Death in Paradise", "Lewis", "Inspector Morse",
|
||||
"Dalziel and Pascoe", "Happy Valley", "The Wire", "Hawaii Five-0", "Blue Bloods"), smartOnly = true))
|
||||
|
||||
/** A fallback for a channel with no old loop: its first episodes, in turn. Used only outside its plan. */
|
||||
internal fun loopFromPool(pool: List<PoolSeries>, programming: ChannelProgramming): SampleChannel? {
|
||||
|
||||
@@ -57,7 +57,16 @@ 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(152, "Paradox", ChannelPresets.first { it.number == 152 }.searches)) +
|
||||
ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = 25, finishTogether = true, exactTitles = true) }
|
||||
ChannelPresets.filter { it.smartOnly }.map { ChannelProgramming(it.number, it.name, it.searches, seriesCount = 40, finishTogether = true, exactTitles = true,
|
||||
suggestionRules = SmartChannelRules[it.number].orEmpty()) }
|
||||
|
||||
/** Optional bigger pools, only used once reviewed. No rule for Orbit Gold: nothing in the library's details can tell a British sitcom apart. */
|
||||
private val SmartChannelRules: Map<Int, List<SuggestionRule>> get() = mapOf(
|
||||
121 to listOf(SuggestionRule("all", "Superhero animation", "Series", listOf("Animation", "Action"), 1990..2020, 7f, setOf("TV-MA"), 30, setOf("Anime"), titleContains = null)),
|
||||
122 to listOf(SuggestionRule("all", "Classic cartoons", "Series", listOf("Animation", "Comedy"), 1940..1990, 6.5f, setOf("TV-MA"), 40)),
|
||||
123 to listOf(SuggestionRule("all", "After-school cartoons", "Series", listOf("Animation", "Comedy"), 1990..2010, 7f, setOf("TV-MA"), 40, setOf("Anime"))),
|
||||
124 to listOf(SuggestionRule("all", "Action cartoons", "Series", listOf("Animation", "Action"), 1980..2000, 6.5f, setOf("TV-MA"), 40, setOf("Anime"))),
|
||||
141 to listOf(SuggestionRule("all", "Crime drama", "Series", listOf("Crime", "Drama"), 1985..2022, 7f, emptySet(), 40, setOf("Documentary", "Animation"))))
|
||||
|
||||
internal data class EpisodeMark(val season: Int, val episode: Int) : Comparable<EpisodeMark> {
|
||||
override fun compareTo(other: EpisodeMark) = compareValuesBy(this, other, { it.season }, { it.episode })
|
||||
@@ -110,31 +119,45 @@ internal fun dayPartPool(pool: List<PoolSeries>, parts: List<DayPart>, startMs:
|
||||
return pool.filter { !it.isFilm || pool.all(PoolSeries::isFilm) }.ifEmpty { pool }
|
||||
}
|
||||
|
||||
internal fun turnOrder(pool: List<PoolSeries>, last: String?): List<PoolSeries> {
|
||||
val after = pool.indexOfFirst { it.id == last } + 1
|
||||
return (0 until pool.size).map { pool[(after + it) % pool.size] }
|
||||
}
|
||||
|
||||
/** The next show after [last] in the rota that [eligible] allows. */
|
||||
internal fun nextInTurn(pool: List<PoolSeries>, last: String?, eligible: (PoolSeries) -> Boolean): PoolSeries {
|
||||
val after = pool.indexOfFirst { it.id == last } + 1
|
||||
return (0 until pool.size).map { pool[(after + it) % pool.size] }.first(eligible)
|
||||
return turnOrder(pool, last).first(eligible)
|
||||
}
|
||||
|
||||
/** True if a show would overlap itself on another channel. */
|
||||
internal typealias ProgrammeClash = (show: PoolSeries, startMs: Long, endMs: Long) -> Boolean
|
||||
|
||||
/** The same show may run on several channels, just not at the same moment. Compares by title, so two copies of a show still count. */
|
||||
internal fun clashesWith(plans: List<ChannelPlan>): ProgrammeClash {
|
||||
val airings = plans.flatMap { it.entries }.groupBy { seriesKey(it.media.item.title.substringBefore(" / ")) }
|
||||
return { show, startMs, endMs -> airings[seriesKey(show.title)].orEmpty().any { it.startMs < endMs && startMs < it.endMs } }
|
||||
}
|
||||
|
||||
/**
|
||||
* A film past its rest, chosen by a generator seeded from the start time so a plan can be explained and reproduced.
|
||||
* If the pool is too small for the rest period, the film that has waited longest.
|
||||
*/
|
||||
internal fun nextFilm(pool: List<PoolSeries>, shown: Map<String, Long>, startMs: Long, restMs: Long): PoolSeries {
|
||||
val rested = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true }
|
||||
internal fun nextFilm(pool: List<PoolSeries>, shown: Map<String, Long>, startMs: Long, restMs: Long, avoid: ProgrammeClash = { _, _, _ -> false }): PoolSeries {
|
||||
val rested = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true }.let { films ->
|
||||
films.filter { !avoid(it, startMs, startMs + it.episodes.first().durationMs) }.ifEmpty { films } }
|
||||
return if (rested.isNotEmpty()) rested[java.util.Random(startMs).nextInt(rested.size)]
|
||||
else pool.minBy { shown[it.id] ?: Long.MIN_VALUE }
|
||||
}
|
||||
|
||||
/** Films by their rest period, back to back from [fromMs] until [untilMs] is covered. */
|
||||
internal fun extendFilmSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, restMs: Long,
|
||||
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): ChannelPlan {
|
||||
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan {
|
||||
val shown = cursor.filmShown.toMutableMap()
|
||||
val entries = mutableListOf<ScheduleEntry>()
|
||||
var last = cursor.lastSeriesId
|
||||
var start = fromMs
|
||||
while (start < untilMs) {
|
||||
val film = nextFilm(dayPartPool(pool, dayParts, start, zone), shown, start, restMs)
|
||||
val film = nextFilm(dayPartPool(pool, dayParts, start, zone), shown, start, restMs, avoid)
|
||||
val copy = film.episodes.first()
|
||||
val item = SampleItem(film.title, "Movie", copy.durationMs / 60_000, "", copy.id, copy.durationMs)
|
||||
entries += ScheduleEntry(film.id, copy.mark, ScheduledMedia(item, copy.sourceId, copy.durationMs), start)
|
||||
@@ -147,9 +170,9 @@ 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()): ChannelPlan {
|
||||
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan {
|
||||
require(pool.isNotEmpty() && pool.all { it.episodes.isNotEmpty() })
|
||||
if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs, dayParts, zone)
|
||||
if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs, dayParts, zone, avoid)
|
||||
val marks = cursor.marks.toMutableMap()
|
||||
val entries = mutableListOf<ScheduleEntry>()
|
||||
var last = cursor.lastSeriesId
|
||||
@@ -158,7 +181,9 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
|
||||
val available = dayPartPool(pool, dayParts, start, zone)
|
||||
// Shows only wait for others in the same block, or a daytime show could stand idle for an evening one.
|
||||
if (finishTogether && available.all { marks[it.id] == it.episodes.last().mark }) available.forEach { marks.remove(it.id) }
|
||||
val series = nextInTurn(pool, last) { it in available && (!finishTogether || marks[it.id] != it.episodes.last().mark) }
|
||||
val allowed = { show: PoolSeries -> show in available && (!finishTogether || marks[show.id] != show.episodes.last().mark) }
|
||||
// Skips a show already on elsewhere, unless that would leave nothing to show.
|
||||
val series = turnOrder(pool, last).firstOrNull { allowed(it) && !avoid(it, start, start + nextEpisode(it, marks[it.id]).durationMs) } ?: nextInTurn(pool, last, allowed)
|
||||
val episode = nextEpisode(series, marks[series.id])
|
||||
val item = if (series.isFilm) SampleItem(series.title, "Movie", episode.durationMs / 60_000, "", episode.id, episode.durationMs) else scheduledItem(series.title, episode)
|
||||
val media = ScheduledMedia(item, episode.sourceId, episode.durationMs)
|
||||
@@ -191,12 +216,12 @@ 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()): ChannelPlan? {
|
||||
dayParts: List<DayPart> = emptyList(), zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> 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 added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone)
|
||||
val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid)
|
||||
return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,10 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
|
||||
val existing = store.loadPlan(userId, number)
|
||||
val progress = store.loadProgress(userId, number)
|
||||
val base = progress?.aired ?: ScheduleCursor()
|
||||
val plan = topUpPlan(existing, base, pool?.series, now, programming.filmRestMs, programming.finishTogether, programming.dayParts) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
|
||||
// Other channels' plans are only read if this one is actually about to grow.
|
||||
val others by lazy { clashesWith(SmartChannels.filter { it.number != number }.mapNotNull { store.loadPlan(userId, it.number) }) }
|
||||
val plan = topUpPlan(existing, base, pool?.series, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
|
||||
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }) ?: 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 })
|
||||
|
||||
@@ -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.49.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.50.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -27,6 +27,7 @@ internal enum class ChannelCategory(val label: String, val icon: Int) {
|
||||
Entertainment("Entertainment", R.drawable.orbit_icon_entertainment),
|
||||
Movies("Movies", R.drawable.orbit_icon_movies),
|
||||
Comedy("Comedy", R.drawable.orbit_icon_comedy),
|
||||
Cartoons("Cartoons", R.drawable.orbit_icon_kids),
|
||||
Kids("Kids", R.drawable.orbit_icon_kids),
|
||||
Documentaries("Documentaries", R.drawable.orbit_icon_documentaries),
|
||||
Sport("Sport", R.drawable.orbit_icon_sport)
|
||||
|
||||
@@ -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.49.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.50.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()) {
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -106,7 +106,7 @@ class ChannelPresetsTest {
|
||||
}
|
||||
|
||||
@Test fun theNewChannelsAreConfiguredAndBranded() {
|
||||
listOf(111, 121, 141).forEach { number ->
|
||||
listOf(111, 121, 122, 123, 124, 141).forEach { number ->
|
||||
assertTrue(SmartChannels.any { it.number == number && it.exactTitles && it.finishTogether })
|
||||
assertNotNull(ChannelBrands.forNumber(number))
|
||||
assertTrue(ChannelPresets.first { it.number == number }.smartOnly)
|
||||
|
||||
@@ -405,4 +405,49 @@ class ChannelScheduleTest {
|
||||
dayParts = listOf(DayPart(6, 18, listOf("Show s")), DayPart(18, 6, listOf("Show o"))), zone = london).entries
|
||||
assertEquals(List(6) { "s" }, entries.filter { localHour(it.startMs) in 6 until 18 }.map { it.seriesId })
|
||||
}
|
||||
|
||||
private fun programmeOverlaps(a: List<ScheduleEntry>, b: List<ScheduleEntry>) = a.sumOf { x -> b.count { y ->
|
||||
x.media.item.title.substringBefore(" / ") == y.media.item.title.substringBefore(" / ") && x.startMs < y.endMs && y.startMs < x.endMs } }
|
||||
|
||||
@Test fun aSharedShowNeverAirsOnTwoChannelsAtOnce() {
|
||||
val shared = listOf(show("batman", 9, 40), show("spider", 9, 40), show("xmen", 9, 40))
|
||||
val first = topUpPlan(null, ScheduleCursor(), shared, now)!!
|
||||
val second = topUpPlan(null, ScheduleCursor(), shared, now, avoid = clashesWith(listOf(first)))!!
|
||||
assertEquals(0, programmeOverlaps(first.entries, second.entries))
|
||||
assertTrue(second.entries.map { it.seriesId }.toSet().size == 3)
|
||||
}
|
||||
|
||||
@Test fun withoutTheRuleTheSamePoolWouldClash() {
|
||||
val shared = listOf(show("batman", 9, 40), show("spider", 9, 40))
|
||||
val first = topUpPlan(null, ScheduleCursor(), shared, now)!!
|
||||
assertTrue(programmeOverlaps(first.entries, topUpPlan(null, ScheduleCursor(), shared, now)!!.entries) > 0)
|
||||
}
|
||||
|
||||
@Test fun aChannelWithOnlyAClashingShowStillStaysOnAir() {
|
||||
val only = listOf(show("batman", 9, 40))
|
||||
val other = topUpPlan(null, ScheduleCursor(), only, now)!!
|
||||
val plan = topUpPlan(null, ScheduleCursor(), only, now, avoid = clashesWith(listOf(other)))!!
|
||||
assertEquals(other.entries.size, plan.entries.size)
|
||||
}
|
||||
|
||||
@Test fun twoCopiesOfAShowCountAsTheSameShow() {
|
||||
val copyA = PoolSeries("copy-a", "Dragon Ball Z", listOf(PoolEpisode("a1", "s", EpisodeMark(1, 1), "E", 30 * minute)))
|
||||
val copyB = PoolSeries("copy-b", "The Dragon Ball Z", listOf(PoolEpisode("b1", "s", EpisodeMark(1, 1), "E", 30 * minute)))
|
||||
val plan = extendSchedule(listOf(copyA), ScheduleCursor(), now, now + 30 * minute)
|
||||
assertTrue(clashesWith(listOf(plan))(copyB, now + 10 * minute, now + 20 * minute))
|
||||
assertFalse(clashesWith(listOf(plan))(copyB, now + 30 * minute, now + 60 * minute))
|
||||
}
|
||||
|
||||
@Test fun filmsAvoidClashesToo() {
|
||||
val other = extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest)
|
||||
val mine = extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest, avoid = clashesWith(listOf(other)))
|
||||
assertEquals(0, programmeOverlaps(other.entries, mine.entries))
|
||||
}
|
||||
|
||||
@Test fun aFreshPlanDoesNotReadOtherChannels() {
|
||||
var asked = false
|
||||
val first = topUpPlan(null, ScheduleCursor(), pool, now)!!
|
||||
topUpPlan(first, ScheduleCursor(), pool, now + minute, avoid = { _, _, _ -> asked = true; false })
|
||||
assertFalse(asked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ Audit and phased plan are in review, from `docs/briefs/indexing-programming-engi
|
||||
|
||||
## Smart channels status
|
||||
|
||||
Phase A of [the Smart Channels Foundation brief](briefs/smart-channels-foundation-2026-11-01.md) passed Paul's review on 2 November 2026, with Paradox on the box (VERIFICATION-0.39.0.md). Phase B (Terra and After Dark, VERIFICATION-0.40.0.md) and Phase C (Vortex, plus fewer repeats on Terra, VERIFICATION-0.41.0.md) are on the box as 0.41, tested and awaiting Paul's review. Day parts are on the box on Vortex and After Dark (VERIFICATION-0.42.0.md). Phase D (the remaining channels) needs separate approval. Reviewed suggestions are built and in use on After Dark and Vortex (VERIFICATION-0.47.0.md), the first piece of the programming director. Vortex Movie Night, the first weekday event, is on the box. Phase D has started with Orbit Gold (111), Heroes (121) and Evidence (141), built as 0.49. Their numbers are placeholders until Paul maps the lineup. Animated idents are design files only and aren't in the app yet.
|
||||
Phase A of [the Smart Channels Foundation brief](briefs/smart-channels-foundation-2026-11-01.md) passed Paul's review on 2 November 2026, with Paradox on the box (VERIFICATION-0.39.0.md). Phase B (Terra and After Dark, VERIFICATION-0.40.0.md) and Phase C (Vortex, plus fewer repeats on Terra, VERIFICATION-0.41.0.md) are on the box as 0.41, tested and awaiting Paul's review. Day parts are on the box on Vortex and After Dark (VERIFICATION-0.42.0.md). Phase D (the remaining channels) needs separate approval. Reviewed suggestions are built and in use on After Dark and Vortex (VERIFICATION-0.47.0.md), the first piece of the programming director. Vortex Movie Night, the first weekday event, is on the box. Phase D has started with Orbit Gold (111), Heroes (121) and Evidence (141), on the box as 0.49, then ToonZ (122), Rewind (123) and Orbit X (124), built as 0.50. Numbers are placeholders until Paul maps the lineup. A show may run on several channels but never at the same moment. Animated idents are design files only and aren't in the app yet.
|
||||
|
||||
## Programming director
|
||||
|
||||
|
||||