Orbit Premiere: weighed release profile, three proposals a week, booking off
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:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user