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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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<String>, val missing: List<String>)
|
||||
|
||||
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>): SeriesCandidate? {
|
||||
|
||||
@@ -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<String>, val seriesCount: Int = 3)
|
||||
internal data class ChannelProgramming(val number: Int, val name: String, val searches: List<String>, 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<EpisodeMark> {
|
||||
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<PoolEpisode>)
|
||||
|
||||
/** 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<String, EpisodeMark> = emptyMap())
|
||||
internal data class ScheduleCursor(val lastSeriesId: String? = null, val marks: Map<String, EpisodeMark> = emptyMap(),
|
||||
/** When each film last started, for its rest period. */
|
||||
val filmShown: Map<String, Long> = 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<PoolSeries>, shown: Map<String, Long>, 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<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long, restMs: Long): ChannelPlan {
|
||||
val shown = cursor.filmShown.toMutableMap()
|
||||
val entries = mutableListOf<ScheduleEntry>()
|
||||
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<PoolSeries>, cursor: ScheduleCursor, fromMs: Long, untilMs: Long): ChannelPlan {
|
||||
internal fun extendSchedule(pool: List<PoolSeries>, 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<ScheduleEntry>()
|
||||
var last = cursor.lastSeriesId
|
||||
@@ -58,20 +98,24 @@ internal fun scheduledItem(seriesTitle: String, episode: PoolEpisode) = SampleIt
|
||||
internal fun airedCursor(base: ScheduleCursor, entries: List<ScheduleEntry>, 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<PoolSeries>?, nowMs: Long): ChannelPlan? {
|
||||
internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List<PoolSeries>?, 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Map<Int, ScheduledChannel>>(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 })
|
||||
|
||||
@@ -186,9 +186,21 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
|
||||
|
||||
internal class PoolRead(val series: List<PoolSeries>, 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<PoolSeries>()
|
||||
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()
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SampleChannel>, 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<SampleChannel>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user