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) } + } +}