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 d93bbb7..294d1e5 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt @@ -77,13 +77,15 @@ internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val sca /** This week's Orbit Premiere proposals, at most three. */ val premieres: List = emptyList(), /** Recent major films whose evidence is still to be read before they can be judged. */ - val premiereQueued: Int = 0) { + val premiereQueued: Int = 0, + /** Premiere debuts you've approved, waiting for the booking workflow. */ + val premiereApproved: List = emptyList()) { 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("premieres", JSONArray(premieres.map { it.toJson() })).put("premiereQueued", premiereQueued) + .put("decided", decided).put("premieres", JSONArray(premieres.map { it.toJson() })).put("premiereQueued", premiereQueued).put("premiereApproved", JSONArray(premiereApproved)) 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() @@ -92,7 +94,8 @@ internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val sca 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"), - list("premieres").map(DeskPremiere::of), o.optInt("premiereQueued")) + list("premieres").map(DeskPremiere::of), o.optInt("premiereQueued"), + o.optJSONArray("premiereApproved")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()) } } } @@ -228,7 +231,8 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools: 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)) } - 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) + 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, + PremiereCalls.filter { it.premiere }.map { it.title }) } } @@ -267,7 +271,8 @@ internal fun premiereInputs(store: ScheduleStore, catalogue: CatalogueStore, use 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 }) + loadDecisions(store.decisionsFile()).count { it.premiere && it.atMs >= weekStart } + + PremiereCalls.count { !it.decidedOn.isBefore(today.with(java.time.DayOfWeek.MONDAY)) && !it.decidedOn.isAfter(today) }) } /** The first sentence of a description, kept short for a card. */ @@ -360,6 +365,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ 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.premiereApproved.isNotEmpty()) Text("Approved for Premiere, waiting for booking: ${r.premiereApproved.joinToString(", ")}", color = ChairMint, fontSize = 10.sp) 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) diff --git a/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt b/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt index 787187e..889f920 100644 --- a/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt +++ b/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt @@ -17,6 +17,21 @@ 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 +/** + * Paul's editorial calls on Premiere proposals, kept by library item: an approved debut with its permanent home (and whether it + * stays eligible for Premiere), or a ruling that a film isn't a theatrical premiere. Approved debuts wait here for the booking + * workflow, which shows their dates and collision checks before anything is booked; nothing is booked or listed from this. + */ +internal class PremiereCall(val itemId: String, val title: String, val premiere: Boolean, val home: Int, val keepsPremiere: Boolean, val note: String, + /** The day of the call: it counts towards that week's three. */ + val decidedOn: LocalDate = LocalDate.of(2026, 11, 5)) + +internal val PremiereCalls = listOf( + PremiereCall("7789551", "Avatar: Fire and Ash", true, 303, true, "approved by Paul on 5 November 2026: Premiere debut, then Orbit Sci-Fi"), + PremiereCall("8006401", "Spider-Man: Brand New Day", true, 302, true, "approved by Paul on 5 November 2026: Premiere debut, then Orbit Action"), + PremiereCall("7974596", "Mayday", false, 302, false, + "not a theatrical premiere: an Apple Original Film released on Apple TV on 4 September 2026 (Paul, 5 November 2026); regular Orbit Action instead")) + /** 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, val studios: List, val premiere: String, val runtimeMs: Long?, val work: String) @@ -97,8 +112,11 @@ internal fun premiereCandidate(f: PremiereFacts, evidence: TitleEvidence?, commu * 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, decided: Set, decidedThisWeek: Int): List = - candidates.filter { it.worthwhile && it.facts.id !in decided }.distinctBy { it.facts.work }.sortedByDescending { it.priority } +internal fun weeklyPremiereProposals(candidates: List, decided: Set, decidedThisWeek: Int, + /** Films you've already ruled on ([PremiereCalls]): never proposed again, either way. */ + called: Set = PremiereCalls.map { it.itemId }.toSet()): List = + // Only films over the bar are proposed: a week with fewer than three worthwhile films shows fewer, never a filler. + candidates.filter { it.worthwhile && it.facts.id !in decided && it.facts.id !in called }.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. */ diff --git a/android/app/src/test/java/tv/orbit/classic/PremiereSelectionTest.kt b/android/app/src/test/java/tv/orbit/classic/PremiereSelectionTest.kt index d9b76fc..d259051 100644 --- a/android/app/src/test/java/tv/orbit/classic/PremiereSelectionTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PremiereSelectionTest.kt @@ -67,6 +67,19 @@ class PremiereSelectionTest { assertTrue(weeklyPremiereProposals(all, setOf(week.first().facts.id), 0).none { it.facts.id == week.first().facts.id }) } + @Test fun yourCallsStickAndNoFillerIsMade() { + // Paul's calls: Avatar and Spider-Man approved, Mayday not a theatrical premiere. None is proposed again. + val called = PremiereCalls.map { it.itemId } + val films = called.map { id -> candidate(facts(id, "Called $id", listOf("Universal Pictures")), trending = true) } + assertTrue(weeklyPremiereProposals(films, emptySet(), 0).isEmpty()) + assertEquals(setOf(303, 302), PremiereCalls.filter { it.premiere }.map { it.home }.toSet()) + assertFalse(PremiereCalls.single { it.title == "Mayday" }.premiere) + // One worthwhile film and weaker ones: one proposal, not three. + val week = weeklyPremiereProposals(listOf(candidate(facts("31", "Big One", listOf("Pixar")), trending = true), + candidate(facts("32", "Small One", listOf("Tiny Films")), fit = .1, community = 5.0), candidate(facts("33", "Old One", listOf("Pixar"), premiere = "2022-01-01"))), emptySet(), 0) + assertEquals(listOf("31"), week.map { it.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)