From 4bea3e7db52d345ecf1960ff0cc9de260c2d4bbe Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 11:30:30 +0000 Subject: [PATCH] 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 ' 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) --- .../java/tv/orbit/classic/ChannelScheduler.kt | 7 ++ .../java/tv/orbit/classic/DirectorChair.kt | 93 +++++++++++----- .../java/tv/orbit/classic/DirectorPremiere.kt | 102 ++++++++++++++++++ .../tv/orbit/classic/EmbyConnectionScreen.kt | 10 +- .../java/tv/orbit/classic/ScheduleStore.kt | 2 + .../tv/orbit/classic/DirectorPremiereTest.kt | 91 ++++++++++++++++ 6 files changed, 279 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt 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 e2bddb3..10bfe64 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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.") diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt index 81c197d..6f79b77 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt @@ -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 = emptyList()) + val options: List = 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 = emptyList(), val line: String = "") + val options: List = emptyList(), val line: String = "", val firstRun: DeskOption? = null) -private fun JSONObject.item(id: String, type: String, year: Int?, options: List) = - 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, 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 = 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 = 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>): Triple>, released: List, carriedWorks: Set, originals: Map, newToLibrary: Int, catalogueComplete: Boolean, mode: DirectorMode, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(), - decided: Set = emptySet()): DeskReport = - buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided).getValue(mode) + decided: Set = emptySet(), aired: Set = 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>, released: List, carriedWorks: Set, originals: Map, 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 = emptySet()): Map { + decided: Set = 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 = emptySet()): Map { 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, 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, 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(null) } @@ -243,6 +272,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ var decided by remember { mutableStateOf(emptySet()) } var open by remember { mutableStateOf(null) } var said by remember { mutableStateOf(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)) { diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt new file mode 100644 index 0000000..df5176f --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt @@ -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 = + 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) { + 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, 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, films: Map): List = + 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 = 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 } +} 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 0f624de..5e873b4 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 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 ad3a45c..98b8643 100644 --- a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt @@ -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 diff --git a/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt b/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt new file mode 100644 index 0000000..4a62c62 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt @@ -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): 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) } + } +}