Premiere as a broadcast stage: first-run event, then the genre home

Paul's policy, 5 November 2026. The Director's Chair makes two
recommendations: a genre home (Premiere is never one) and, for a film
never booked on Orbit that suits an event debut, a first run on Orbit
Premiere. 'Premiere, then <home>' puts it on Premiere's list with a debut
as a fixed slot on the first Friday or Saturday at 20:00 after Premiere's
committed plan (clear of Advent's doors), branded FIRST ON ORBIT. Four weeks
after the debut it leaves Premiere's list for its home's, for that
channel's future schedule. Nothing already booked changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 14:56:43 +00:00
co-authored by Claude Opus 5.5
parent 652139a5ee
commit 4bea3e7db5
6 changed files with 279 additions and 26 deletions
@@ -175,6 +175,8 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
SeriesInspection.take()?.let { title -> runCatching { session.inspectSeries(title, store.loadFailed(userId)) }.getOrElse { listOf("inspect failed: ${it.javaClass.simpleName} ${(it as? EmbyCheckException)?.httpStatus} ${it.message?.take(160)}") }.forEach { Log.i("OrbitSchedule", it) } }
val number = programming.number
val now = clock()
// A film whose premiere window has ended moves from Premiere's list to its home's, before either reads its list.
runCatching { graduatePremieres(store, userId, now) }.onSuccess { if (it.isNotEmpty()) Log.i("OrbitSchedule", "premiere graduated channels=${it.sorted()}") }
val cached = store.loadPool(userId, number)
val choices = store.loadChoices(userId, number)
val signature = "${programming.signature}#${choices.tag}"
@@ -215,6 +217,11 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
clashesWithAirings((SmartChannels.map { it.number }.filter { it != number } + DRIVE_IN_NUMBER).flatMap { airingsOn(userId, it) })
.also { othersMs = android.os.SystemClock.elapsedRealtime() - began }
}
// Advent on Orbit: this channel's doors as fixed slots, from films verified on the box; every film channel holds them back.
val fixed = (if (programming.filmRestMs != null && (AdventDoors.any { it.channel == number } || number == AFTER_DARK_NUMBER))
adventFilms(userId, session, now).associateBy { it.id }.let { films -> adventSlots(number, films) + naughtySlots(number, films) } else emptyList()) +
// Premiere's debuts, placed from the Director's Chair: fixed first showings that only append.
(if (number == PREMIERE_NUMBER) premiereSlots(loadPremiereRuns(store.premiereRunsFile()), pool?.series.orEmpty().associateBy { it.id }) else emptyList())
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes, mixed = programming::mixes,
weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
@@ -42,13 +43,16 @@ internal class DeskOption(val number: Int, val name: String, val reason: String)
/** A discovery: [options] are where it can go, its suggested channel first. [id], [type] and [year] are the library item's, for placing it. */
internal class DeskPick(val title: String, val channel: String, val tag: String, val line: String, val id: String = "", val type: String = "", val year: Int? = null,
val options: List<DeskOption> = emptyList())
val options: List<DeskOption> = emptyList(),
/** Its first-run event: Orbit Premiere, offered only for a film never booked on Orbit that suits an event debut. */
val firstRun: DeskOption? = null)
/** An either-or call: [options] are the two channels it's torn between. */
internal class DeskAsk(val title: String, val first: String, val second: String, val id: String = "", val type: String = "", val year: Int? = null,
val options: List<DeskOption> = emptyList(), val line: String = "")
val options: List<DeskOption> = emptyList(), val line: String = "", val firstRun: DeskOption? = null)
private fun JSONObject.item(id: String, type: String, year: Int?, options: List<DeskOption>) =
put("i", id).put("k", type).put("y", year ?: JSONObject.NULL).put("o", JSONArray(options.map { it.toJson() }))
private fun JSONObject.item(id: String, type: String, year: Int?, options: List<DeskOption>, firstRun: DeskOption?) =
put("i", id).put("k", type).put("y", year ?: JSONObject.NULL).put("o", JSONArray(options.map { it.toJson() })).put("f", firstRun?.toJson() ?: JSONObject.NULL)
private fun JSONObject.firstRun() = optJSONObject("f")?.let(DeskOption::of)
private fun JSONObject.options() = optJSONArray("o")?.let { a -> (0 until a.length()).map { DeskOption.of(a.getJSONObject(it)) } }.orEmpty()
private fun JSONObject.year() = if (isNull("y")) null else optInt("y").takeIf { it > 0 }
@@ -58,8 +62,8 @@ internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val sca
val decided: Int = 0) {
fun toJson(): JSONObject = JSONObject().put("newlyReleased", newlyReleased).put("newToLibrary", newToLibrary).put("scanned", scanned).put("unread", unread)
.put("strong", strong).put("strongByChannel", JSONArray(strongByChannel.map { JSONObject().put("c", it.first).put("n", it.second) }))
.put("picks", JSONArray(picks.map { JSONObject().put("t", it.title).put("c", it.channel).put("g", it.tag).put("l", it.line).item(it.id, it.type, it.year, it.options) }))
.put("asks", JSONArray(asks.map { JSONObject().put("t", it.title).put("a", it.first).put("b", it.second).put("l", it.line).item(it.id, it.type, it.year, it.options) }))
.put("picks", JSONArray(picks.map { JSONObject().put("t", it.title).put("c", it.channel).put("g", it.tag).put("l", it.line).item(it.id, it.type, it.year, it.options, it.firstRun) }))
.put("asks", JSONArray(asks.map { JSONObject().put("t", it.title).put("a", it.first).put("b", it.second).put("l", it.line).item(it.id, it.type, it.year, it.options, it.firstRun) }))
.put("collections", collections).put("franchises", franchises).put("catalogueComplete", catalogueComplete).put("computedMs", computedMs).put("tookMs", tookMs)
.put("decided", decided)
companion object {
@@ -67,20 +71,22 @@ internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val sca
fun list(key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.orEmpty()
return DeskReport(o.optInt("newlyReleased"), o.optInt("newToLibrary"), o.optInt("scanned"), o.optInt("unread"),
list("strongByChannel").map { it.optString("c") to it.optInt("n") }, o.optInt("strong"),
list("picks").map { DeskPick(it.optString("t"), it.optString("c"), it.optString("g"), it.optString("l"), it.optString("i"), it.optString("k"), it.year(), it.options()) },
list("asks").map { DeskAsk(it.optString("t"), it.optString("a"), it.optString("b"), it.optString("i"), it.optString("k"), it.year(), it.options(), it.optString("l")) },
list("picks").map { DeskPick(it.optString("t"), it.optString("c"), it.optString("g"), it.optString("l"), it.optString("i"), it.optString("k"), it.year(), it.options(), it.firstRun()) },
list("asks").map { DeskAsk(it.optString("t"), it.optString("a"), it.optString("b"), it.optString("i"), it.optString("k"), it.year(), it.options(), it.optString("l"), it.firstRun()) },
o.optInt("collections"), o.optBoolean("catalogueComplete"), o.optInt("franchises"), o.optLong("computedMs"), o.optLong("tookMs"), o.optInt("decided"))
}
}
}
/** A decision you made on the Chair: placed on [channel], or dropped (channel null). Recorded for the Chair to learn from. */
internal class ChairDecision(val id: String, val title: String, val type: String, val placed: Boolean, val channel: Int?, val suggested: Int?, val atMs: Long) {
internal class ChairDecision(val id: String, val title: String, val type: String, val placed: Boolean, val channel: Int?, val suggested: Int?, val atMs: Long,
/** Placed with a premiere run first: [channel] is its home, after the run. */
val premiere: Boolean = false) {
fun toJson(): JSONObject = JSONObject().put("i", id).put("t", title).put("k", type).put("p", placed).put("c", channel ?: JSONObject.NULL)
.put("s", suggested ?: JSONObject.NULL).put("a", atMs)
.put("s", suggested ?: JSONObject.NULL).put("a", atMs).put("r", premiere)
companion object {
fun of(o: JSONObject) = ChairDecision(o.getString("i"), o.optString("t"), o.optString("k"), o.optBoolean("p"),
if (o.isNull("c")) null else o.optInt("c"), if (o.isNull("s")) null else o.optInt("s"), o.optLong("a"))
if (o.isNull("c")) null else o.optInt("c"), if (o.isNull("s")) null else o.optInt("s"), o.optLong("a"), o.optBoolean("r"))
}
}
@@ -98,6 +104,15 @@ private val DecisionLock = Any()
private fun reasonLine(why: List<String>): String =
why.filter { it.startsWith("themes:") || it.startsWith("genres ") || it.startsWith("British") || it.startsWith("in the ") }.take(2).joinToString("; ")
.ifEmpty { why.firstOrNull { it.startsWith("description like") } ?: "fits the channel's brief" }
/** Premiere is a broadcast stage, not a home (Paul, 5 November 2026): a title's homes are its other channels, best first. */
internal fun homeOf(p: Placement): Placement {
val homes = p.ranked.filter { it.first.number != PREMIERE_NUMBER }
if (homes.size == p.ranked.size && p.top.number != PREMIERE_NUMBER && p.second.number != PREMIERE_NUMBER) return p
val nowhere = Briefs.first { it.number != PREMIERE_NUMBER }
return Placement(p.evidence, homes.getOrNull(0)?.first ?: nowhere, homes.getOrNull(0)?.second ?: 0.0, homes.getOrNull(1)?.first ?: nowhere,
homes.getOrNull(1)?.second ?: 0.0, homes.getOrNull(0)?.third ?: listOf("no home channel"), homes)
}
/** Where a placed title can go: the scored channels, best first, three at most, never one the scoring ruled out. */
private fun optionsOf(p: Placement, max: Int = 3): List<DeskOption> = p.ranked.take(max).map { (b, _, why) -> DeskOption(b.number, b.name, reasonLine(why)) }
@@ -135,14 +150,16 @@ internal fun franchiseLinks(file: File, pools: Map<Int, List<String>>): Triple<F
*/
internal fun buildDeskReport(evidenceFile: File, collectionsFile: File, pools: Map<Int, List<String>>, released: List<Arrival>, carriedWorks: Set<String>,
originals: Map<String, String>, newToLibrary: Int, catalogueComplete: Boolean, mode: DirectorMode, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(),
decided: Set<String> = emptySet()): DeskReport =
buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided).getValue(mode)
decided: Set<String> = emptySet(), aired: Set<String> = emptySet()): DeskReport =
buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided, aired).getValue(mode)
/** All three modes' reports from one scoring pass: the modes only differ in how many discoveries they show and how far they reach. */
internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools: Map<Int, List<String>>, released: List<Arrival>, carriedWorks: Set<String>,
originals: Map<String, String>, newToLibrary: Int, catalogueComplete: Boolean, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(),
/** Titles you've placed or dropped on the Chair: never shown again, so the next ones take their place. */
decided: Set<String> = emptySet()): Map<DirectorMode, DeskReport> {
decided: Set<String> = emptySet(),
/** Every library item and work ever booked on Orbit: a film among them has had its first airing, so it has no premiere. */
aired: Set<String> = emptySet()): Map<DirectorMode, DeskReport> {
val started = System.nanoTime()
val evidence = if (evidenceFile.exists()) evidenceFile.readLines().mapNotNull { runCatching { JSONObject(it) }.getOrNull() }
.associate { it.optString("id") to TitleEvidence.of(it, originals[it.optString("id")]) } else emptyMap()
@@ -151,7 +168,15 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools:
val carriedIds = pools.values.flatten().toSet()
val candidates = released.filter { it.id !in carriedIds && it.work !in carriedWorks }.distinctBy { it.work }
val read = candidates.mapNotNull { evidence[it.id] }
val placed = read.map { placeTitle(it, text, links) }
val scored = read.map { placeTitle(it, text, links) }
val placed = scored.map(::homeOf)
val workOf = candidates.associate { it.id to it.work }
// Paul's policy: a premiere is a first airing on Orbit, never just a new film. Offered for a film that suits an event debut.
val firstRuns = scored.mapNotNull { p ->
val fit = p.ranked.firstOrNull { it.first.number == PREMIERE_NUMBER }?.takeIf { it.second >= .35 } ?: return@mapNotNull null
if (p.evidence.type != "Movie" || p.evidence.id in aired || workOf[p.evidence.id] in aired) return@mapNotNull null
p.evidence.id to DeskOption(PREMIERE_NUMBER, fit.first.name, "First run on Orbit: " + reasonLine(fit.third))
}.toMap()
val strong = placed.filter { it.tier == FitTier.Strong }
val byChannel = strong.groupingBy { it.top.name }.eachCount().entries.sortedByDescending { it.value }.map { it.key to it.value }
val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate()
@@ -159,7 +184,8 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools:
fun recent(id: String) = premiered[id]?.takeIf { it.length == 10 }?.let { runCatching { LocalDate.parse(it) }.getOrNull() }?.let { !it.isBefore(today.minusDays(14)) } == true
val undecided = placed.filter { it.evidence.id !in decided }
val asks = undecided.filter { it.tier == FitTier.NeedsEye }.sortedByDescending { it.s1 }.take(3).map {
DeskAsk(it.evidence.name, it.top.name, it.second.name, it.evidence.id, it.evidence.type, it.evidence.year, optionsOf(it, 2), it.evidence.overview?.let { o -> sentence(o) } ?: "No description in the library yet") }
DeskAsk(it.evidence.name, it.top.name, it.second.name, it.evidence.id, it.evidence.type, it.evidence.year, optionsOf(it, 2), it.evidence.overview?.let { o -> sentence(o) } ?: "No description in the library yet",
firstRuns[it.evidence.id]?.takeIf { _ -> it.ranked.isNotEmpty() }) }
val tookMs = (System.nanoTime() - started) / 1_000_000
return DirectorMode.values().associateWith { mode ->
// Discoveries: the strongest strong matches, one per channel; Surprise Me reaches into the possibles for channels the strong list missed.
@@ -167,7 +193,7 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools:
if (mode == DirectorMode.Surprise) pool += undecided.filter { it.tier == FitTier.Possible && pool.none { p -> p.top.number == it.top.number } }.sortedByDescending { it.s1 }.take(2)
val picks = pool.take(mode.picks).map { p ->
DeskPick(p.evidence.name, p.top.name, when { p.tier == FitTier.Possible -> "SOMETHING DIFFERENT"; recent(p.evidence.id) -> "JUST RELEASED"; else -> "STRONG MATCH" },
p.evidence.overview?.let { sentence(it) } ?: "No description in the library yet", p.evidence.id, p.evidence.type, p.evidence.year, optionsOf(p))
p.evidence.overview?.let { sentence(it) } ?: "No description in the library yet", p.evidence.id, p.evidence.type, p.evidence.year, optionsOf(p), firstRuns[p.evidence.id])
}
DeskReport(candidates.size, newToLibrary, read.size, candidates.size - read.size, byChannel, strong.size, picks, asks, collections, catalogueComplete, franchises, nowMs, tookMs, placed.count { it.evidence.id in decided })
}
@@ -194,7 +220,7 @@ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, us
val (arrivals, _) = store.loadArrivals(userId)
val newToLibrary = arrivals.count { nowMs - it.firstSeenMs < NEW_TO_LIBRARY_DAYS * 24 * 60 * 60_000L }
val reports = buildDeskReports(evidence, collections, ids, released, carriedWorks, catalogue.originalTitles(released.map { it.id }), newToLibrary, catalogue.complete(), nowMs,
decided = loadDecisions(decisions).map { it.id }.toSet())
decided = loadDecisions(decisions).map { it.id }.toSet(), aired = store.loadBooked(userId).let { booked -> booked + catalogue.worksOf(booked).values })
runCatching { cache.writeText(JSONObject().put("key", key).put("savedMs", nowMs).put("reports", JSONObject().apply { reports.forEach { (m, r) -> put(m.name, r.toJson()) } }).toString()) }
val b = reports.getValue(DirectorMode.Balanced)
android.util.Log.i("OrbitSchedule", "director newlyReleased=${b.newlyReleased} read=${b.scanned} strong=${b.strong} asks=${b.asks.size} collections=${b.collections} tookMs=${b.tookMs}")
@@ -221,7 +247,8 @@ internal fun dropFromChair(store: ScheduleStore, id: String, title: String, type
saveDecision(store.decisionsFile(), ChairDecision(id, title.take(160), type, false, null, suggested, nowMs))
/** What the Chair's pop-up acts on: one pick or one ask. */
internal class ChairChoice(val title: String, val id: String, val type: String, val year: Int?, val tag: String?, val line: String, val options: List<DeskOption>, val ask: Boolean)
internal class ChairChoice(val title: String, val id: String, val type: String, val year: Int?, val tag: String?, val line: String, val options: List<DeskOption>, val ask: Boolean,
val firstRun: DeskOption? = null)
private val ChairNavy = Color(0xFF07163A)
private val ChairPanel = Color(0xFF10285A)
@@ -235,7 +262,9 @@ private val ChairSky = Color(0xFF78BEFF)
*/
@Composable
internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequester, modifier: Modifier,
place: (ChairChoice, DeskOption) -> Unit = { _, _ -> }, drop: (ChairChoice) -> Unit = {}) {
place: (ChairChoice, DeskOption) -> Unit = { _, _ -> }, drop: (ChairChoice) -> Unit = {},
/** Premiere first, then [DeskOption] as its home; returns what to tell you (the debut, or why there's none). */
premiere: suspend (ChairChoice, DeskOption) -> String = { _, _ -> "" }) {
var mode by remember { mutableStateOf(DirectorMode.Balanced) }
var version by remember { mutableIntStateOf(0) }
var report by remember { mutableStateOf<DeskReport?>(null) }
@@ -243,6 +272,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
var decided by remember { mutableStateOf(emptySet<String>()) }
var open by remember { mutableStateOf<ChairChoice?>(null) }
var said by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(mode, version) { withContext(Dispatchers.Default) { runCatching { load(mode) }.getOrNull() }?.let { report = it } }
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -280,15 +310,15 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
Text("WORTH A LOOK", color = Color.White, fontFamily = Anton, fontSize = 14.sp)
val picks = r.picks.filter { it.id !in decided }
if (picks.isEmpty()) Text("Nothing stands out this week.", color = Muted, fontSize = 12.sp)
picks.forEach { p -> ChairCard(p.tag, p.title, p.channel.uppercase(java.util.Locale.UK), p.line,
onClick = { open = ChairChoice(p.title, p.id, p.type, p.year, p.tag, p.line, p.options, false) }.takeIf { p.options.isNotEmpty() && p.id.isNotEmpty() }) }
picks.forEach { p -> ChairCard(p.tag, p.title, p.channel.uppercase(java.util.Locale.UK) + if (p.firstRun != null) " · PREMIERE FIRST" else "", p.line,
onClick = { open = ChairChoice(p.title, p.id, p.type, p.year, p.tag, p.line, p.options, false, p.firstRun) }.takeIf { p.options.isNotEmpty() && p.id.isNotEmpty() }) }
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("NEEDS YOUR EYE", color = Color.White, fontFamily = Anton, fontSize = 14.sp)
val asks = r.asks.filter { it.id !in decided }
if (asks.isEmpty()) Text("No either-or calls this week.", color = Muted, fontSize = 12.sp)
asks.forEach { a -> ChairCard(null, a.title, null, "${a.first} or ${a.second}?",
onClick = { open = ChairChoice(a.title, a.id, a.type, a.year, null, a.line, a.options, true) }.takeIf { a.options.isNotEmpty() && a.id.isNotEmpty() }) }
onClick = { open = ChairChoice(a.title, a.id, a.type, a.year, null, a.line, a.options, true, a.firstRun) }.takeIf { a.options.isNotEmpty() && a.id.isNotEmpty() }) }
Spacer(Modifier.height(4.dp))
Text("RULES IN FORCE", color = Color.White, fontFamily = Anton, fontSize = 13.sp)
DirectorRules.forEach { Text("· $it", color = Muted, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) }
@@ -304,13 +334,16 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
}, onDrop = {
open = null; decided = decided + choice.id; drop(choice)
said = "${choice.title} dropped. The Chair won't offer it again."; version++
}, onPremiere = { home ->
open = null; decided = decided + choice.id
scope.launch { said = premiere(choice, home); version++ }
}, onClose = { open = null })
}
}
/** The pop-up for one title: its suggested channel (or, for an ask, the two it's torn between), any other fits, and Drop. */
@Composable
private fun ChairPopup(choice: ChairChoice, onPlace: (DeskOption) -> Unit, onDrop: () -> Unit, onClose: () -> Unit) {
private fun ChairPopup(choice: ChairChoice, onPlace: (DeskOption) -> Unit, onDrop: () -> Unit, onPremiere: (DeskOption) -> Unit, onClose: () -> Unit) {
var others by remember { mutableStateOf(false) }
androidx.compose.ui.window.Dialog(onDismissRequest = onClose, properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)) {
val firstButton = remember { FocusRequester() }
@@ -321,6 +354,16 @@ private fun ChairPopup(choice: ChairChoice, onPlace: (DeskOption) -> Unit, onDro
Text(choice.title.uppercase(java.util.Locale.UK), color = Color.White, fontFamily = Anton, fontSize = 28.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(listOfNotNull(choice.year?.toString(), if (choice.type == "Series") "Series" else "Film").joinToString(" · "), color = ChairSky, fontSize = 12.sp)
Text(choice.line, color = Color.White, fontSize = 13.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
// Two recommendations, not one: the first-run event on Premiere, then the film's genre home.
val home = choice.options.firstOrNull()
if (choice.firstRun != null && home != null) Row(Modifier.fillMaxWidth().background(ChairPanel, RoundedCornerShape(6.dp)).border(1.dp, ChairGold, RoundedCornerShape(6.dp)).padding(10.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Column(Modifier.weight(1f)) {
Text("FIRST-RUN EVENT: ${choice.firstRun.name.uppercase(java.util.Locale.UK)} · THEN HOME: ${home.name.uppercase(java.util.Locale.UK)}", color = ChairGold, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text("A debut night on Premiere, its window of ${PREMIERE_WINDOW_MS / (24 * 60 * 60_000L) / 7} weeks, then ${home.name}'s regular rotation.", color = Muted, fontSize = 11.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
OrbitButton("Premiere, then ${home.name.removePrefix("Orbit ")}", Modifier.focusRequester(firstButton), onClick = { onPremiere(home) })
}
// Asks show both channels from the start; a pick shows its suggestion, and the other fits on Change channel.
val shown = when { choice.ask || others -> choice.options; else -> choice.options.take(1) }
shown.forEachIndexed { i, option ->
@@ -330,7 +373,7 @@ private fun ChairPopup(choice: ChairChoice, onPlace: (DeskOption) -> Unit, onDro
Text(option.name.uppercase(java.util.Locale.UK) + if (i == 0 && !choice.ask) " · SUGGESTED" else "", color = ChairSky, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(option.reason, color = Muted, fontSize = 11.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
OrbitButton("Place on ${option.name}", if (i == 0) Modifier.focusRequester(firstButton) else Modifier, onClick = { onPlace(option) })
OrbitButton("Place on ${option.name}", if (i == 0 && choice.firstRun == null) Modifier.focusRequester(firstButton) else Modifier, onClick = { onPlace(option) })
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -0,0 +1,102 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalTime
import java.time.ZoneId
/**
* Premiere as a broadcast stage (Paul's policy, 5 November 2026): a new film placed from the Director's Chair can make its debut
* on Orbit Premiere and then move to its genre home. Premiere is never a film's home. Its first showing is a fixed slot, branded
* as a first airing on Orbit; for [PREMIERE_WINDOW_MS] after it, Premiere's own rotation may repeat it under its usual film rest;
* then the film leaves Premiere's list and joins its home channel's, for that channel's future, uncommitted schedule. Nothing
* already booked ever changes. Only a film never booked on Orbit can have a premiere.
*/
internal const val PREMIERE_NUMBER = 301
internal const val PREMIERE_WINDOW_MS = 28 * 24 * 60 * 60_000L
internal const val PREMIERE_WHY = "premiere: first airing on Orbit"
/** Debuts are on Friday and Saturday evenings at this time. */
private val PREMIERE_DEBUT_AT: LocalTime = LocalTime.of(20, 0)
/** A debut is kept this far from any other fixed slot on Premiere (an Advent door, another debut). */
private const val DEBUT_GAP_MS = 4 * 60 * 60_000L
/** One film's premiere run: its debut, when its window ends, the home it moves to, and whether it has moved. */
internal class PremiereRun(val id: String, val title: String, val home: Int, val debutMs: Long, val untilMs: Long, val graduated: Boolean = false) {
fun toJson(): JSONObject = JSONObject().put("i", id).put("t", title).put("h", home).put("d", debutMs).put("u", untilMs).put("g", graduated)
companion object { fun of(o: JSONObject) = PremiereRun(o.getString("i"), o.optString("t"), o.getInt("h"), o.getLong("d"), o.getLong("u"), o.optBoolean("g")) }
}
internal fun loadPremiereRuns(file: File): List<PremiereRun> =
runCatching { JSONArray(file.readText()).let { a -> (0 until a.length()).map { PremiereRun.of(a.getJSONObject(it)) } } }.getOrDefault(emptyList())
private val PremiereLock = Any()
private fun savePremiereRuns(file: File, runs: List<PremiereRun>) {
val temp = File(file.parentFile, file.name + ".tmp")
temp.writeText(JSONArray(runs.map { it.toJson() }).toString()); if (!temp.renameTo(file)) { file.writeText(temp.readText()); temp.delete() }
}
/**
* The first Friday or Saturday at 20:00 after Premiere's committed plan ([planEndMs]), so the debut only ever appends; kept clear
* of [otherSlots] (Advent doors, other debuts). Null if none within three weeks.
*/
internal fun debutSlot(planEndMs: Long, nowMs: Long, otherSlots: List<Long>, zone: ZoneId = ZoneId.systemDefault()): Long? {
var day = Instant.ofEpochMilli(maxOf(planEndMs, nowMs)).atZone(zone).toLocalDate()
repeat(21) {
if (day.dayOfWeek == DayOfWeek.FRIDAY || day.dayOfWeek == DayOfWeek.SATURDAY) {
val at = day.atTime(PREMIERE_DEBUT_AT).atZone(zone).toInstant().toEpochMilli()
if (at > planEndMs && at > nowMs && otherSlots.none { kotlin.math.abs(it - at) < DEBUT_GAP_MS }) return at
}
day = day.plusDays(1)
}
return null
}
/**
* Places a film on Premiere for its first run, moving to [home] after its window: Premiere's list takes it now (as an accepted
* choice) and its debut is set as a fixed slot after Premiere's committed plan. Returns the debut, or null if no slot was free
* (then nothing is changed).
*/
internal fun placePremiereFromChair(store: ScheduleStore, userId: String, id: String, title: String, year: Int?, home: Int, nowMs: Long = System.currentTimeMillis(),
zone: ZoneId = ZoneId.systemDefault()): Long? = synchronized(PremiereLock) {
require(EmbyClient.validId(id) && home != PREMIERE_NUMBER)
val runs = loadPremiereRuns(store.premiereRunsFile())
runs.firstOrNull { it.id == id }?.let { return@synchronized it.debutMs }
// Booked on Orbit already (now or since the report was worked out): its first airing has been, so it has no premiere.
if (id in store.loadBooked(userId)) return@synchronized null
val doors = AdventDoors.filter { it.channel == PREMIERE_NUMBER }.map { it.at.atZone(zone).toInstant().toEpochMilli() }
val debut = debutSlot(store.loadPlan(userId, PREMIERE_NUMBER)?.endMs ?: nowMs, nowMs, doors + runs.map { it.debutMs }, zone) ?: return@synchronized null
val choices = store.loadChoices(userId, PREMIERE_NUMBER)
if (choices.accepted.none { it.id == id })
store.saveChoices(userId, PREMIERE_NUMBER, ChannelChoices(choices.accepted + Suggestion(id, title.take(160), year, "Movie", "", "Premiere run, placed by you from the Director's Chair"), choices.rejected - id))
savePremiereRuns(store.premiereRunsFile(), runs + PremiereRun(id, title.take(160), home, debut, debut + PREMIERE_WINDOW_MS))
saveDecision(store.decisionsFile(), ChairDecision(id, title.take(160), "Movie", true, home, null, nowMs, premiere = true))
debut
}
/** Premiere's debuts still to come, as fixed slots, for films its pool has read (a film with no playable copy has none). */
internal fun premiereSlots(runs: List<PremiereRun>, films: Map<String, PoolSeries>): List<FixedSlot> =
runs.filter { !it.graduated }.mapNotNull { run -> films[run.id]?.let { FixedSlot(run.debutMs, it, "$PREMIERE_WHY; debut chosen on the Director's Chair", "") } }
/**
* Moves every film whose premiere window has ended to its home channel's list, and off Premiere's. Their bookings on Premiere,
* committed already, stay as they are. Returns the channels whose lists changed.
*/
internal fun graduatePremieres(store: ScheduleStore, userId: String, nowMs: Long = System.currentTimeMillis()): Set<Int> = synchronized(PremiereLock) {
val runs = loadPremiereRuns(store.premiereRunsFile())
val due = runs.filter { !it.graduated && it.untilMs <= nowMs }
if (due.isEmpty()) return@synchronized emptySet()
val premiere = store.loadChoices(userId, PREMIERE_NUMBER)
val leaving = premiere.accepted.filter { a -> due.any { it.id == a.id } }
store.saveChoices(userId, PREMIERE_NUMBER, ChannelChoices(premiere.accepted - leaving.toSet(), premiere.rejected))
due.forEach { run ->
val film = leaving.firstOrNull { it.id == run.id } ?: return@forEach
val home = store.loadChoices(userId, run.home)
if (home.accepted.none { it.id == run.id })
store.saveChoices(userId, run.home, ChannelChoices(home.accepted + film.copy(reason = "After its premiere run, placed by you from the Director's Chair"), home.rejected - run.id))
}
savePremiereRuns(store.premiereRunsFile(), runs.map { r -> if (due.any { it.id == r.id }) PremiereRun(r.id, r.title, r.home, r.debutMs, r.untilMs, true) else r })
setOf(PREMIERE_NUMBER) + due.map { it.home }
}
@@ -515,7 +515,15 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
android.util.Log.i("OrbitSchedule", "director placed channel=${option.number} suggested=${option.number == choice.options.firstOrNull()?.number}")
}.onFailure { android.util.Log.w("OrbitSchedule", "director placeFailed=${it.javaClass.simpleName}") } } } },
drop = { choice -> scope.launch(Dispatchers.IO) { runCatching { dropFromChair(scheduleStore, choice.id, choice.title, choice.type, choice.options.firstOrNull()?.number) }
.onFailure { android.util.Log.w("OrbitSchedule", "director dropFailed=${it.javaClass.simpleName}") }; android.util.Log.i("OrbitSchedule", "director dropped") } }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
.onFailure { android.util.Log.w("OrbitSchedule", "director dropFailed=${it.javaClass.simpleName}") }; android.util.Log.i("OrbitSchedule", "director dropped") } },
premiere = { choice, home -> val active = session
if (active == null) "Not connected, so nothing was placed." else withContext(Dispatchers.IO) { runCatching {
placePremiereFromChair(scheduleStore, active.accountId, choice.id, choice.title, choice.year, home.number)
}.getOrNull() }.let { debut ->
android.util.Log.i("OrbitSchedule", "director premiere home=${home.number} debut=${debut != null}")
if (debut == null) "${choice.title} wasn't placed: it has been booked on Orbit already, or there was no free Friday or Saturday night in the next three weeks."
else "${choice.title} premieres on Orbit Premiere ${java.time.Instant.ofEpochMilli(debut).atZone(java.time.ZoneId.systemDefault()).let { "%s %d %s at %02d:%02d".format(it.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.dayOfMonth, it.month.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.hour, it.minute) }}, then joins ${home.name}."
} }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on ->
val tots = scheduledChannels[TOTS_NUMBER]
val active = session
@@ -56,6 +56,8 @@ internal class ScheduleStore(private val directory: File) {
fun directorFile() = File(directory, "director-0.json")
/** Your decisions on the Director's Chair ([ChairDecision]): what you placed where, and what you dropped. */
fun decisionsFile() = File(directory, "director-decisions-0.json")
/** Films on their premiere run ([PremiereRun]): debut, window and home. */
fun premiereRunsFile() = File(directory, "premiere-runs-0.json")
/** When the channels' pools last changed, without reading them: their files' times together. */
fun poolsStamp(): Long = directory.listFiles { f -> f.name.startsWith("episodes-") && f.name.endsWith(".json") }?.sumOf { it.lastModified() } ?: 0L
@@ -0,0 +1,91 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.io.File
import java.time.DayOfWeek
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
/** Premiere as a broadcast stage: two recommendations, a debut that only appends, a window, then the film's genre home. */
class DirectorPremiereTest {
private val london = ZoneId.of("Europe/London")
private val minute = 60_000L
private val day = 24 * 60 * minute
private fun at(month: Int, d: Int, h: Int, m: Int = 0) = ZonedDateTime.of(2026, month, d, h, m, 0, 0, london).toInstant().toEpochMilli()
private val user = "a1b2c3"
private fun store() = ScheduleStore(kotlin.io.path.createTempDirectory("premiere").toFile().apply { deleteOnExit() })
private val fighter = JSONObject().put("id", "8100001").put("type", "Movie").put("name", "Street Brawlers").put("year", 2026)
.put("overview", "An epic battle: a hero on a mission joins the world fighting tournament, where an agent seeks revenge in every fight.")
.put("genres", JSONArray(listOf("Action", "Adventure", "Science Fiction"))).put("studios", JSONObject.NULL).put("rating", "PG-13")
.put("countries", JSONArray(listOf("United States"))).put("taglines", JSONObject.NULL)
private fun report(aired: Set<String>): DeskReport {
val evidence = File.createTempFile("evidence", ".jsonl").apply { deleteOnExit(); writeText("$fighter\n") }
val sets = File.createTempFile("collections", ".jsonl").apply { delete(); deleteOnExit() }
return buildDeskReport(evidence, sets, emptyMap(), listOf(Arrival("8100001", "Movie", "Street Brawlers", 2026, "2026-10-30", "tmdb:81", 0)), emptySet(), emptyMap(), 1, true,
DirectorMode.Surprise, at(11, 5, 12), london, aired = aired)
}
@Test fun premiereIsAStageNotAHomeAndOnlyForAFirstAiring() {
val r = report(emptySet())
val pick = (r.picks.map { it.options to it.firstRun } + r.asks.map { it.options to it.firstRun }).single()
assertTrue(pick.first.none { it.number == PREMIERE_NUMBER }) // never offered as a home
assertEquals(302, pick.first.first().number) // its genre home: Orbit Action
assertEquals(PREMIERE_NUMBER, pick.second!!.number) // and its first-run event
// Booked on Orbit before, by this item or another copy of the same work: new, but no premiere.
listOf(setOf("8100001"), setOf("tmdb:81")).forEach { aired ->
val again = report(aired)
assertTrue((again.picks.map { it.firstRun } + again.asks.map { it.firstRun }).all { it == null })
}
val back = DeskReport.of(r.toJson())
assertEquals(PREMIERE_NUMBER, (back.picks.map { it.firstRun } + back.asks.map { it.firstRun }).single()!!.number)
}
@Test fun theDebutIsAFridayOrSaturdayNightAfterTheCommittedPlan() {
val debut = debutSlot(at(11, 12, 6), at(11, 5, 12), emptyList(), london)!!
val t = Instant.ofEpochMilli(debut).atZone(london)
assertEquals(DayOfWeek.FRIDAY, t.dayOfWeek); assertEquals(13, t.dayOfMonth); assertEquals(20, t.hour)
// Kept clear of another fixed slot that night: Saturday instead.
assertEquals(at(11, 14, 20), debutSlot(at(11, 12, 6), at(11, 5, 12), listOf(at(11, 13, 18)), london))
// Never inside the committed plan, even on a Friday.
assertEquals(at(11, 14, 20), debutSlot(at(11, 13, 21), at(11, 5, 12), emptyList(), london))
}
@Test fun placingSetsADebutAndTheWindowMovesItHome() {
val store = store()
val debut = placePremiereFromChair(store, user, "8100001", "Street Brawlers", 2026, 302, at(11, 5, 12), london)!!
assertEquals(listOf("8100001"), store.loadChoices(user, PREMIERE_NUMBER).accepted.map { it.id })
assertTrue(store.loadChoices(user, 302).accepted.isEmpty())
assertEquals(debut, placePremiereFromChair(store, user, "8100001", "Street Brawlers", 2026, 302, at(11, 5, 13), london)) // once only
assertTrue(loadDecisions(store.decisionsFile()).single().premiere)
// The debut is a fixed slot, booked only after the committed plan, branded as a first airing on Orbit.
val film = PoolSeries("8100001", "Street Brawlers", listOf(PoolEpisode("8100001", "s1", EpisodeMark(1, 1), "Street Brawlers", 110 * minute)))
val pool = (1..40).map { PoolSeries("f$it", "Film $it", listOf(PoolEpisode("f$it", "sf$it", EpisodeMark(1, 1), "Film $it", (90L + it % 30) * minute))) } + film
val slots = premiereSlots(loadPremiereRuns(store.premiereRunsFile()), pool.associateBy { it.id })
val plan = extendFilmSchedule(pool, ScheduleCursor(), at(11, 5, 6), debut + 2 * day, 7 * day, zone = london, fixed = slots)
val first = plan.entries.first { it.seriesId == "8100001" }
assertTrue(first.startMs in debut..debut + 20 * minute)
assertTrue(PREMIERE_WHY in first.why)
assertEquals("FIRST ON ORBIT", whatsOnPage(mapOf(PREMIERE_NUMBER to plan), null, debut - 6 * 60 * minute, london).let { p -> (listOfNotNull(p.pick) + p.tonight + p.comingUp).first { it.title.contains("Street Brawlers") }.tag })
// Before its window ends nothing moves; after, it leaves Premiere's list for Action's. Its bookings aren't touched here.
assertTrue(graduatePremieres(store, user, debut + PREMIERE_WINDOW_MS - 1).isEmpty())
assertEquals(setOf(PREMIERE_NUMBER, 302), graduatePremieres(store, user, debut + PREMIERE_WINDOW_MS))
assertTrue(store.loadChoices(user, PREMIERE_NUMBER).accepted.isEmpty())
assertEquals(listOf("8100001"), store.loadChoices(user, 302).accepted.map { it.id })
assertTrue(premiereSlots(loadPremiereRuns(store.premiereRunsFile()), pool.associateBy { it.id }).isEmpty())
assertTrue(graduatePremieres(store, user, debut + PREMIERE_WINDOW_MS + day).isEmpty()) // once only
}
@Test fun aFilmAlreadyBookedOnOrbitGetsNoPremiere() {
val store = store()
store.saveBooked(user, setOf("8100001"))
assertNull(placePremiereFromChair(store, user, "8100001", "Street Brawlers", 2026, 302, at(11, 5, 12), london))
assertTrue(store.loadChoices(user, PREMIERE_NUMBER).accepted.isEmpty())
assertThrows(IllegalArgumentException::class.java) { placePremiereFromChair(store, user, "8100002", "Another", 2026, PREMIERE_NUMBER, at(11, 5, 12), london) }
}
}