Orbit Premiere: weighed release profile, three proposals a week, booking off
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m57s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m45s

Paul's refined policy, 5 November 2026. Premiere is a curated premium film
channel. Recency, a major studio, an event-film description, trending lists
and viewer ratings are weighed into one priority; none is required. Format
(TV movie, documentary, under 75 minutes) and Orbit status (aired or booked:
no duplicate first run) are the only hard limits. Streaming originals are
weighed down by evidence (a streamer studio, a streaming list within weeks
of release), not excluded. Unread films are queued for the evidence reader,
which now reads the last 12 months' films (read only). At most three
proposals a week, apart from the picks. After the configurable window a
film joins its genre home, stays on Premiere if premium with no home, or is
held for review; never a forced home. ORBIT PREMIERE is its own What's On
label, apart from FIRST ON ORBIT. Booking from the Chair stays off
(PREMIERE_BOOKING_ACTIVE) until Paul approves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:46:52 +00:00
co-authored by Claude Opus 5.5
parent c5a77f4397
commit 3e4919fe45
10 changed files with 346 additions and 60 deletions
@@ -65,6 +65,17 @@ internal class CatalogueStore private constructor(context: Context) : CatalogueS
Arrival(row.getString(0), row.getString(1), row.getString(2), year, row.getString(4).take(10), workKey(ids, row.getString(2), year), 0)
}.toList()
}
/** Orbit Premiere's raw material: every film released between the dates, with what the catalogue knows of its release profile. */
@Synchronized fun premiereFacts(fromDate: String, toDate: String, limit: Int = 6_000): List<PremiereFacts> =
readableDatabase.rawQuery("SELECT id, title, year, genres, studios, premiere, runtime_ms, provider_ids FROM items WHERE type = 'Movie' AND substr(premiere, 1, 10) >= ? AND substr(premiere, 1, 10) <= ? ORDER BY premiere DESC LIMIT $limit",
arrayOf(fromDate, toDate)).use { c ->
generateSequence { if (c.moveToNext()) c else null }.map { row ->
val ids = runCatching { JSONObject(row.getString(7)).let { o -> o.keys().asSequence().associateWith { k -> o.optString(k) } } }.getOrDefault(emptyMap())
val year = if (row.isNull(2)) null else row.getInt(2)
PremiereFacts(row.getString(0), row.getString(1), year, row.getString(3).split('|').filter { it.isNotEmpty() }.toSet(), row.getString(4).split('|').filter { it.isNotEmpty() },
row.getString(5).take(10), if (row.isNull(6)) null else row.getLong(6), workKey(ids, row.getString(1), year))
}.toList()
}
/** Stage C: the work each of these library ids is, for telling which works a channel already holds. */
@Synchronized fun worksOf(ids: Collection<String>): Map<String, String> = ids.chunked(400).flatMap { chunk ->
readableDatabase.rawQuery("SELECT id, title, year, provider_ids FROM items WHERE id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c ->
@@ -78,7 +78,9 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
if (!EvidenceRequest.take()) return
val today = java.time.LocalDate.now()
val ids = SmartChannels.flatMap { store.loadPool(userId, it.number)?.series.orEmpty().map { s -> s.id } } +
catalogue.releasedBetween(today.minusDays(NEWLY_RELEASED_DAYS).toString(), today.toString(), 2_000).map { it.id }
catalogue.releasedBetween(today.minusDays(NEWLY_RELEASED_DAYS).toString(), today.toString(), 2_000).map { it.id } +
// Orbit Premiere (Paul, 5 November 2026): the last 12 months' films, so their ratings and descriptions are there to weigh.
catalogue.premiereFacts(today.minusDays(365).toString(), today.toString(), 3_000).map { it.id }
val file = store.evidenceFile()
val missing = evidenceMissing(file, ids).take(EVIDENCE_MAX_PER_RUN)
var read = 0; var failed = 0
@@ -50,6 +50,20 @@ internal class DeskPick(val title: String, val channel: String, val tag: String,
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 firstRun: DeskOption? = null)
/** One of this week's Orbit Premiere proposals: why, the doubts, its home and what happens after its window. Read only. */
internal class DeskPremiere(val id: String, val title: String, val year: Int?, val priority: Double, val home: DeskOption?, val after: String, val remainsOnPremiere: Boolean,
val reasons: List<String>, val cautions: List<String>) {
fun toJson(): JSONObject = JSONObject().put("i", id).put("t", title).put("y", year ?: JSONObject.NULL).put("p", priority).put("h", home?.toJson() ?: JSONObject.NULL)
.put("a", after).put("s", remainsOnPremiere).put("r", JSONArray(reasons)).put("c", JSONArray(cautions))
companion object {
fun of(o: JSONObject): DeskPremiere {
fun strings(key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()
return DeskPremiere(o.getString("i"), o.optString("t"), o.year(), o.optDouble("p"), o.optJSONObject("h")?.let(DeskOption::of), o.optString("a"), o.optBoolean("s"),
strings("r"), strings("c"))
}
}
}
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)
@@ -59,13 +73,17 @@ private fun JSONObject.year() = if (isNull("y")) null else optInt("y").takeIf {
internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val scanned: Int, val unread: Int, val strongByChannel: List<Pair<String, Int>>, val strong: Int,
val picks: List<DeskPick>, val asks: List<DeskAsk>, val collections: Int, val catalogueComplete: Boolean, val franchises: Int, val computedMs: Long, val tookMs: Long,
/** Titles you've already placed or dropped, kept out of the picks and asks. */
val decided: Int = 0) {
val decided: Int = 0,
/** This week's Orbit Premiere proposals, at most three. */
val premieres: List<DeskPremiere> = emptyList(),
/** Recent major films whose evidence is still to be read before they can be judged. */
val premiereQueued: 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, 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)
.put("decided", decided).put("premieres", JSONArray(premieres.map { it.toJson() })).put("premiereQueued", premiereQueued)
companion object {
fun of(o: JSONObject): DeskReport {
fun list(key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.orEmpty()
@@ -73,7 +91,8 @@ internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val sca
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(), 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"))
o.optInt("collections"), o.optBoolean("catalogueComplete"), o.optInt("franchises"), o.optLong("computedMs"), o.optLong("tookMs"), o.optInt("decided"),
list("premieres").map(DeskPremiere::of), o.optInt("premiereQueued"))
}
}
}
@@ -150,16 +169,22 @@ 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(), aired: Set<String> = emptySet()): DeskReport =
buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided, aired).getValue(mode)
decided: Set<String> = emptySet(), premiere: PremiereInputs = PremiereInputs()): DeskReport =
buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided, premiere).getValue(mode)
/** What Orbit Premiere's proposals are judged from: the catalogue's recent films, each film's Orbit status and this week's premiere decisions. */
internal class PremiereInputs(val films: List<PremiereFacts> = emptyList(), val status: (String) -> OrbitStatus = { OrbitStatus.Never }, val decidedThisWeek: Int = 0)
/** The members of the library's collections named [names]. */
internal fun listMembers(file: File, names: Set<String>): Set<String> = if (!file.exists()) emptySet() else HashSet<String>().also { out ->
file.forEachLine { line -> runCatching { JSONObject(line) }.getOrNull()?.takeIf { it.optString("name") in names }?.optJSONArray("members")?.let { a -> (0 until a.length()).forEach { out += a.getString(it) } } }
}
/** 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(),
/** 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> {
decided: Set<String> = emptySet(), premiere: PremiereInputs = PremiereInputs()): 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()
@@ -170,22 +195,30 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools:
val read = candidates.mapNotNull { evidence[it.id] }
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()
// Orbit Premiere (Paul's corrected policy): the release profile weighed, at most three proposals a week, apart from the picks.
val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate()
val trending = listMembers(collectionsFile, TrendingLists); val streaming = listMembers(collectionsFile, StreamingLists)
// Two passes, to stay quick on the box: every film scored on Premiere's brief alone, then only the strongest placed in full for a home.
val premiereBrief = Briefs.first { it.number == PREMIERE_NUMBER }
fun judge(f: PremiereFacts, withHome: Boolean): PremiereCandidate {
val e = evidence[f.id]
val home = if (withHome && e != null) homeOf(placeTitle(e, text, links)).let { it.top to it.s1 } else null
return premiereCandidate(f, e, e?.community, e?.let { channelScore(it, premiereBrief, text, links).first }, home, f.id in trending, f.id in streaming, premiere.status(f.id), today)
}
val premiereCandidates = premiere.films.map { judge(it, false) }
val strongest = premiereCandidates.filter { it.worthwhile && it.facts.id !in decided }.sortedByDescending { it.priority }.take(PREMIERE_PROPOSALS_PER_WEEK * 3).map { judge(it.facts, true) }
val premieres = weeklyPremiereProposals(strongest, decided, premiere.decidedThisWeek).map { c ->
DeskPremiere(c.facts.id, c.facts.title, c.facts.year, c.priority, c.home?.let { h -> DeskOption(h.number, h.name, "its genre home") }, c.afterWindow.label, c.remainsOnPremiere, c.reasons, c.cautions)
}
val premiereQueued = premiereCandidates.count { !it.evidenceRead && it.excluded == null && it.proposable && it.status == OrbitStatus.Never && it.priority >= .5 }
val proposed = premieres.map { it.id }.toSet()
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()
val premiered = released.associate { it.id to it.premiere }
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 undecided = placed.filter { it.evidence.id !in decided && it.evidence.id !in proposed }
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",
firstRuns[it.evidence.id]?.takeIf { _ -> it.ranked.isNotEmpty() }) }
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") }
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.
@@ -193,9 +226,9 @@ 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), firstRuns[p.evidence.id])
p.evidence.overview?.let { sentence(it) } ?: "No description in the library yet", p.evidence.id, p.evidence.type, p.evidence.year, optionsOf(p))
}
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 })
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 }, premieres, premiereQueued)
}
}
@@ -220,13 +253,23 @@ 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(), aired = store.loadBooked(userId).let { booked -> booked + catalogue.worksOf(booked).values })
decided = loadDecisions(decisions).map { it.id }.toSet(), premiere = premiereInputs(store, catalogue, userId, nowMs))
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}")
reports
}
/** Orbit Premiere's inputs from the box: films of the last [PREMIERE_LOOKBACK_DAYS], their Orbit status, and this week's premiere decisions. */
internal fun premiereInputs(store: ScheduleStore, catalogue: CatalogueStore, userId: String, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): PremiereInputs {
val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate()
val upcoming = (SmartChannels.map { it.number } + DRIVE_IN_NUMBER).flatMap { store.loadPlan(userId, it)?.entries.orEmpty().filter { e -> e.startMs > nowMs }.map { e -> e.seriesId } }.toSet()
val booked = store.loadBooked(userId)
val weekStart = today.with(java.time.DayOfWeek.MONDAY).atStartOfDay(zone).toInstant().toEpochMilli()
return PremiereInputs(catalogue.premiereFacts(today.minusDays(PREMIERE_LOOKBACK_DAYS).toString(), today.toString()), { orbitStatus(it, booked, upcoming) },
loadDecisions(store.decisionsFile()).count { it.premiere && it.atMs >= weekStart })
}
/** The first sentence of a description, kept short for a card. */
internal fun sentence(text: String): String = text.split(Regex("(?<=[.!?])\\s")).first().let { if (it.length > 90) it.take(87).trimEnd() + "…" else it }
@@ -234,17 +277,19 @@ internal fun sentence(text: String): String = text.split(Regex("(?<=[.!?])\\s"))
* Places a title from the Chair on [channel] (approved by Paul on 5 November 2026): it's added to that channel's own list, as an
* accepted choice, so it joins the channel's pool at its next top-up. Nothing is booked here and committed bookings never change.
*/
internal fun placeFromChair(store: ScheduleStore, userId: String, id: String, title: String, type: String, year: Int?, channel: Int, suggested: Int?, nowMs: Long = System.currentTimeMillis()) {
internal fun placeFromChair(store: ScheduleStore, userId: String, id: String, title: String, type: String, year: Int?, channel: Int, suggested: Int?, nowMs: Long = System.currentTimeMillis(),
/** A Premiere proposal placed straight on its home: counts towards the week's premiere decisions. */
premiere: Boolean = false) {
require(EmbyClient.validId(id) && (type == "Movie" || type == "Series"))
val choices = store.loadChoices(userId, channel)
if (choices.accepted.none { it.id == id })
store.saveChoices(userId, channel, ChannelChoices(choices.accepted + Suggestion(id, title.take(160), year, type, "", "Placed by you from the Director's Chair"), choices.rejected - id))
saveDecision(store.decisionsFile(), ChairDecision(id, title.take(160), type, true, channel, suggested, nowMs))
saveDecision(store.decisionsFile(), ChairDecision(id, title.take(160), type, true, channel, suggested, nowMs, premiere))
}
/** Drops a title from the Chair: it isn't offered again. No channel or schedule changes. */
internal fun dropFromChair(store: ScheduleStore, id: String, title: String, type: String, suggested: Int?, nowMs: Long = System.currentTimeMillis()) =
saveDecision(store.decisionsFile(), ChairDecision(id, title.take(160), type, false, null, suggested, nowMs))
internal fun dropFromChair(store: ScheduleStore, id: String, title: String, type: String, suggested: Int?, nowMs: Long = System.currentTimeMillis(), premiere: Boolean = false) =
saveDecision(store.decisionsFile(), ChairDecision(id, title.take(160), type, false, null, suggested, nowMs, premiere))
/** 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,
@@ -308,6 +353,14 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
}
Column(Modifier.weight(1.3f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("WORTH A LOOK", color = Color.White, fontFamily = Anton, fontSize = 14.sp)
// Orbit Premiere's proposals, at most three a week, apart from the channel picks.
r.premieres.filter { it.id !in decided }.forEach { p ->
ChairCard("ORBIT PREMIERE", p.title, p.home?.name?.uppercase(java.util.Locale.UK)?.let { "THEN $it" } ?: p.after.uppercase(java.util.Locale.UK),
(p.reasons.take(2) + p.cautions.take(1)).joinToString("; "),
onClick = { open = ChairChoice(p.title, p.id, "Movie", p.year, "ORBIT PREMIERE", (p.reasons + p.cautions.map { "but $it" }).joinToString("; "),
listOfNotNull(p.home), false, DeskOption(PREMIERE_NUMBER, "Orbit Premiere", p.after + if (p.remainsOnPremiere) ", and stays eligible for Premiere" else "")) })
}
if (r.premiereQueued > 0) Text("${r.premiereQueued} recent major films still being read for Premiere", color = Muted, fontSize = 10.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) + if (p.firstRun != null) " · PREMIERE FIRST" else "", p.line,
@@ -356,13 +409,14 @@ private fun ChairPopup(choice: ChairChoice, onPlace: (DeskOption) -> Unit, onDro
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),
if (choice.firstRun != 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)
Text("ORBIT PREMIERE · THEN ${home?.name?.uppercase(java.util.Locale.UK) ?: "NO GENRE HOME"}", color = ChairGold, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text("A debut on Premiere, a window of ${PREMIERE_WINDOW_MS / (7 * 24 * 60 * 60_000L)} weeks (changeable), then it ${choice.firstRun.reason}.", color = Muted, fontSize = 11.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
OrbitButton("Premiere, then ${home.name.removePrefix("Orbit ")}", Modifier.focusRequester(firstButton), onClick = { onPremiere(home) })
if (PREMIERE_BOOKING_ACTIVE && home != null) OrbitButton("Premiere, then ${home.name.removePrefix("Orbit ")}", Modifier.focusRequester(firstButton), onClick = { onPremiere(home) })
else Text("Premiere booking switches on once you approve it", color = ChairGold, fontSize = 10.sp)
}
// 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) }
@@ -373,12 +427,12 @@ 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 && choice.firstRun == null) Modifier.focusRequester(firstButton) else Modifier, onClick = { onPlace(option) })
OrbitButton("Place on ${option.name}", if (i == 0 && (choice.firstRun == null || !PREMIERE_BOOKING_ACTIVE)) Modifier.focusRequester(firstButton) else Modifier, onClick = { onPlace(option) })
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (!choice.ask && !others && choice.options.size > 1) OrbitButton("Change channel", onClick = { others = true })
OrbitButton("Drop", onClick = onDrop)
OrbitButton("Drop", if (choice.options.isEmpty()) Modifier.focusRequester(firstButton) else Modifier, onClick = onDrop)
OrbitButton("Not now", onClick = onClose)
}
Text("Placing adds it to that channel's own list; it joins at the next top-up and nothing already booked changes. Drop keeps it off the Chair.",
@@ -16,8 +16,12 @@ import java.time.ZoneId
* already booked ever changes. Only a film never booked on Orbit can have a premiere.
*/
internal const val PREMIERE_NUMBER = 301
/** Premiere booking from the Chair: off until Paul approves the final candidate selection and window behaviour (5 November 2026). */
internal const val PREMIERE_BOOKING_ACTIVE = false
/** The default premiere window; a run can carry its own. */
internal const val PREMIERE_WINDOW_MS = 28 * 24 * 60 * 60_000L
internal const val PREMIERE_WHY = "premiere: first airing on Orbit"
/** A theatrical premiere's debut: its own label in What's On, apart from a series' FIRST ON ORBIT, so no film carries both. */
internal const val PREMIERE_WHY = "orbit premiere: debut"
/** 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). */
@@ -60,7 +64,7 @@ internal fun debutSlot(planEndMs: Long, nowMs: Long, otherSlots: List<Long>, zon
* (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) {
zone: ZoneId = ZoneId.systemDefault(), windowMs: Long = PREMIERE_WINDOW_MS): 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 }
@@ -71,14 +75,14 @@ internal fun placePremiereFromChair(store: ScheduleStore, userId: String, id: St
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))
savePremiereRuns(store.premiereRunsFile(), runs + PremiereRun(id, title.take(160), home, debut, debut + windowMs))
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", "") } }
runs.filter { !it.graduated }.mapNotNull { run -> films[run.id]?.let { FixedSlot(run.debutMs, it, "$PREMIERE_WHY; approved 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,
@@ -103,14 +103,17 @@ private val Token = Regex("[a-z][a-z'-]{2,}")
/** One title's evidence, as the evidence reader keeps it, with the library's original title. Null is missing, not negative. */
internal class TitleEvidence(val id: String, val type: String, val name: String, val year: Int?, val overview: String?, val genres: List<String>?, val studios: List<String>?,
val rating: String?, val countries: List<String>?, val taglines: List<String>?, val originalTitle: String? = null) {
val rating: String?, val countries: List<String>?, val taglines: List<String>?, val originalTitle: String? = null,
/** The viewers' rating (community), supporting evidence only. */
val community: Double? = null) {
val genreSet: Set<String> by lazy { genres.orEmpty().map { GenreSynonyms[it] ?: it }.toSet() }
companion object {
fun of(line: JSONObject, originalTitle: String? = null): TitleEvidence {
fun list(key: String) = line.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }
fun text(key: String) = if (line.isNull(key)) null else line.optString(key).takeIf { it.isNotEmpty() }
return TitleEvidence(line.optString("id"), line.optString("type"), line.optString("name"), if (line.isNull("year")) null else line.optInt("year"),
text("overview"), list("genres"), list("studios"), text("rating"), list("countries"), list("taglines"), originalTitle)
text("overview"), list("genres"), list("studios"), text("rating"), list("countries"), list("taglines"), originalTitle,
if (line.isNull("community")) null else line.optDouble("community").takeIf { !it.isNaN() })
}
}
}
@@ -511,10 +511,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
arrivals = { detail, first -> NewArrivalsDetail(::loadArrivalsReport, first, detail) },
director = { detail, first -> DirectorChair(::loadDirectorReport, first, detail,
place = { choice, option -> session?.let { active -> scope.launch(Dispatchers.IO) { runCatching {
placeFromChair(scheduleStore, active.accountId, choice.id, choice.title, choice.type, choice.year, option.number, choice.options.firstOrNull()?.number)
placeFromChair(scheduleStore, active.accountId, choice.id, choice.title, choice.type, choice.year, option.number, choice.options.firstOrNull()?.number, premiere = choice.firstRun != null)
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) }
drop = { choice -> scope.launch(Dispatchers.IO) { runCatching { dropFromChair(scheduleStore, choice.id, choice.title, choice.type, choice.options.firstOrNull()?.number, premiere = choice.firstRun != null) }
.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 {
@@ -0,0 +1,109 @@
package tv.orbit.classic
import java.time.LocalDate
import java.time.temporal.ChronoUnit
/**
* Orbit Premiere's candidates (Paul's corrected policy, approved in principle on 5 November 2026). Premiere is a curated premium
* film channel: it prioritises major theatrical releases, blockbusters, high-profile films and special events Paul approves.
* Every signal here is supporting evidence weighed into one priority, never a hard rule, apart from format (a TV movie, a
* documentary or anything under 75 minutes is not a premiere) and Orbit status (a film already aired or booked on Orbit has had
* its first run, so it gets no second one). A film whose evidence hasn't been read is queued for reading, not judged. At most
* [PREMIERE_PROPOSALS_PER_WEEK] are proposed a week; nothing is booked from here. Read only.
*/
internal const val PREMIERE_PROPOSALS_PER_WEEK = 3
/** The priority a film needs to be proposed at all: below it, it's not worth a decision. */
internal const val PREMIERE_WORTHWHILE = .6
/** Films released longer ago than this aren't proposed by the Chair; an older major film can still be chosen editorially. */
internal const val PREMIERE_LOOKBACK_DAYS = 730L
/** What the library says about a film, from the catalogue: enough to judge its release profile before its evidence is read. */
internal class PremiereFacts(val id: String, val title: String, val year: Int?, val genres: Set<String>, val studios: List<String>, val premiere: String,
val runtimeMs: Long?, val work: String)
internal enum class OrbitStatus(val label: String) { Aired("aired on Orbit"), Scheduled("booked on Orbit, not yet aired"), Never("never scheduled on Orbit") }
/** After its premiere window: into its genre home, kept on Premiere (premium, no home), or held for your review. Never a forced home. */
internal enum class AfterWindow(val label: String) { GenreHome("joins its genre home"), StaysOnPremiere("stays on Premiere"), EditorialReview("held for your review") }
internal class PremiereCandidate(val facts: PremiereFacts, val priority: Double, val reasons: List<String>, val cautions: List<String>, val status: OrbitStatus,
val evidenceRead: Boolean, val home: Brief?, val afterWindow: AfterWindow,
/** A premium film keeps its place on Premiere after the window as well as joining its home. */
val remainsOnPremiere: Boolean, val excluded: String? = null,
/** Released within [PREMIERE_LOOKBACK_DAYS]: older films are yours to choose, not the Chair's to propose. */
val proposable: Boolean = true) {
val worthwhile get() = excluded == null && proposable && evidenceRead && status == OrbitStatus.Never && priority >= PREMIERE_WORTHWHILE
}
private val MajorStudio = Regex("Warner Bros|DC Studios|Universal Pictures|Paramount Pictures|Walt Disney Pictures|Marvel Studios|Lucasfilm|Pixar|20th Century|" +
"Columbia Pictures|Sony Pictures|Legendary|Lionsgate|New Line|DreamWorks|Amblin|Blumhouse|Metro-Goldwyn-Mayer|Illumination|Skydance|A24|NEON|Focus Features|" +
"Searchlight|TriStar|Screen Gems|Village Roadshow|Working Title|StudioCanal|Toho", RegexOption.IGNORE_CASE)
/** A streaming service as a film's studio: evidence it's a streaming original. Weighed, not ruled out. */
private val StreamingStudio = Regex("\\bNetflix\\b|Amazon MGM|Amazon Studios|Apple Studios|Apple Original|Hulu|Peacock|Paramount\\+|Disney\\+|Max Original", RegexOption.IGNORE_CASE)
private val TvFilmStudio = Regex("Hallmark|Lifetime|Great American", RegexOption.IGNORE_CASE)
/** The library's collections that say a film is high-profile now. */
internal val TrendingLists = setOf("Trending Movies", "Trakt Trending", "IMDB Top 250 Movies", "Letterboxd Top 500")
/** The library's streaming services' lists: a recent major film on one may have gone straight to streaming. */
internal val StreamingLists = setOf("Netflix", "Prime", "Apple TV", "Hulu", "Peacock", "Max", "Disney+", "Paramount+", "Netflix Top 10's", "Prime Top 10's", "Apple TV+ Top 10's")
/**
* One film's premiere priority, with its reasons and cautions in plain words. [evidence] is its read evidence (null: not read
* yet), [premiereFit] its fit to Premiere's brief and [home] its best other channel (Premiere is never a home).
*/
internal fun premiereCandidate(f: PremiereFacts, evidence: TitleEvidence?, community: Double?, premiereFit: Double?, home: Pair<Brief, Double>?, trending: Boolean,
onStreamingList: Boolean, status: OrbitStatus, today: LocalDate): PremiereCandidate {
val reasons = mutableListOf<String>(); val cautions = mutableListOf<String>()
val runtimeMin = (f.runtimeMs ?: 0L) / 60_000
val excluded = when {
"TV Movie" in f.genres || TvFilmStudio.containsMatchIn(f.studios.joinToString(" ")) -> "made for TV"
"Documentary" in f.genres -> "a documentary"
f.runtimeMs != null && runtimeMin < 75 -> "under 75 minutes"
else -> null
}
val released = runCatching { LocalDate.parse(f.premiere.take(10)) }.getOrNull()
val age = released?.let { ChronoUnit.DAYS.between(it, today) }
var p = 0.0
when {
age == null -> cautions += "no release date"
age < 0 -> cautions += "not released yet"
age <= 365 -> { p += .25; reasons += "released ${released} (in the last 12 months)" }
age <= PREMIERE_LOOKBACK_DAYS -> { p += .1; cautions += "released over a year ago" }
else -> cautions += "an older film: editorial choice only"
}
val studios = f.studios.joinToString(" ")
val major = MajorStudio.find(studios)?.value
if (major != null) { p += .3; reasons += "a $major release" }
StreamingStudio.find(studios)?.let { p -= .2; cautions += "${it.value} among its studios: likely a streaming original" }
// On a streaming service within weeks of release is evidence of a streaming original; later, it's just a film's usual path.
if (onStreamingList && age != null && age <= 60) { p -= .1; cautions += "on a streaming service within weeks of release: likely a streaming original" }
else if (major != null && onStreamingList && age != null && age <= 365) cautions += "already on a streaming service, so its cinema release isn't confirmed"
if (premiereFit != null) { p += .35 * minOf(1.0, premiereFit / .6); if (premiereFit >= .45) reasons += "an event film by its description" }
if (trending) { p += .15; reasons += "on the library's trending lists" }
when {
community == null -> if (evidence != null) cautions += "no viewer rating"
community >= 7 -> { p += .08; reasons += "rated %.1f".format(community) }
community >= 6 -> p += .04
else -> cautions += "rated %.1f, which doesn't rule it out".format(community)
}
if (evidence == null) cautions += "description and rating not read yet: queued"
val homeBrief = home?.takeIf { it.second >= .35 }?.first
val premium = major != null && ((premiereFit ?: 0.0) >= .45 || trending)
val after = when { homeBrief != null -> AfterWindow.GenreHome; premium && p >= .75 -> AfterWindow.StaysOnPremiere; else -> AfterWindow.EditorialReview }
return PremiereCandidate(f, p, reasons, cautions, status, evidence != null, homeBrief, after, premium && homeBrief != null, excluded,
age != null && age in 0..PREMIERE_LOOKBACK_DAYS)
}
/**
* This week's proposals: the strongest worthwhile candidates you haven't decided, at most [PREMIERE_PROPOSALS_PER_WEEK] less
* the premieres you've already decided this week. The rest wait; they're never shown as a list of decisions.
*/
internal fun weeklyPremiereProposals(candidates: List<PremiereCandidate>, decided: Set<String>, decidedThisWeek: Int): List<PremiereCandidate> =
candidates.filter { it.worthwhile && it.facts.id !in decided }.distinctBy { it.facts.work }.sortedByDescending { it.priority }
.take((PREMIERE_PROPOSALS_PER_WEEK - decidedThisWeek).coerceAtLeast(0))
/** A film's Orbit status: booked ahead on any channel ([upcoming]), booked before ([booked]) and not ahead, or neither. */
internal fun orbitStatus(id: String, booked: Set<String>, upcoming: Set<String>): OrbitStatus = when {
id in upcoming -> OrbitStatus.Scheduled
id in booked -> OrbitStatus.Aired
else -> OrbitStatus.Never
}
@@ -42,6 +42,7 @@ internal fun whatsOnRule(channel: Int, entry: ScheduleEntry, zone: ZoneId): Trip
val why = entry.why
val hour = Instant.ofEpochMilli(entry.startMs).atZone(zone).hour
return when {
why.startsWith(PREMIERE_WHY) -> Triple("ORBIT PREMIERE", "A major film's debut on Orbit Premiere", 100)
"premiere: first airing on Orbit" in why -> Triple("FIRST ON ORBIT", "A first airing on Orbit", 100)
// An Advent door, only ever from its confirmed booking; its number is the broadcast's own date.
why.startsWith(ADVENT_WHY) -> adventDoorNumber(entry.startMs, zone)?.let { Triple("ADVENT ON ORBIT · DOOR $it", "Advent on Orbit", 90) }
@@ -24,26 +24,27 @@ class DirectorPremiereTest {
.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 {
private fun report(status: OrbitStatus, studios: List<String> = listOf("Paramount Pictures"), released: String = "2026-10-13"): 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)
val sets = File.createTempFile("collections", ".jsonl").apply { deleteOnExit(); writeText(JSONObject().put("name", "Trending Movies").put("members", JSONArray(listOf("8100001"))).toString() + "\n") }
val film = PremiereFacts("8100001", "Street Brawlers", 2026, setOf("Action", "Adventure"), studios, released, 108 * minute, "tmdb:81")
return buildDeskReport(evidence, sets, emptyMap(), listOf(Arrival("8100001", "Movie", "Street Brawlers", 2026, released, "tmdb:81", 0)), emptySet(), emptyMap(), 1, true,
DirectorMode.Surprise, at(11, 5, 12), london, premiere = PremiereInputs(listOf(film), { status }))
}
@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 premiereIsAProposalApartFromThePicksAndNeverASecondFirstRun() {
val r = report(OrbitStatus.Never)
val p = r.premieres.single()
assertEquals("8100001", p.id); assertEquals(302, p.home!!.number) // its genre home: Orbit Action; Premiere is never a home
assertTrue(p.reasons.any { "Paramount" in it })
assertTrue((r.picks.map { it.id } + r.asks.map { it.id }).none { it == "8100001" }) // shown once, as a premiere
assertEquals(p.title, DeskReport.of(r.toJson()).premieres.single().title)
// Already aired, or already booked: no duplicate first run, however big.
assertTrue(report(OrbitStatus.Aired).premieres.isEmpty()); assertTrue(report(OrbitStatus.Scheduled).premieres.isEmpty())
// An older film isn't proposed by the Chair.
assertTrue(report(OrbitStatus.Never, released = "2023-06-01").premieres.isEmpty())
// No studio in the library data, not trending... still weighed: here its event-film description and recency carry it.
assertNotNull(report(OrbitStatus.Never, studios = listOf("CAPCOM")).premieres.singleOrNull())
}
@Test fun theDebutIsAFridayOrSaturdayNightAfterTheCommittedPlan() {
@@ -70,8 +71,8 @@ class DirectorPremiereTest {
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 })
assertTrue(first.why.startsWith(PREMIERE_WHY)); assertFalse("first airing on Orbit" in first.why) // apart from FIRST ON ORBIT
assertEquals("ORBIT PREMIERE", 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))
@@ -0,0 +1,101 @@
package tv.orbit.classic
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.File
import java.time.LocalDate
/** Orbit Premiere's candidates: weighed evidence, format and Orbit status as the only hard limits, at most three a week. */
class PremiereSelectionTest {
private val today = LocalDate.of(2026, 11, 5)
private fun facts(id: String, title: String, studios: List<String>, premiere: String = "2026-10-01", genres: Set<String> = setOf("Action"), minutes: Long = 110) =
PremiereFacts(id, title, 2026, genres, studios, premiere, minutes * 60_000, "tmdb:$id")
private val action = Briefs.first { it.number == 302 }
private fun ev(id: String, title: String) = TitleEvidence(id, "Movie", title, 2026, "An epic battle.", listOf("Action"), null, "PG-13", null, null)
private fun candidate(f: PremiereFacts, read: Boolean = true, community: Double? = 6.5, fit: Double? = .5, trending: Boolean = false, streaming: Boolean = false,
status: OrbitStatus = OrbitStatus.Never, home: Pair<Brief, Double>? = action to .5) =
premiereCandidate(f, if (read) ev(f.id, f.title) else null, community, if (read) fit else null, if (read) home else null, trending, streaming, status, today)
@Test fun aMajorRecentEventFilmIsWorthwhileWhateverItsReviews() {
val c = candidate(facts("1", "Big Sequel", listOf("Universal Pictures")), community = 4.8)
assertTrue(c.worthwhile)
assertTrue(c.cautions.any { "doesn't rule it out" in it }) // a poorly reviewed blockbuster is still a premiere
assertEquals(AfterWindow.GenreHome, c.afterWindow); assertEquals(302, c.home!!.number)
assertTrue(c.remainsOnPremiere) // premium: stays eligible for Premiere too
}
@Test fun signalsAreWeighedNotRequired() {
// No major studio in the library's data, but an event film, recent and rated: still a candidate (Street Fighter lists only CAPCOM).
assertTrue(candidate(facts("2", "Street Brawlers", listOf("CAPCOM")), community = 6.3, fit = .61).worthwhile)
// An older major film isn't proposed by the Chair, but isn't ruled out either.
val older = candidate(facts("3", "Classic", listOf("Warner Bros. Pictures"), premiere = "2023-06-01"))
assertNull(older.excluded); assertFalse(older.worthwhile); assertTrue(older.cautions.any { "editorial" in it })
// A streaming original is weighed down, not excluded.
val streamer = candidate(facts("4", "Streamer Film", listOf("Netflix", "Legendary Pictures")), trending = true)
assertNull(streamer.excluded); assertTrue(streamer.cautions.any { "streaming original" in it })
assertTrue(streamer.priority < candidate(facts("5", "Cinema Film", listOf("Legendary Pictures")), trending = true).priority)
}
@Test fun formatAndOrbitStatusAreTheOnlyHardLimits() {
assertEquals("made for TV", candidate(facts("6", "Holiday Romance", listOf("Hallmark Media"), genres = setOf("Romance", "TV Movie"))).excluded)
assertEquals("under 75 minutes", candidate(facts("7", "Special", listOf("Marvel Studios"), minutes = 25)).excluded)
// No duplicate first run: aired or already booked on Orbit means no premiere, however big.
assertFalse(candidate(facts("8", "Aired Hit", listOf("Pixar")), status = OrbitStatus.Aired).worthwhile)
assertFalse(candidate(facts("9", "Booked Hit", listOf("Pixar")), status = OrbitStatus.Scheduled).worthwhile)
// Not read yet: queued for reading, never judged on the catalogue alone.
val unread = candidate(facts("10", "Unread Hit", listOf("Pixar")), read = false, community = null, trending = true)
assertFalse(unread.worthwhile); assertTrue(unread.cautions.any { "queued" in it })
}
@Test fun noHomeIsNeverForced() {
val premium = candidate(facts("11", "Event Drama", listOf("Paramount Pictures")), fit = .6, trending = true, home = null)
assertEquals(AfterWindow.StaysOnPremiere, premium.afterWindow); assertNull(premium.home)
val modest = candidate(facts("12", "Quiet Drama", listOf("A24")), fit = .1, home = action to .2)
assertEquals(AfterWindow.EditorialReview, modest.afterWindow)
}
@Test fun atMostThreeAWeekAndDecidedOnesMakeWay() {
val all = (1..8).map { candidate(facts("2$it", "Film $it", listOf("Universal Pictures")), community = 6.0 + it * .2, trending = it % 2 == 0) }
val week = weeklyPremiereProposals(all, emptySet(), 0)
assertEquals(3, week.size); assertEquals(week.sortedByDescending { it.priority }, week)
assertEquals(1, weeklyPremiereProposals(all, emptySet(), 2).size)
assertTrue(weeklyPremiereProposals(all, emptySet(), 3).isEmpty())
assertTrue(weeklyPremiereProposals(all, setOf(week.first().facts.id), 0).none { it.facts.id == week.first().facts.id })
}
/** Hand-run on saved inputs: ORBIT_DIRECTOR_FIXTURE with premiere-facts.json, evidence-0.jsonl and the channels' episodes files. Writes premiere-selection.json. */
@Test fun replayOnTheLibrary() {
val dir = System.getenv("ORBIT_DIRECTOR_FIXTURE")?.let(::File)
assumeTrue(dir != null && File(dir, "premiere-facts.json").exists())
val input = JSONObject(File(dir, "premiere-facts.json").readText())
val cols = input.getJSONObject("collections")
fun members(names: Set<String>) = names.filter(cols::has).flatMap { n -> cols.getJSONArray(n).let { a -> (0 until a.length()).map { a.getString(it) } } }.toSet()
val trending = members(TrendingLists); val streaming = members(StreamingLists)
val lines = File(dir, "evidence-0.jsonl").readLines().map { JSONObject(it) }
val evidence = lines.associate { it.getString("id") to TitleEvidence.of(it) }
val pools = Briefs.mapNotNull { b -> File(dir, "episodes-${b.number}.json").takeIf { it.exists() }?.let { f ->
val rows = JSONObject(f.readText()).getJSONArray("series"); b.number to (0 until rows.length()).map { rows.getJSONObject(it).getString("id") } } }.toMap()
val text = ChannelText(pools, evidence)
val rows = input.getJSONArray("facts")
val out = JSONArray()
val candidates = (0 until rows.length()).map { rows.getJSONObject(it) }.map { o ->
val f = PremiereFacts(o.getString("id"), o.getString("title"), o.optInt("year").takeIf { it > 0 }, o.getJSONArray("genres").let { a -> (0 until a.length()).map { a.getString(it) }.toSet() },
o.getJSONArray("studios").let { a -> (0 until a.length()).map { a.getString(it) } }, o.getString("premiere"), if (o.isNull("runtime")) null else o.getLong("runtime"), o.getString("work"))
val e = evidence[f.id]
val placed = e?.let { placeTitle(it, text) }
val fit = placed?.ranked?.firstOrNull { it.first.number == PREMIERE_NUMBER }?.second ?: e?.let { 0.0 }
val home = placed?.let(::homeOf)?.let { it.top to it.s1 }
premiereCandidate(f, e, e?.community, fit, home, f.id in trending, f.id in streaming, OrbitStatus.valueOf(o.getString("status")), today)
}
candidates.sortedByDescending { it.priority }.forEach { c -> out.put(JSONObject().put("id", c.facts.id).put("title", c.facts.title).put("premiere", c.facts.premiere)
.put("priority", c.priority).put("worthwhile", c.worthwhile).put("read", c.evidenceRead).put("excluded", c.excluded ?: JSONObject.NULL).put("status", c.status.name)
.put("home", c.home?.name ?: JSONObject.NULL).put("after", c.afterWindow.label).put("stays", c.remainsOnPremiere)
.put("reasons", JSONArray(c.reasons)).put("cautions", JSONArray(c.cautions))) }
File(dir, "premiere-selection.json").writeText(out.toString(1))
File(dir, "premiere-week.json").writeText(JSONArray(weeklyPremiereProposals(candidates, emptySet(), 0).map { it.facts.title }).toString())
}
}