diff --git a/VERIFICATION-0.74.0.md b/VERIFICATION-0.74.0.md new file mode 100644 index 0000000..b1c99e3 --- /dev/null +++ b/VERIFICATION-0.74.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 24c579c..edecf39 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 = 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 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index d0fe5f1..85203b8 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -86,6 +86,9 @@ private val SmartChannelRules: Map> 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")))) diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 121dbc9..cfadfec 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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, 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) diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt index c2ad1e1..eaa84f1 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSuggestions.kt @@ -33,10 +33,10 @@ internal const val SUGGESTION_MAX_PAGES = 3 internal class SuggestionRead(val suggestions: List, 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? { +internal fun playableSource(row: org.json.JSONObject, canDecode: (String) -> Boolean = { true }, englishAudio: Boolean = false): Pair? { 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, - alreadyInPool: Set, rejected: Set, checkActive: () -> Unit = {}, canDecode: (String) -> Boolean = { true }): SuggestionRead { + alreadyInPool: Set, rejected: Set, checkActive: () -> Unit = {}, canDecode: (String) -> Boolean = { true }, + /** Titles another channel already has, as seriesKey values, so each channel keeps films of its own. */ + takenTitles: Set = emptySet(), englishAudio: Boolean = false): SuggestionRead { require(EmbyClient.validId(userId)) val found = LinkedHashMap() val seen = mutableSetOf() @@ -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) 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 078e1fd..5d4fa8a 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) 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 d200bd6..7149a85 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 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 a5fc66d..1348c66 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -224,8 +224,8 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v } return PoolRead(pool, requests) } - fun suggestFor(rules: List, alreadyInPool: Set, rejected: Set, checkActive: () -> Unit = {}) = - findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive, BoxDecoders::canDecode) + fun suggestFor(rules: List, alreadyInPool: Set, rejected: Set, takenTitles: Set = 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) { channelEpisodes = channelEpisodes + items.associateBy { it.id } } diff --git a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt index b5ad229..8af2a38 100644 --- a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt @@ -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) = + 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 = 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) = write(file("tickets", 0), root(userId, 0).put("tickets", JSONObject().also { saved -> tickets.forEach { (start, stage) -> saved.put(start.toString(), stage) } })) 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 0978747..fc268be 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.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()) { diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 566e050..0a4f750 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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) }) } } diff --git a/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt index 64fea76..454b330 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelSuggestionsTest.kt @@ -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)) + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 7b96bac..bb591be 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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.