From 6ed45810e6da54bf64c253dd1471ee9975c5b206 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Mon, 2 Nov 2026 06:18:54 +0000 Subject: [PATCH] Add film rotation, Terra and After Dark, guide day jump and clearer switch (0.40.0, Phase B in testing) Co-Authored-By: Claude Opus 5.5 (1M context) --- android/app/build.gradle.kts | 4 +- .../java/tv/orbit/classic/ChannelPresets.kt | 2 +- .../java/tv/orbit/classic/ChannelSchedule.kt | 60 +++++++++++++-- .../java/tv/orbit/classic/ChannelScheduler.kt | 6 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 28 ++++--- .../tv/orbit/classic/EmbyPlaybackProtocol.kt | 14 +++- .../java/tv/orbit/classic/EpisodeDiscovery.kt | 25 ++++++ .../main/java/tv/orbit/classic/GuideGrid.kt | 17 ++++- .../java/tv/orbit/classic/ScheduleStore.kt | 9 ++- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../tv/orbit/classic/ChannelScheduleTest.kt | 76 +++++++++++++++++++ docs/roadmap.md | 11 ++- 13 files changed, 219 insertions(+), 37 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0c424df..d3ad7b3 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 = 40 - versionName = "0.39.0-smart-paradox" + versionCode = 41 + versionName = "0.40.0-smart-terra-after-dark" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt b/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt index 1daf8e9..2e61282 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelPresets.kt @@ -10,7 +10,7 @@ internal val ChannelPresets = listOf( /** 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, val missing: List) -private fun seriesKey(title: String) = title.lowercase(java.util.Locale.ROOT).replace(Regex("[^a-z0-9 ]"), " ").trim().removePrefix("the ").replace(Regex(" +"), " ") +internal 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? { 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 0ac959c..7da1456 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -1,9 +1,18 @@ package tv.orbit.classic /** What makes a channel itself. The engine below knows nothing about any particular channel. */ -internal data class ChannelProgramming(val number: Int, val name: String, val searches: List, val seriesCount: Int = 3) +internal data class ChannelProgramming(val number: Int, val name: String, val searches: List, val seriesCount: Int = 3, + /** Set for a film channel: how long before a film may air again, as long as the pool allows it. */ + val filmRestMs: Long? = null) -internal val SmartChannels = listOf(ChannelProgramming(152, "Paradox", ChannelPresets.first { it.number == 152 }.searches)) +internal val SmartChannels = listOf( + ChannelProgramming(102, "After Dark", listOf("The Thing", "The Evil Dead", "Re-Animator", "Hellraiser", "Videodrome", "An American Werewolf in London", + "Halloween", "Day of the Dead", "They Live", "Escape from New York", "The Fog", "Near Dark", "The Lost Boys", "Fright Night", "Society", "Tremors", + "Basket Case", "Phantasm", "Repo Man", "Donnie Darko", "The Wicker Man", "Suspiria", "Eraserhead", "Blue Velvet", "From Dusk Till Dawn", "Scream", + "Poltergeist", "The Howling", "Creepshow", "Prince of Darkness"), seriesCount = 40, filmRestMs = 48 * 60 * 60_000L), + ChannelProgramming(140, "Terra", listOf("Blue Planet", "Planet Earth", "Frozen Planet", "Life on Earth", "Our Planet", "Dynasties", + "Seven Worlds One Planet", "A Perfect Planet", "Hostile Planet", "The Living Planet"), seriesCount = 8), + ChannelProgramming(152, "Paradox", ChannelPresets.first { it.number == 152 }.searches)) internal data class EpisodeMark(val season: Int, val episode: Int) : Comparable { override fun compareTo(other: EpisodeMark) = compareValuesBy(this, other, { it.season }, { it.episode }) @@ -12,7 +21,9 @@ internal data class PoolEpisode(val id: String, val sourceId: String, val mark: internal data class PoolSeries(val id: String, val title: String, val episodes: List) /** Where a channel has got to: the last episode of each show, and whose turn was last. */ -internal data class ScheduleCursor(val lastSeriesId: String? = null, val marks: Map = emptyMap()) +internal data class ScheduleCursor(val lastSeriesId: String? = null, val marks: Map = emptyMap(), + /** When each film last started, for its rest period. */ + val filmShown: Map = emptyMap()) internal data class ScheduleEntry(val seriesId: String, val mark: EpisodeMark, val media: ScheduledMedia, val startMs: Long) { val endMs get() = startMs + media.durationMs @@ -31,9 +42,38 @@ internal const val SCHEDULE_KEEP_PAST_MS = 24 * 60 * 60_000L internal fun nextEpisode(series: PoolSeries, last: EpisodeMark?): PoolEpisode = last?.let { mark -> series.episodes.firstOrNull { it.mark > mark } } ?: series.episodes.first() +/** + * 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, shown: Map, startMs: Long, restMs: Long): PoolSeries { + val rested = pool.filter { film -> shown[film.id]?.let { startMs - it >= restMs } ?: true } + 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, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, restMs: Long): ChannelPlan { + val shown = cursor.filmShown.toMutableMap() + val entries = mutableListOf() + var last = cursor.lastSeriesId + var start = fromMs + while (start < untilMs) { + val film = nextFilm(pool, shown, start, restMs) + 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) + shown[film.id] = start + last = film.id + start += copy.durationMs + } + return ChannelPlan(entries, ScheduleCursor(last, cursor.marks, shown)) +} + /** Shows take turns, one episode each, back to back from [fromMs] until [untilMs] is covered. */ -internal fun extendSchedule(pool: List, cursor: ScheduleCursor, fromMs: Long, untilMs: Long): ChannelPlan { +internal fun extendSchedule(pool: List, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, filmRestMs: Long? = null): ChannelPlan { require(pool.isNotEmpty() && pool.all { it.episodes.isNotEmpty() }) + if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs) val marks = cursor.marks.toMutableMap() val entries = mutableListOf() var last = cursor.lastSeriesId @@ -58,20 +98,24 @@ internal fun scheduledItem(seriesTitle: String, episode: PoolEpisode) = SampleIt internal fun airedCursor(base: ScheduleCursor, entries: List, nowMs: Long): ScheduleCursor { val marks = base.marks.toMutableMap() var last = base.lastSeriesId - entries.takeWhile { it.startMs <= nowMs }.forEach { marks[it.seriesId] = it.mark; last = it.seriesId } - return ScheduleCursor(last, marks) + val shown = base.filmShown.toMutableMap() + entries.takeWhile { it.startMs <= nowMs }.forEach { + marks[it.seriesId] = it.mark; last = it.seriesId + if (it.media.item.type == "Movie") shown[it.seriesId] = it.startMs + } + return ScheduleCursor(last, marks, shown) } /** * Only ever appends. Programmes already committed stay as they are, apart from dropping old ones. * With no usable plan it starts from now at the aired position. */ -internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List?, nowMs: Long): ChannelPlan? { +internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List?, nowMs: Long, filmRestMs: Long? = null): 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) + val added = extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs) return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail) } 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 3d4c5e9..07a5d37 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -30,7 +30,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl * Brings a channel's plan up to seven days ahead, reading the provider only if the cached pool is missing or old. * A failed refresh keeps what's there, so a provider outage can't disturb what's committed. */ - fun topUp(session: EmbySampleSession, userId: String, programming: ChannelProgramming, checkActive: () -> Unit = {}): SchedulerReport { + fun topUp(session: EmbySampleSession, userId: String, programming: ChannelProgramming, loop: SampleChannel, checkActive: () -> Unit = {}): SchedulerReport { val number = programming.number val now = clock() val cached = store.loadPool(userId, number) @@ -40,7 +40,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl if (cached == null || now - cached.fetchedAtMs >= POOL_REFRESH_MS) { val started = android.os.SystemClock.elapsedRealtime() try { - val read = session.readChannelPool(programming, checkActive) + val read = session.readChannelPool(programming, loop, checkActive) requests = read.requests fetchMs = android.os.SystemClock.elapsedRealtime() - started if (read.series.isNotEmpty()) { @@ -53,7 +53,7 @@ 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) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.") + val plan = topUpPlan(existing, base, pool?.series, now, programming.filmRestMs) ?: 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 }) 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 dfea698..58f9e1a 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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.39.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.40.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 a323729..c4a4234 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -94,6 +94,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val scheduleStore = remember { ScheduleStore(java.io.File(appContext.noBackupFilesDir, "schedule")) } val scheduler = remember { ChannelScheduler(scheduleStore) } var scheduledChannels by remember { mutableStateOf>(emptyMap()) } + var lastSmartPress by remember { mutableLongStateOf(0L) } fun onAir(loop: SampleChannel) = scheduledChannels[loop.number]?.takeIf { it.loop === loop } ?: loop var channelClockOffset by remember { mutableLongStateOf(0) } var busyLabel by remember { mutableStateOf("Connecting…") } @@ -115,7 +116,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea seriesMatches = saved.matches.takeIf { it.isNotEmpty() }; approvedSeries = saved.approved 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, *extraChannels.map(::onAir).toTypedArray()) + val available = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel, *extraChannels.map(::onAir).toTypedArray()) selectedNumber = available.firstOrNull { it.number == saved.selectedNumber }?.number ?: available.firstOrNull()?.number ?: 101 onChannelAvailable(available.isNotEmpty()) } @@ -173,7 +174,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } // Read before the channels appear, so the guide never shows the loop for a moment first. scheduledChannels = withContext(Dispatchers.IO) { - saved.extra.mapNotNull { loop -> scheduler.load(saved.identity.userId, loop)?.also { resumed.rememberScheduledItems(it.plan.entries.map { entry -> entry.media.item }) } }.associateBy { it.number } + (saved.extra + listOfNotNull(saved.afterDark)).mapNotNull { loop -> scheduler.load(saved.identity.userId, loop)?.also { resumed.rememberScheduledItems(it.plan.entries.map { entry -> entry.media.item }) } }.associateBy { it.number } } session = resumed; sample = resumed.sample; applyChoices(saved) saveTicket = savedStore.ticket() @@ -212,16 +213,17 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } } LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } } + fun smartLoop(programming: ChannelProgramming) = (extraChannels + listOfNotNull(afterDarkChannel)).firstOrNull { it.number == programming.number } // Keeps smart channels topped up in the background. A failure changes nothing on air. - LaunchedEffect(session, extraChannels) { + LaunchedEffect(session, extraChannels, afterDarkChannel) { val active = session ?: return@LaunchedEffect while (true) { for (programming in SmartChannels) { - val loop = extraChannels.firstOrNull { it.number == programming.number } ?: continue + val loop = smartLoop(programming) ?: continue try { withContext(Dispatchers.IO) { if (scheduler.enabled(active.accountId, programming.number)) { - scheduler.topUp(active, active.accountId, programming) + scheduler.topUp(active, active.accountId, programming, loop) val fresh = scheduleStore.loadPlan(active.accountId, programming.number) val onAirNow = scheduledChannels[programming.number]?.takeIf { it.loop === loop } if (fresh != null && onAirNow != null) onAirNow.plan = fresh @@ -251,7 +253,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, *extraChannels.map(::onAir).toTypedArray()) + val availableChannels = availableSampleChannels(channel, afterDarkChannel?.let(::onAir), toonamiChannel, *extraChannels.map(::onAir).toTypedArray()) val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber } if ((mainGuide || mainHome) && liveSession != null && liveChannel != null) { if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset, @@ -422,8 +424,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } fun setSmartSchedule(programming: ChannelProgramming, turnOn: Boolean) { val active = session ?: return - val loop = extraChannels.firstOrNull { it.number == programming.number } ?: return - if (busy) return + val loop = smartLoop(programming) ?: return + // A second press straight after the first is taken as a slip. + if (busy || now - lastSmartPress < 5_000) return + lastSmartPress = now busy = true; busyLabel = "Reading ${programming.name}'s whole series and planning seven days…"; message = null job = scope.launch { try { @@ -431,11 +435,11 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val report = withContext(Dispatchers.IO) { val context = coroutineContext scheduler.setEnabled(active.accountId, programming.number, true) - scheduler.topUp(active, active.accountId, programming) { context.ensureActive() }.also { + scheduler.topUp(active, active.accountId, programming, loop) { context.ensureActive() }.also { scheduler.load(active.accountId, loop)?.let { on -> scheduledChannels = scheduledChannels + (programming.number to on) } } } - message = "${programming.name} is scheduled: ${report.programmes} programmes from ${report.episodes} episodes of ${report.shows.joinToString()}. " + + message = "${programming.name} is scheduled: ${report.programmes} programmes from ${report.episodes} titles: ${report.shows.take(10).joinToString()}${if (report.shows.size > 10) " and ${report.shows.size - 10} more" else ""}. " + "${report.requests} requests, ${report.fetchMs / 1000} s to read, ${report.planMs} ms to plan, ${report.bytes / 1024} KB stored." } else { withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) } @@ -636,8 +640,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } SmartChannels.forEach { programming -> val on = scheduledChannels.containsKey(programming.number) - OrbitButton("${programming.number} ${programming.name} — smart schedule ${if (on) "on (press for loop)" else "off (press to start)"}", Modifier.fillMaxWidth(), - enabled = !busy && session != null && extraChannels.any { it.number == programming.number }, onClick = { setSmartSchedule(programming, !on) }) + OrbitButton("${programming.number} ${programming.name} — smart schedule is ${if (on) "ON. Press to go back to its loop" else "OFF. Press to turn on"}", Modifier.fillMaxWidth(), + enabled = !busy && session != null && smartLoop(programming) != null, onClick = { setSmartSchedule(programming, !on) }) } OrbitButton("Check metadata coverage", Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = { message = null; reviewingCoverage = true }) 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 715465b..5ac770f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -186,9 +186,21 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v internal class PoolRead(val series: List, val requests: Int) /** The first shows in the channel's list that have episodes to schedule. */ - fun readChannelPool(programming: ChannelProgramming, checkActive: () -> Unit = {}): PoolRead { + fun readChannelPool(programming: ChannelProgramming, loop: SampleChannel, checkActive: () -> Unit = {}): PoolRead { var requests = 0 val pool = mutableListOf() + if (programming.filmRestMs != null) { + // Films you've already chosen for this channel stay in it. + loop.lineup.filter { it.item.type == "Movie" }.forEach { pool += PoolSeries(it.item.id, it.item.title, listOf(PoolEpisode(it.item.id, it.sourceId, EpisodeMark(1, 1), it.item.title, it.durationMs))) } + for (title in programming.searches) { + if (pool.size >= programming.seriesCount) break + checkActive() + requests++ + val film = findFilm(transport, userId, ::currentToken, title) ?: continue + if (pool.none { it.id == film.id }) pool += film + } + return PoolRead(pool, requests) + } for (search in programming.searches) { if (pool.size == programming.seriesCount) break checkActive() diff --git a/android/app/src/main/java/tv/orbit/classic/EpisodeDiscovery.kt b/android/app/src/main/java/tv/orbit/classic/EpisodeDiscovery.kt index 76b3ebe..ae538f8 100644 --- a/android/app/src/main/java/tv/orbit/classic/EpisodeDiscovery.kt +++ b/android/app/src/main/java/tv/orbit/classic/EpisodeDiscovery.kt @@ -87,3 +87,28 @@ private fun poolEpisode(item: org.json.JSONObject): PoolEpisode? { val duration = (ticks / 10_000).takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } ?: return null return PoolEpisode(id, source.getString("Id"), EpisodeMark(season, number), EmbyClient.clean(item.optString("Name"), 160).ifBlank { "Untitled episode" }, duration) } + +/** One film by its exact title, oldest first, with a playable source. Near matches don't count, so a sequel never slips in. */ +internal fun findFilm(transport: EmbyTransport, userId: String, token: () -> String, title: String): PoolSeries? { + require(EmbyClient.validId(userId)) + val term = java.net.URLEncoder.encode(title, "UTF-8") + val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Movie&SearchTerm=$term&StartIndex=0&Limit=10&SortBy=SortName&SortOrder=Ascending&Fields=MediaSources&EnableImages=false&EnableUserData=false", + token = token(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS)) + EmbyClient.expectSuccess(reply.status, false) + val rows = EmbyClient.parse(reply.body).optJSONArray("Items") ?: return null + val key = seriesKey(title) + return (0 until minOf(rows.length(), 10)).mapNotNull { rows.optJSONObject(it) } + .filter { it.optString("Type") == "Movie" && seriesKey(EmbyClient.clean(it.optString("Name"), 160)) == key } + .sortedBy { it.optInt("ProductionYear", Int.MAX_VALUE).takeIf { year -> year > 0 } ?: Int.MAX_VALUE } + .firstNotNullOfOrNull { row -> + val id = row.optString("Id") + if (!EmbyClient.validId(id)) return@firstNotNullOfOrNull null + val sources = row.optJSONArray("MediaSources") ?: return@firstNotNullOfOrNull null + val source = (0 until minOf(sources.length(), 20)).mapNotNull { sources.optJSONObject(it) } + .firstOrNull { supportedDirectContainer(it.optString("Container")) != null && EmbyClient.validId(it.optString("Id")) } ?: return@firstNotNullOfOrNull 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@firstNotNullOfOrNull null + val name = EmbyClient.clean(row.optString("Name"), 160) + PoolSeries(id, name, listOf(PoolEpisode(id, source.getString("Id"), EpisodeMark(1, 1), name, duration))) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt index 1bc0ea5..2a9c4d7 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -57,6 +57,10 @@ internal fun guideDayPrefix(timeMs: Long, nowMs: Long): String { return if (day.format(java.util.Date(timeMs)) == day.format(java.util.Date(nowMs))) "" else java.text.SimpleDateFormat("EEE d MMM ", java.util.Locale.UK).format(java.util.Date(timeMs)) } +/** Holding left or right jumps a day at a time, staying between now and a week ahead. */ +internal fun guideDayJump(cursorMs: Long, nowMs: Long, forward: Boolean): Long = + (maxOf(cursorMs, nowMs) + if (forward) 24 * 60 * 60_000L else -24 * 60 * 60_000L).coerceIn(nowMs, nowMs + GUIDE_MAX_SLOTS_AHEAD * GUIDE_SLOT_MS - 1) + /** The first row to draw, moving only as far as it takes to keep the chosen row on screen. */ internal fun guideFirstRow(row: Int, first: Int, visible: Int, count: Int): Int { val last = (count - visible).coerceAtLeast(0) @@ -139,10 +143,15 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif when (native.keyCode) { KeyEvent.KEYCODE_DPAD_UP -> { if (row > 0) row-- else toolsFocus.requestFocus(); true } KeyEvent.KEYCODE_DPAD_DOWN -> { if (row < channels.lastIndex) row++; true } - KeyEvent.KEYCODE_DPAD_RIGHT -> { - if (slotsAhead < GUIDE_MAX_SLOTS_AHEAD) keepCursorInWindow(guideStep(channel, cursor, now, true)); true + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_LEFT -> { + val forward = native.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + when { + native.repeatCount == 0 -> if (!forward || slotsAhead < GUIDE_MAX_SLOTS_AHEAD) keepCursorInWindow(guideStep(channel, cursor, now, forward)) + // Key repeats come quickly, so a day for every ten of them. + native.repeatCount % 10 == 0 -> keepCursorInWindow(guideDayJump(cursor, now, forward)) + } + true } - KeyEvent.KEYCODE_DPAD_LEFT -> { keepCursorInWindow(guideStep(channel, cursor, now, false)); true } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { if (native.repeatCount == 0 && focused.startMs <= now) onWatch(channel.number, 0); true } @@ -178,6 +187,6 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif } } } - Text("▲▼ Channel ◄► Time OK Watch Back Home", color = Muted, fontSize = 11.sp) + Text("▲▼ Channel ◄► Time Hold ◄► Day OK Watch Back Home", color = Muted, fontSize = 11.sp) } } 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 09575c0..8227149 100644 --- a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt @@ -42,10 +42,13 @@ internal class ScheduleStore(private val directory: File) { private fun encodeCursor(cursor: ScheduleCursor) = JSONObject().put("last", cursor.lastSeriesId ?: JSONObject.NULL) .put("marks", JSONObject().also { marks -> cursor.marks.forEach { (id, mark) -> marks.put(id, JSONArray().put(mark.season).put(mark.episode)) } }) + .put("shown", JSONObject().also { shown -> cursor.filmShown.forEach { (id, at) -> shown.put(id, at) } }) private fun decodeCursor(o: JSONObject): ScheduleCursor { val marks = o.getJSONObject("marks") return ScheduleCursor(if (o.isNull("last")) null else validId(o.getString("last")), - marks.keys().asSequence().associate { id -> validId(id) to marks.getJSONArray(id).let { EpisodeMark(it.getInt(0), it.getInt(1)) } }) + marks.keys().asSequence().associate { id -> validId(id) to marks.getJSONArray(id).let { EpisodeMark(it.getInt(0), it.getInt(1)) } }, + // Absent in files saved before film channels existed. + o.optJSONObject("shown")?.let { shown -> shown.keys().asSequence().associate { id -> validId(id) to shown.getLong(id) } }.orEmpty()) } private fun validId(id: String) = id.also { require(EmbyClient.validId(it)) } private fun validDuration(ms: Long) = ms.also { require(it in 1_000..SampleChannel.MAX_DURATION_MS) } @@ -53,7 +56,7 @@ internal class ScheduleStore(private val directory: File) { fun savePlan(userId: String, number: Int, plan: ChannelPlan) { val entries = JSONArray() plan.entries.forEach { e -> entries.put(JSONObject().put("s", e.seriesId).put("n", JSONArray().put(e.mark.season).put(e.mark.episode)) - .put("i", e.media.item.id).put("c", e.media.sourceId).put("t", e.media.item.title).put("d", e.media.durationMs).put("a", e.startMs)) } + .put("i", e.media.item.id).put("c", e.media.sourceId).put("t", e.media.item.title).put("d", e.media.durationMs).put("a", e.startMs).also { row -> if (e.media.item.type == "Movie") row.put("k", "Movie") }) } write(file("schedule", number), root(userId, number).put("tail", encodeCursor(plan.tail)).put("entries", entries)) } fun loadPlan(userId: String, number: Int): ChannelPlan? = read(file("schedule", number), userId, number) { root -> @@ -62,7 +65,7 @@ internal class ScheduleStore(private val directory: File) { val o = rows.getJSONObject(index) val duration = validDuration(o.getLong("d")) val mark = o.getJSONArray("n").let { EpisodeMark(it.getInt(0), it.getInt(1)) } - val item = SampleItem(o.getString("t"), "Episode", duration / 60_000, "", validId(o.getString("i")), duration) + val item = SampleItem(o.getString("t"), o.optString("k", "Episode").also { require(it in listOf("Episode", "Movie")) }, duration / 60_000, "", validId(o.getString("i")), duration) ScheduleEntry(validId(o.getString("s")), mark, ScheduledMedia(item, validId(o.getString("c")), duration), o.getLong("a")) } require(entries.zipWithNext().all { (first, second) -> first.endMs <= second.startMs }) 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 ce33ca8..7653620 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.39.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.40.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/test/java/tv/orbit/classic/ChannelScheduleTest.kt b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt index 7f2e70c..34ad8bc 100644 --- a/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/ChannelScheduleTest.kt @@ -220,4 +220,80 @@ class ChannelScheduleTest { assertEquals("", guideDayPrefix(now, now)) assertTrue(guideDayPrefix(now + 3 * day, now).isNotEmpty()) } + + private fun film(id: String, minutes: Long = 100) = PoolSeries(id, "Film $id", listOf(PoolEpisode(id, "src-$id", EpisodeMark(1, 1), "Film $id", minutes * minute))) + private val films = (1..40).map { film("f$it", 80L + it) } + private val rest = 48 * 60 * minute + + @Test fun filmsKeepTheirRestPeriodWhenThePoolAllowsIt() { + val entries = extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest).entries + entries.groupBy { it.seriesId }.values.forEach { showings -> + showings.zipWithNext().forEach { (a, b) -> assertTrue(b.startMs - a.startMs >= rest) } + } + assertTrue(entries.zipWithNext().all { (a, b) -> a.endMs == b.startMs }) + assertTrue(entries.all { it.media.item.type == "Movie" && !it.media.item.title.contains(" / ") }) + } + + @Test fun aSmallFilmPoolStillFillsThePlanWithWhatHasWaitedLongest() { + val two = listOf(film("a"), film("b")) + val entries = extendSchedule(two, ScheduleCursor(), now, now + day, rest).entries + assertEquals(listOf("a", "b", "a", "b").toSet(), entries.map { it.seriesId }.toSet()) + assertTrue(entries.zipWithNext().all { (x, y) -> x.seriesId != y.seriesId }) + } + + @Test fun filmOrderIsNotASimpleLoopButIsReproducible() { + val first = extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest).entries.map { it.seriesId } + assertEquals(first, extendSchedule(films, ScheduleCursor(), now, now + 7 * day, rest).entries.map { it.seriesId }) + val cycle = first.take(40) + assertNotEquals(films.map { it.id }, cycle) + } + + @Test fun filmRestSurvivesASaveAndAnExtension() { + val store = store() + val plan = topUpPlan(null, ScheduleCursor(), films, now, rest)!! + store.savePlan(user, 102, plan) + val read = store.loadPlan(user, 102)!! + assertEquals(plan, read) + val later = topUpPlan(read, ScheduleCursor(), films, now + 2 * day, rest)!! + later.entries.groupBy { it.seriesId }.values.forEach { showings -> + showings.zipWithNext().forEach { (a, b) -> assertTrue(b.startMs - a.startMs >= rest) } + } + } + + @Test fun aSavedPlanFromBeforeFilmsStillOpens() { + val dir = folder.newFolder() + val store = ScheduleStore(dir) + store.savePlan(user, 152, topUpPlan(null, ScheduleCursor(), pool, now)!!) + val file = java.io.File(dir, "schedule-152.json") + val root = JSONObject(file.readText()) + root.getJSONObject("tail").remove("shown") + file.writeText(root.toString()) + assertNotNull(store.loadPlan(user, 152)) + } + + @Test fun airedFilmsAreRemembered() { + val plan = extendSchedule(films, ScheduleCursor(), now, now + day, rest) + val aired = airedCursor(ScheduleCursor(), plan.entries, plan.entries[2].startMs) + assertEquals(plan.entries[2].startMs, aired.filmShown[plan.entries[2].seriesId]) + assertEquals(3, aired.filmShown.size) + } + + @Test fun onlyAnExactFilmTitleCounts() { + val rows = JSONArray() + .put(JSONObject().put("Id", "sequel").put("Type", "Movie").put("Name", "Halloween II").put("ProductionYear", 1981).put("RunTimeTicks", 60_000_000_000L) + .put("MediaSources", JSONArray().put(JSONObject().put("Id", "s1").put("Container", "mkv")))) + .put(JSONObject().put("Id", "remake").put("Type", "Movie").put("Name", "Halloween").put("ProductionYear", 2007).put("RunTimeTicks", 60_000_000_000L) + .put("MediaSources", JSONArray().put(JSONObject().put("Id", "s2").put("Container", "mkv")))) + .put(JSONObject().put("Id", "original").put("Type", "Movie").put("Name", "Halloween").put("ProductionYear", 1978).put("RunTimeTicks", 60_000_000_000L) + .put("MediaSources", JSONArray().put(JSONObject().put("Id", "s3").put("Container", "mkv")))) + val transport = EmbyTransport { EmbyReply(200, JSONObject().put("Items", rows).toString()) } + assertEquals("original", findFilm(transport, user, { "t" }, "Halloween")?.id) + assertNull(findFilm(transport, user, { "t" }, "Halloween Ends")) + } + + @Test fun aDayJumpStaysBetweenNowAndAWeekAhead() { + assertEquals(now + day, guideDayJump(now, now, true)) + assertEquals(now, guideDayJump(now + 60 * minute, now, false)) + assertTrue(guideDayJump(now + 7 * day, now, true) < now + 7 * day) + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 36edd8e..d571bbd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -71,7 +71,16 @@ 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) is built and on the box as 0.39, on Paradox only, awaiting Paul's review. See VERIFICATION-0.39.0.md. Phases B to D (Terra and After Dark, Vortex, the remaining channels) need separate approval. 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) is built as 0.40, in testing. Phases C and D (Vortex, the remaining channels) need separate approval. Animated idents are design files only and aren't in the app yet. + +## Programming director + +Paul's direction, 2 November 2026, for after broadcast reliability is proven. Proposed, not built. + +- **Two tracks:** broadcast reliability (episode progression, schedule recovery, film rotation, day parts) comes first. Smart programming (channel identity rules, discovery, variety, explainable recommendations, and eventually local learning) follows. +- **Two parts:** the scheduler executes a stable programme plan. A separate programming director decides what belongs in that plan. +- **Personality setting:** Familiar (more established favourites), Balanced (favourites mixed with discoveries), Surprise Me (more unusual programming). Illustrative, not in the app. +- **Ownership:** rule-based and transparent, with lightweight preference signals. Learning can be switched off entirely and reset. No cloud, no mandatory AI, no uploading viewing history. ## Future polish ideas