Fill film channels automatically from the library (0.74.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m53s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m24s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:33:23 +00:00
co-authored by Claude Opus 5.5
parent 7de34d754d
commit a055187681
13 changed files with 103 additions and 13 deletions
+11
View File
@@ -0,0 +1,11 @@
# Orbit TV 0.74.0: film pools fill themselves
The first piece of the programming director. Paul wanted bigger film pools filled automatically, with the engine and smart director to be ironed out later.
- Each film channel below 100 films is filled from the library in the background, using its own rules, highest rated first, one channel at a time, 20 seconds apart. At most once a week per channel.
- Premiere, Action, Sci-Fi, Horror, VHS, Romance, Wonder, PixelZ and Detour. New rules for Wonder and PixelZ (split by year, as the library's details can't separate hand-drawn from CGI) and Detour. BritX has no rule, as nothing in the details can tell a British film apart. After Dark is already reviewed and has enough.
- A film goes to the first channel that takes it, so channels keep their own identity. Films with no English sound are left out.
- Paul's reviewed picks and rejections are respected. Settings' Add more is renamed Titles: everything is listed there, and anything unticked never comes back.
- New films join at each channel's next top-up. The week already planned stays, so the full effect takes about a week.
Checked: build, lint and 280 unit tests pass. Not checked until installed: what the rules actually pick from the library, which needs Paul's eye, as earlier rules let in misfits.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 77
versionName = "0.73.1-gear-reach"
versionCode = 78
versionName = "0.74.0-film-fill"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -86,6 +86,9 @@ private val SmartChannelRules: Map<Int, List<SuggestionRule>> get() = mapOf(
302 to listOf(SuggestionRule("all", "Action", "Movie", listOf("Action"), 1980..2025, 6.5f, emptySet(), 60, setOf("Animation", "Documentary", "Family", "Horror"), 70)),
307 to listOf(SuggestionRule("all", "Romantic comedy", "Movie", listOf("Romance", "Comedy"), 1985..2025, 6.5f, emptySet(), 60, setOf("Animation", "Documentary", "Horror"), 70)),
301 to listOf(SuggestionRule("all", "Big event films", "Movie", listOf("Adventure", "Action"), 1975..2025, 7.5f, emptySet(), 40, setOf("Animation", "Documentary"), 80)),
612 to listOf(SuggestionRule("all", "Classic family animation", "Movie", listOf("Animation", "Family"), 1937..2000, 6.5f, setOf("NC-17", "X", "R"), 60, setOf("Documentary"), 55)),
613 to listOf(SuggestionRule("all", "Modern family animation", "Movie", listOf("Animation", "Family"), 2001..2025, 6.5f, setOf("NC-17", "X", "R"), 60, setOf("Documentary", "Anime"), 55)),
308 to listOf(SuggestionRule("all", "Teen and road comedy", "Movie", listOf("Comedy"), 1995..2015, 5.5f, setOf("G", "PG", "TV-G", "U"), 70, setOf("Family", "Animation", "Documentary", "Horror", "Drama"), 70, 7.8f)),
202 to listOf(SuggestionRule("all", "History and science", "Series", listOf("Documentary", "History"), 1970..2025, 7f, emptySet(), 30, setOf("Crime", "Animation"))),
107 to listOf(SuggestionRule("all", "Crime drama", "Series", listOf("Crime", "Drama"), 1985..2022, 7f, emptySet(), 40, setOf("Documentary", "Animation"))))
@@ -4,6 +4,15 @@ import android.util.Log
internal const val POOL_REFRESH_MS = 7 * 24 * 60 * 60_000L
/** About a week of films back to back, so one needn't repeat before its rest is up. */
internal const val FILM_POOL_TARGET = 100
/** Biggest channels first, as a film goes to whichever channel takes it first. */
internal val FilmFillOrder = listOf(301, 302, 303, 304, 306, 307, 612, 613, 308)
internal fun needsFilmFill(programming: ChannelProgramming, films: Int) =
programming.filmRestMs != null && programming.suggestionRules.isNotEmpty() && films < FILM_POOL_TARGET
/** Only counts and times, so it's safe to log. Never titles, addresses or the account. */
internal class SchedulerReport(val programmes: Int, val episodes: Int, val shows: List<String>, val requests: Int,
val fetchMs: Long, val planMs: Long, val bytes: Long, val fromCache: Boolean, val endMs: Long) {
@@ -40,6 +49,30 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
return plan
}
/**
* Fills a film channel from the library until it has enough for a week with no repeats, by its own rules. New titles join from its
* next top-up, and what's already planned stays. Anything can be taken out under Titles, and then never comes back. Once a week at most.
*/
fun fillFilmPool(session: EmbySampleSession, userId: String, programming: ChannelProgramming, loop: SampleChannel): Boolean {
val number = programming.number
val now = clock()
val pool = store.loadPool(userId, number) ?: return false
if (!needsFilmFill(programming, pool.series.size)) return false
if (now - store.loadFilled(userId)[number].orZero() < POOL_REFRESH_MS) return false
val choices = store.loadChoices(userId, number)
val taken = SmartChannels.filter { it.filmRestMs != null && it.number != number }.flatMap { store.loadPool(userId, it.number)?.series.orEmpty() }.map { seriesKey(it.title) }.toSet()
val read = session.suggestFor(programming.suggestionRules, pool.series.map { it.id }.toSet() + choices.accepted.map { it.id }, choices.rejected, taken)
val added = read.suggestions.take(FILM_POOL_TARGET - pool.series.size)
store.saveFilled(userId, store.loadFilled(userId) + (number to now))
if (added.isEmpty()) return false
store.saveChoices(userId, number, ChannelChoices(choices.accepted + added, choices.rejected))
topUp(session, userId, programming, loop)
Log.i("OrbitSchedule", "channel=$number filmsAdded=${added.size} requests=${read.requests}")
return true
}
private fun Long?.orZero() = this ?: 0L
/** A file that wouldn't play. It stays in what's already planned, and is left out from here on. */
fun markFailed(userId: String, itemId: String) {
store.saveFailed(userId, store.loadFailed(userId) + itemId)
@@ -33,10 +33,10 @@ internal const val SUGGESTION_MAX_PAGES = 3
internal class SuggestionRead(val suggestions: List<Suggestion>, val requests: Int)
/** The first playable copy of a title, as (source id, runtime). */
internal fun playableSource(row: org.json.JSONObject, canDecode: (String) -> Boolean = { true }): Pair<String, Long>? {
internal fun playableSource(row: org.json.JSONObject, canDecode: (String) -> Boolean = { true }, englishAudio: Boolean = false): Pair<String, Long>? {
val sources = row.optJSONArray("MediaSources") ?: return null
val source = (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) }
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) && sourceVideoCodec(it)?.let(canDecode) != false } ?: return null
.firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) && sourceVideoCodec(it)?.let(canDecode) != false && (!englishAudio || hasEnglishAudio(it)) } ?: return null
val ticks = source.optLong("RunTimeTicks", 0).takeIf { it > 0 } ?: row.optLong("RunTimeTicks", 0)
val duration = (ticks / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } ?: return null
return source.getString("Id") to duration
@@ -44,7 +44,9 @@ internal fun playableSource(row: org.json.JSONObject, canDecode: (String) -> Boo
/** Highest rated first, in bounded pages, one rule at a time. A title is never suggested twice, and a rejected one never again. */
internal fun findSuggestions(transport: EmbyTransport, userId: String, token: () -> String, rules: List<SuggestionRule>,
alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}, canDecode: (String) -> Boolean = { true }): SuggestionRead {
alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}, canDecode: (String) -> Boolean = { true },
/** Titles another channel already has, as seriesKey values, so each channel keeps films of its own. */
takenTitles: Set<String> = emptySet(), englishAudio: Boolean = false): SuggestionRead {
require(EmbyClient.validId(userId))
val found = LinkedHashMap<String, Suggestion>()
val seen = mutableSetOf<String>()
@@ -76,8 +78,8 @@ internal fun findSuggestions(transport: EmbyTransport, userId: String, token: ()
val score = row.optDouble("CommunityRating", 0.0)
if (score > rule.maxRating) continue
if (row.optString("OfficialRating") in rule.excludeRatings) continue
if (!seen.add("${seriesKey(title)}#$year")) continue
val source = if (rule.type == "Movie") playableSource(row, canDecode) ?: continue else null
if (seriesKey(title) in takenTitles || !seen.add("${seriesKey(title)}#$year")) continue
val source = if (rule.type == "Movie") playableSource(row, canDecode, englishAudio) ?: continue else null
if (source != null && source.second < rule.minMinutes * 60_000L) continue
val reason = "${rule.label}: ${rule.genres.joinToString(" and ")}, ${year ?: "year unknown"}, rated ${"%.1f".format(java.util.Locale.UK, score)}"
found[id] = Suggestion(id, title, year, rule.type, rule.group, reason, source?.first, source?.second)
@@ -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.73.1\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.74.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -263,6 +263,18 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
try { driveInPlan = withContext(Dispatchers.IO) { scheduler.topUpDriveIn(active.accountId) } }
catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* Tried again next hour. */ }
// Quiet, and one at a time, so the provider is never rushed.
for (programming in FilmFillOrder.mapNotNull { number -> SmartChannels.firstOrNull { it.number == number } }) {
val loop = smartLoop(programming) ?: continue
try {
val filled = withContext(Dispatchers.IO) { scheduler.fillFilmPool(active, active.accountId, programming, loop) }
if (filled) {
withContext(Dispatchers.IO) { scheduleStore.loadPlan(active.accountId, programming.number) }?.let { fresh -> scheduledChannels[programming.number]?.plan = fresh }
delay(20_000)
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* Tried again next week. */ }
}
delay(60 * 60_000L)
}
}
@@ -398,7 +410,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val inPool = withContext(Dispatchers.IO) { scheduleStore.loadPool(active.accountId, programming.number)?.series.orEmpty().map { it.id }.toSet() } - choices.accepted.map { it.id }.toSet()
val read = withContext(Dispatchers.IO) {
val context = coroutineContext
active.suggestFor(programming.suggestionRules, inPool + choices.accepted.map { it.id }, choices.rejected) { context.ensureActive() }
active.suggestFor(programming.suggestionRules, inPool + choices.accepted.map { it.id }, choices.rejected, checkActive = { context.ensureActive() })
}
android.util.Log.i("OrbitSchedule", "channel=${programming.number} suggestions=${read.suggestions.size} requests=${read.requests} suggestMs=${android.os.SystemClock.elapsedRealtime() - started}")
suggestions = choices.accepted + read.suggestions
@@ -224,8 +224,8 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
}
return PoolRead(pool, requests)
}
fun suggestFor(rules: List<SuggestionRule>, alreadyInPool: Set<String>, rejected: Set<String>, checkActive: () -> Unit = {}) =
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive, BoxDecoders::canDecode)
fun suggestFor(rules: List<SuggestionRule>, alreadyInPool: Set<String>, rejected: Set<String>, takenTitles: Set<String> = emptySet(), checkActive: () -> Unit = {}) =
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive, BoxDecoders::canDecode, takenTitles, englishAudio = true)
fun fetchArtwork(path: String): ByteArray? = (transport as? HttpsEmbyTransport)?.fetchImage(path, currentToken())
fun rememberScheduledItems(items: List<SampleItem>) { channelEpisodes = channelEpisodes + items.associateBy { it.id } }
@@ -140,6 +140,14 @@ internal class ScheduleStore(private val directory: File) {
(0 until items.length()).map { validId(items.getString(it)) }.toSet()
}.orEmpty()
/** When each channel's films were last filled in automatically. */
fun saveFilled(userId: String, filled: Map<Int, Long>) =
write(file("filled", 0), root(userId, 0).put("filled", JSONObject().also { saved -> filled.forEach { (number, at) -> saved.put(number.toString(), at) } }))
fun loadFilled(userId: String): Map<Int, Long> = read(file("filled", 0), userId, 0) { root ->
val saved = root.getJSONObject("filled")
saved.keys().asSequence().associate { it.toInt() to saved.getLong(it) }
}.orEmpty()
/** Drive-In tickets, by the bill's start time, with how far their reminders have got. */
fun saveTickets(userId: String, tickets: Map<Long, Int>) =
write(file("tickets", 0), root(userId, 0).put("tickets", JSONObject().also { saved -> tickets.forEach { (start, stage) -> saved.put(start.toString(), stage) } }))
@@ -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.73.1")
next.setRequestProperty("User-Agent", "OrbitTV/0.74.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()) {
@@ -94,7 +94,7 @@ private fun ChannelsDetail(busy: Boolean, message: String?, isOn: (Int) -> Boole
Text(programming.number.toString(), Modifier.width(30.dp), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
if (brand != null) Image(painterResource(brand.logo), brand.displayName, Modifier.width(88.dp).height(28.dp))
Text(brand?.displayName ?: programming.name, Modifier.weight(1f), color = Color.White, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (programming.suggestionRules.isNotEmpty()) OrbitButton("Add more", enabled = !busy, onClick = { onSuggestions(programming) })
if (programming.suggestionRules.isNotEmpty()) OrbitButton("Titles", enabled = !busy, onClick = { onSuggestions(programming) })
OrbitButton(if (on) "On" else "Off", Modifier.width(64.dp), selected = on, enabled = !busy, onClick = { onSwitch(programming, !on) })
}
}
@@ -168,4 +168,24 @@ class ChannelSuggestionsTest {
assertEquals(listOf("db"), read.suggestions.map { it.id })
assertTrue(requests.single().path.contains("SearchTerm=Dragon+Ball"))
}
@Test fun aTitleAnotherChannelHasIsLeftOut() {
val rows = arrayOf(row("a", "Taken Film", listOf("Horror", "Comedy")), row("b", "Free Film", listOf("Horror", "Comedy")))
val read = findSuggestions(reply(*rows), user, { "t" }, listOf(rule), emptySet(), emptySet(), takenTitles = setOf(seriesKey("Taken Film")))
assertEquals(listOf("b"), read.suggestions.map { it.id })
}
@Test fun onlyFilmChannelsWithRulesAndRoomAreFilled() {
val premiere = SmartChannels.first { it.number == 301 }
assertTrue(needsFilmFill(premiere, 41))
assertFalse(needsFilmFill(premiere, FILM_POOL_TARGET))
assertFalse(needsFilmFill(SmartChannels.first { it.number == 102 }, 5))
FilmFillOrder.forEach { number -> assertTrue("$number has no rule", SmartChannels.first { it.number == number }.suggestionRules.isNotEmpty()) }
}
@Test fun fillDatesSurviveARestart() {
val folder = java.nio.file.Files.createTempDirectory("filled").toFile()
ScheduleStore(folder).saveFilled(user, mapOf(301 to 5L))
assertEquals(mapOf(301 to 5L), ScheduleStore(folder).loadFilled(user))
}
}
+1
View File
@@ -92,6 +92,7 @@ Paul's ideas, 1 November 2026. Not fixes and not scheduled.
- The highlighted left-menu item could reflect the channel or theme, while yellow stays the clear focus signal.
- Orbit Drive-In gets its own full-screen destination, opened from the teaser card.
- **Rings around the planet (Paul, 1 November 2026):** Orbit 1's background had its rings off to one side of the planet. The app's copy is fixed, from orbit-1/sources/orbit_1_background_v2.svg. Orbit 1's ident and the other channels built from the same planet layout (Orbit 2, Orbit Sci-Fi and any others on that theme) still need checking and the same fix before they join the app.
- **Film pools fill themselves (Paul, 2 November 2026):** in 0.74, the first piece of the programming director. Each film channel fills to 100 films by its own rules, and anything can be taken out under Titles. The rest of the director is to be ironed out with Paul.
- **Build your own network (Paul, 2 November 2026):** for anyone else using the app. On first sign-in, Orbit checks our channels against their library and ticks the ones it can fill, with those channels on by default. They can then edit any channel, adding or removing titles, turn channels on or off, and create new channels of their own. It's about people building what they want, as Paul did. In the pipeline, not scheduled.
- **Seamless idents (Paul, 2 November 2026):** when idents arrive, the next show loads silently behind the ident and takes over as it ends, so there's no loading gap. To test: whether the box can decode two videos at once (otherwise draw the ident in the app), and a second Emby stream for a few seconds against the plan's limit of 3.
- **Settings (Paul, 2 November 2026):** built in 0.72, then cut back in 0.73 to Channels, Watching and Emby account, as Paul wants only what's mandatory and a simple flow. The demo guide, old menu and test tools are gone.