diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt index 14bb3ba..1974f5f 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt @@ -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 = + 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): Map = ids.chunked(400).flatMap { chunk -> readableDatabase.rawQuery("SELECT id, title, year, provider_ids FROM items WHERE id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c -> diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 3ae90cf..0c9e779 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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 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 6f79b77..b29d945 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt @@ -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 = 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, val cautions: List) { + 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, 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>, val strong: Int, val picks: List, val asks: List, 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 = 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>): Triple>, released: List, carriedWorks: Set, originals: Map, newToLibrary: Int, catalogueComplete: Boolean, mode: DirectorMode, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(), - decided: Set = emptySet(), aired: Set = emptySet()): DeskReport = - buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided, aired).getValue(mode) + decided: Set = 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 = 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): Set = if (!file.exists()) emptySet() else HashSet().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>, released: List, carriedWorks: Set, originals: Map, newToLibrary: Int, catalogueComplete: Boolean, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(), /** Titles you've placed or dropped on the Chair: never shown again, so the next ones take their place. */ - decided: Set = emptySet(), - /** Every library item and work ever booked on Orbit: a film among them has had its first airing, so it has no premiere. */ - aired: Set = emptySet()): Map { + decided: Set = emptySet(), premiere: PremiereInputs = PremiereInputs()): Map { val started = System.nanoTime() val evidence = if (evidenceFile.exists()) evidenceFile.readLines().mapNotNull { runCatching { JSONObject(it) }.getOrNull() } .associate { it.optString("id") to TitleEvidence.of(it, originals[it.optString("id")]) } else emptyMap() @@ -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, 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.", diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt index df5176f..f5e5983 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt @@ -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, 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, films: Map): List = - runs.filter { !it.graduated }.mapNotNull { run -> films[run.id]?.let { FixedSlot(run.debutMs, it, "$PREMIERE_WHY; debut chosen on the Director's Chair", "") } } + 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, diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt b/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt index 3905d2b..e12690f 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt @@ -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?, val studios: List?, - val rating: String?, val countries: List?, val taglines: List?, val originalTitle: String? = null) { + val rating: String?, val countries: List?, val taglines: List?, val originalTitle: String? = null, + /** The viewers' rating (community), supporting evidence only. */ + val community: Double? = null) { val genreSet: Set 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() }) } } } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 5e873b4..75ddfdc 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 { diff --git a/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt b/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt new file mode 100644 index 0000000..787187e --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt @@ -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, val studios: List, 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, val cautions: List, 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?, trending: Boolean, + onStreamingList: Boolean, status: OrbitStatus, today: LocalDate): PremiereCandidate { + val reasons = mutableListOf(); val cautions = mutableListOf() + 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, decided: Set, decidedThisWeek: Int): List = + 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, upcoming: Set): OrbitStatus = when { + id in upcoming -> OrbitStatus.Scheduled + id in booked -> OrbitStatus.Aired + else -> OrbitStatus.Never +} diff --git a/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt b/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt index 4e0508d..9f5196f 100644 --- a/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt +++ b/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt @@ -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) } diff --git a/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt b/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt index 4a62c62..a981a3a 100644 --- a/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt @@ -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): DeskReport { + private fun report(status: OrbitStatus, studios: List = 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)) diff --git a/android/app/src/test/java/tv/orbit/classic/PremiereSelectionTest.kt b/android/app/src/test/java/tv/orbit/classic/PremiereSelectionTest.kt new file mode 100644 index 0000000..d9b76fc --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/PremiereSelectionTest.kt @@ -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, premiere: String = "2026-10-01", genres: Set = 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? = 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) = 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()) + } +}