Director's Chair: place or drop from a pop-up
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m5s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m50s

Pressing a pick opens its pop-up: place it on the suggested channel, change
to another channel the scoring allows (three at most, each with its reason),
or drop it. An ask offers its two channels or Drop. Placing adds the title
to that channel's own list as an accepted choice, so it joins at the next
top-up; nothing is booked and committed bookings never change. Every
decision is recorded, and decided titles make way for the next. A placed
film's playable copy is found when the pool is read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 08:31:49 +00:00
co-authored by Claude Opus 5.5
parent 80d5c3deb0
commit 2a110fbc72
6 changed files with 223 additions and 39 deletions
@@ -27,36 +27,81 @@ import java.time.LocalDate
import java.time.ZoneId
/**
* Settings › Director's Chair (Paul's approved design, 4 November 2026): one read-only summary of what's new and where it fits.
* Routine matches as counts, three to five discoveries, and only the genuine either-or calls. It books nothing, changes no
* pool or schedule and approves nothing; auto-approval is off. Scan gaps (titles not yet read, no collections, an
* Settings › Director's Chair (Paul's approved design, 4 November 2026; decisions approved 5 November): what's new and where it fits.
* Routine matches as counts, three to five discoveries, and only the genuine either-or calls. You place or drop each one yourself:
* it books nothing and approves nothing on its own; auto-approval is off. Scan gaps (titles not yet read, no collections, an
* incomplete catalogue) are shown, never hidden.
*/
internal enum class DirectorMode(val label: String, val picks: Int) { Familiar("FAMILIAR", 3), Balanced("BALANCED", 4), Surprise("SURPRISE ME", 5) }
internal class DeskPick(val title: String, val channel: String, val tag: String, val line: String)
internal class DeskAsk(val title: String, val first: String, val second: String)
/** A channel the Chair can place a title on, with the reason in a line. */
internal class DeskOption(val number: Int, val name: String, val reason: String) {
fun toJson(): JSONObject = JSONObject().put("n", number).put("c", name).put("r", reason)
companion object { fun of(o: JSONObject) = DeskOption(o.optInt("n"), o.optString("c"), o.optString("r")) }
}
/** A discovery: [options] are where it can go, its suggested channel first. [id], [type] and [year] are the library item's, for placing it. */
internal class DeskPick(val title: String, val channel: String, val tag: String, val line: String, val id: String = "", val type: String = "", val year: Int? = null,
val options: List<DeskOption> = emptyList())
/** An either-or call: [options] are the two channels it's torn between. */
internal class DeskAsk(val title: String, val first: String, val second: String, val id: String = "", val type: String = "", val year: Int? = null,
val options: List<DeskOption> = emptyList(), val line: String = "")
private fun JSONObject.item(id: String, type: String, year: Int?, options: List<DeskOption>) =
put("i", id).put("k", type).put("y", year ?: JSONObject.NULL).put("o", JSONArray(options.map { it.toJson() }))
private fun JSONObject.options() = optJSONArray("o")?.let { a -> (0 until a.length()).map { DeskOption.of(a.getJSONObject(it)) } }.orEmpty()
private fun JSONObject.year() = if (isNull("y")) null else optInt("y").takeIf { it > 0 }
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) {
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) {
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) }))
.put("asks", JSONArray(asks.map { JSONObject().put("t", it.title).put("a", it.first).put("b", it.second) }))
.put("picks", JSONArray(picks.map { JSONObject().put("t", it.title).put("c", it.channel).put("g", it.tag).put("l", it.line).item(it.id, it.type, it.year, it.options) }))
.put("asks", JSONArray(asks.map { JSONObject().put("t", it.title).put("a", it.first).put("b", it.second).put("l", it.line).item(it.id, it.type, it.year, it.options) }))
.put("collections", collections).put("franchises", franchises).put("catalogueComplete", catalogueComplete).put("computedMs", computedMs).put("tookMs", tookMs)
.put("decided", decided)
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()
return DeskReport(o.optInt("newlyReleased"), o.optInt("newToLibrary"), o.optInt("scanned"), o.optInt("unread"),
list("strongByChannel").map { it.optString("c") to it.optInt("n") }, o.optInt("strong"),
list("picks").map { DeskPick(it.optString("t"), it.optString("c"), it.optString("g"), it.optString("l")) },
list("asks").map { DeskAsk(it.optString("t"), it.optString("a"), it.optString("b")) },
o.optInt("collections"), o.optBoolean("catalogueComplete"), o.optInt("franchises"), o.optLong("computedMs"), o.optLong("tookMs"))
list("picks").map { DeskPick(it.optString("t"), it.optString("c"), it.optString("g"), it.optString("l"), it.optString("i"), it.optString("k"), it.year(), it.options()) },
list("asks").map { DeskAsk(it.optString("t"), it.optString("a"), it.optString("b"), it.optString("i"), it.optString("k"), it.year(), it.options(), it.optString("l")) },
o.optInt("collections"), o.optBoolean("catalogueComplete"), o.optInt("franchises"), o.optLong("computedMs"), o.optLong("tookMs"), o.optInt("decided"))
}
}
}
/** Paul's standing editorial calls so far, shown so the director's reasoning is visible. Decisions on the Chair itself come later. */
/** A decision you made on the Chair: placed on [channel], or dropped (channel null). Recorded for the Chair to learn from. */
internal class ChairDecision(val id: String, val title: String, val type: String, val placed: Boolean, val channel: Int?, val suggested: Int?, val atMs: Long) {
fun toJson(): JSONObject = JSONObject().put("i", id).put("t", title).put("k", type).put("p", placed).put("c", channel ?: JSONObject.NULL)
.put("s", suggested ?: JSONObject.NULL).put("a", atMs)
companion object {
fun of(o: JSONObject) = ChairDecision(o.getString("i"), o.optString("t"), o.optString("k"), o.optBoolean("p"),
if (o.isNull("c")) null else o.optInt("c"), if (o.isNull("s")) null else o.optInt("s"), o.optLong("a"))
}
}
internal fun loadDecisions(file: File): List<ChairDecision> = runCatching { JSONArray(file.readText()).let { a -> (0 until a.length()).map { ChairDecision.of(a.getJSONObject(it)) } } }.getOrDefault(emptyList())
/** Adds [decision] to the record, replacing any earlier decision on the same title. Written whole, through a temporary file. */
internal fun saveDecision(file: File, decision: ChairDecision) = synchronized(DecisionLock) {
val all = loadDecisions(file).filter { it.id != decision.id } + decision
val temp = File(file.parentFile, file.name + ".tmp")
temp.writeText(JSONArray(all.map { it.toJson() }).toString()); if (!temp.renameTo(file)) { file.writeText(temp.readText()); temp.delete() }
}
private val DecisionLock = Any()
/** The reason a channel suits a title, in a line, from the scoring's own evidence. */
private fun reasonLine(why: List<String>): String =
why.filter { it.startsWith("themes:") || it.startsWith("genres ") || it.startsWith("British") || it.startsWith("in the ") }.take(2).joinToString("; ")
.ifEmpty { why.firstOrNull { it.startsWith("description like") } ?: "fits the channel's brief" }
/** Where a placed title can go: the scored channels, best first, three at most, never one the scoring ruled out. */
private fun optionsOf(p: Placement, max: Int = 3): List<DeskOption> = p.ranked.take(max).map { (b, _, why) -> DeskOption(b.number, b.name, reasonLine(why)) }
/** Paul's standing editorial calls so far, shown so the director's reasoning is visible. */
internal val DirectorRules = listOf("Made-for-TV romances: not for Orbit", "Tales from the Crypt: After Dark strand", "Black Mirror: Paradox from 21:00")
/**
@@ -89,12 +134,15 @@ internal fun franchiseLinks(file: File, pools: Map<Int, List<String>>): Triple<F
* Titles already on a channel are left out (by work, whichever copy). [mode] sets how many discoveries and how far they reach.
*/
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()): DeskReport =
buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone).getValue(mode)
originals: Map<String, String>, newToLibrary: Int, catalogueComplete: Boolean, mode: DirectorMode, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(),
decided: Set<String> = emptySet()): DeskReport =
buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided).getValue(mode)
/** All three modes' reports from one scoring pass: the modes only differ in how many discoveries they show and how far they reach. */
internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools: Map<Int, List<String>>, released: List<Arrival>, carriedWorks: Set<String>,
originals: Map<String, String>, newToLibrary: Int, catalogueComplete: Boolean, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): Map<DirectorMode, DeskReport> {
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()): 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()
@@ -109,17 +157,19 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools:
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 asks = placed.filter { it.tier == FitTier.NeedsEye }.sortedByDescending { it.s1 }.take(3).map { DeskAsk(it.evidence.name, it.top.name, it.second.name) }
val undecided = placed.filter { it.evidence.id !in decided }
val asks = undecided.filter { it.tier == FitTier.NeedsEye }.sortedByDescending { it.s1 }.take(3).map {
DeskAsk(it.evidence.name, it.top.name, it.second.name, it.evidence.id, it.evidence.type, it.evidence.year, optionsOf(it, 2), it.evidence.overview?.let { o -> sentence(o) } ?: "No description in the library yet") }
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.
val pool = strong.sortedByDescending { it.s1 }.distinctBy { it.top.number }.toMutableList()
if (mode == DirectorMode.Surprise) pool += placed.filter { it.tier == FitTier.Possible && pool.none { p -> p.top.number == it.top.number } }.sortedByDescending { it.s1 }.take(2)
val pool = undecided.filter { it.tier == FitTier.Strong }.sortedByDescending { it.s1 }.distinctBy { it.top.number }.toMutableList()
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.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)
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 })
}
}
@@ -131,8 +181,8 @@ private val DirectorLock = Any()
* a time: a second caller waits for the first and then reads its copy. Read only.
*/
internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, userId: String, nowMs: Long = System.currentTimeMillis()): Map<DirectorMode, DeskReport>? = synchronized(DirectorLock) {
val evidence = store.evidenceFile(); val collections = store.collectionsFile(); val cache = store.directorFile()
val key = listOf(evidence.lastModified(), collections.lastModified(), store.poolsStamp(), catalogue.summary().total ?: 0L).joinToString(":")
val evidence = store.evidenceFile(); val collections = store.collectionsFile(); val cache = store.directorFile(); val decisions = store.decisionsFile()
val key = listOf(evidence.lastModified(), collections.lastModified(), store.poolsStamp(), catalogue.summary().total ?: 0L, decisions.lastModified()).joinToString(":")
runCatching { JSONObject(cache.readText()) }.getOrNull()?.takeIf { it.optString("key") == key && nowMs - it.optLong("savedMs") < 24 * 60 * 60_000L }?.let { saved ->
val reports = saved.optJSONObject("reports")
if (reports != null) return@synchronized DirectorMode.values().associateWith { DeskReport.of(reports.getJSONObject(it.name)) }
@@ -143,7 +193,8 @@ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, us
val carriedWorks = catalogue.worksOf(ids.values.flatten()).values.toSet()
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)
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())
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}")
@@ -153,17 +204,46 @@ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, us
/** 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 }
/**
* 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()) {
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))
}
/** 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))
/** 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)
private val ChairNavy = Color(0xFF07163A)
private val ChairPanel = Color(0xFF10285A)
private val ChairGold = Color(0xFFF2C14E)
private val ChairMint = Color(0xFF6EDCAA)
private val ChairSky = Color(0xFF78BEFF)
/** Settings › Director's Chair: read only, as approved. [load] builds (or reads back) the report off the main thread. */
/**
* Settings › Director's Chair. Press a pick or an ask for its pop-up: place it on the suggested channel or another that fits,
* or drop it. [place] and [drop] save the decision off the main thread; nothing is booked from here and auto-approval is off.
*/
@Composable
internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequester, modifier: Modifier) {
internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequester, modifier: Modifier,
place: (ChairChoice, DeskOption) -> Unit = { _, _ -> }, drop: (ChairChoice) -> Unit = {}) {
var mode by remember { mutableStateOf(DirectorMode.Balanced) }
val report by produceState<DeskReport?>(null, mode) { value = withContext(Dispatchers.Default) { runCatching { load(mode) }.getOrNull() } }
var version by remember { mutableIntStateOf(0) }
var report by remember { mutableStateOf<DeskReport?>(null) }
// A decision hides its title at once; the report is then worked out again behind the scenes, keeping this one on screen.
var decided by remember { mutableStateOf(emptySet<String>()) }
var open by remember { mutableStateOf<ChairChoice?>(null) }
var said by remember { mutableStateOf<String?>(null) }
LaunchedEffect(mode, version) { withContext(Dispatchers.Default) { runCatching { load(mode) }.getOrNull() }?.let { report = it } }
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text("DIRECTOR'S CHAIR", color = ChairGold, fontFamily = Anton, fontSize = 20.sp)
@@ -189,7 +269,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
r.strongByChannel.take(6).forEach { (c, n) -> Text("$c $n", color = Muted, fontSize = 11.sp) }
if (r.strongByChannel.size > 6) Text("+${r.strongByChannel.size - 6} more channels", color = Muted, fontSize = 11.sp)
Spacer(Modifier.height(4.dp))
Text("Held as proposals. Nothing is booked.", color = Muted, fontSize = 10.sp)
Text(if (r.decided > 0) "${r.decided} decided by you. The rest are counts only." else "Counts only. Only the few below ask for a decision.", color = Muted, fontSize = 10.sp)
// Scan status, always visible: what hasn't been read yet is said, not hidden.
val gaps = listOfNotNull("${r.unread} not read yet".takeIf { r.unread > 0 }, "no collections read, so franchises unknown".takeIf { r.collections == 0 },
"franchises unknown: none of the library's ${r.collections} collections is a franchise".takeIf { r.collections > 0 && r.franchises == 0 },
@@ -198,26 +278,77 @@ 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)
if (r.picks.isEmpty()) Text("Nothing stands out this week.", color = Muted, fontSize = 12.sp)
r.picks.forEach { p -> ChairCard(p.tag, p.title, p.channel.uppercase(java.util.Locale.UK), p.line) }
val picks = r.picks.filter { it.id !in decided }
if (picks.isEmpty()) Text("Nothing stands out this week.", color = Muted, fontSize = 12.sp)
picks.forEach { p -> ChairCard(p.tag, p.title, p.channel.uppercase(java.util.Locale.UK), p.line,
onClick = { open = ChairChoice(p.title, p.id, p.type, p.year, p.tag, p.line, p.options, false) }.takeIf { p.options.isNotEmpty() && p.id.isNotEmpty() }) }
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("NEEDS YOUR EYE", color = Color.White, fontFamily = Anton, fontSize = 14.sp)
if (r.asks.isEmpty()) Text("No either-or calls this week.", color = Muted, fontSize = 12.sp)
r.asks.forEach { a -> ChairCard(null, a.title, null, "${a.first} or ${a.second}?") }
val asks = r.asks.filter { it.id !in decided }
if (asks.isEmpty()) Text("No either-or calls this week.", color = Muted, fontSize = 12.sp)
asks.forEach { a -> ChairCard(null, a.title, null, "${a.first} or ${a.second}?",
onClick = { open = ChairChoice(a.title, a.id, a.type, a.year, null, a.line, a.options, true) }.takeIf { a.options.isNotEmpty() && a.id.isNotEmpty() }) }
Spacer(Modifier.height(4.dp))
Text("RULES IN FORCE", color = Color.White, fontFamily = Anton, fontSize = 13.sp)
DirectorRules.forEach { Text("· $it", color = Muted, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
}
Text("Read only: nothing is booked, placed or changed from this screen. Worked out in ${r.tookMs} ms.", color = Muted, fontSize = 10.sp)
Text(said ?: "Press a title to place it or drop it. A placed title joins that channel's list at its next top-up; nothing already booked changes. Worked out in ${r.tookMs} ms.",
color = if (said != null) ChairMint else Muted, fontSize = 10.sp)
}
open?.let { choice ->
ChairPopup(choice, onPlace = { option ->
open = null; decided = decided + choice.id; place(choice, option)
said = "${choice.title} placed on ${option.name}. It joins the channel at its next top-up, within the hour."; version++
}, onDrop = {
open = null; decided = decided + choice.id; drop(choice)
said = "${choice.title} dropped. The Chair won't offer it again."; version++
}, onClose = { open = null })
}
}
/** The pop-up for one title: its suggested channel (or, for an ask, the two it's torn between), any other fits, and Drop. */
@Composable
private fun ChairPopup(choice: ChairChoice, onPlace: (DeskOption) -> Unit, onDrop: () -> Unit, onClose: () -> Unit) {
var others by remember { mutableStateOf(false) }
androidx.compose.ui.window.Dialog(onDismissRequest = onClose, properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)) {
val firstButton = remember { FocusRequester() }
LaunchedEffect(others) { runCatching { firstButton.requestFocus() } }
Column(Modifier.width(620.dp).background(ChairNavy, RoundedCornerShape(10.dp)).border(2.dp, ChairGold, RoundedCornerShape(10.dp)).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (choice.ask) "NEEDS YOUR EYE" else choice.tag ?: "WORTH A LOOK", color = ChairGold, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
Text(choice.title.uppercase(java.util.Locale.UK), color = Color.White, fontFamily = Anton, fontSize = 28.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(listOfNotNull(choice.year?.toString(), if (choice.type == "Series") "Series" else "Film").joinToString(" · "), color = ChairSky, fontSize = 12.sp)
Text(choice.line, color = Color.White, fontSize = 13.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
// Asks show both channels from the start; a pick shows its suggestion, and the other fits on Change channel.
val shown = when { choice.ask || others -> choice.options; else -> choice.options.take(1) }
shown.forEachIndexed { i, option ->
Row(Modifier.fillMaxWidth().background(ChairPanel, RoundedCornerShape(6.dp)).padding(10.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Column(Modifier.weight(1f)) {
Text(option.name.uppercase(java.util.Locale.UK) + if (i == 0 && !choice.ask) " · SUGGESTED" else "", color = ChairSky, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(option.reason, color = Muted, fontSize = 11.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
OrbitButton("Place on ${option.name}", if (i == 0) Modifier.focusRequester(firstButton) else Modifier, onClick = { onPlace(option) })
}
}
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("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.",
color = Muted, fontSize = 10.sp)
}
}
}
@Composable
private fun ChairCard(tag: String?, title: String, channel: String?, line: String) {
private fun ChairCard(tag: String?, title: String, channel: String?, line: String, onClick: (() -> Unit)? = null) {
var focused by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.focusable().background(if (focused) Color(0xFF1C3A70) else ChairPanel, RoundedCornerShape(6.dp))
Column(Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier.focusable())
.background(if (focused) Color(0xFF1C3A70) else ChairPanel, RoundedCornerShape(6.dp))
.border(if (focused) 2.dp else 0.dp, ChairGold, RoundedCornerShape(6.dp)).padding(horizontal = 8.dp, vertical = 5.dp)) {
Row { tag?.let { Text(it, Modifier.weight(1f), color = ChairGold, fontSize = 9.sp, fontWeight = FontWeight.SemiBold) }; channel?.let { Text(it, color = ChairSky, fontSize = 9.sp, fontWeight = FontWeight.SemiBold) } }
Text(title, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -227,7 +227,9 @@ internal fun channelScore(e: TitleEvidence, b: Brief, text: ChannelText, links:
internal enum class FitTier(val label: String) { Strong("Strong match"), Possible("Possible"), NeedsEye("Needs your eye"), None("No clear home") }
internal class Placement(val evidence: TitleEvidence, val top: Brief, val s1: Double, val second: Brief, val s2: Double, val why: List<String>) {
internal class Placement(val evidence: TitleEvidence, val top: Brief, val s1: Double, val second: Brief, val s2: Double, val why: List<String>,
/** Every channel the title can go on (a score above nothing), best first, each with its reasons: the Chair's other homes for it. */
val ranked: List<Triple<Brief, Double, List<String>>> = emptyList()) {
/** Strong: 0.45 or more and clearly ahead. Possible: 0.35 or more. Needs your eye: two channels within 15%. As measured on the samples. */
val tier get() = when {
s1 >= .45 && s2 < s1 * .85 -> FitTier.Strong
@@ -240,7 +242,7 @@ internal class Placement(val evidence: TitleEvidence, val top: Brief, val s1: Do
internal fun placeTitle(e: TitleEvidence, text: ChannelText, links: FranchiseLinks = FranchiseLinks.Unknown): Placement {
val scored = Briefs.map { b -> channelScore(e, b, text, links).let { (s, why) -> Triple(b, s, why) } }.sortedByDescending { it.second }
val (b1, s1, why) = scored[0]; val (b2, s2, _) = scored[1]
return Placement(e, b1, s1, b2, s2, why)
return Placement(e, b1, s1, b2, s2, why, scored.filter { it.second > 0 })
}
internal fun closeEnough(a: Double, b: Double) = abs(a - b) < 1e-6
@@ -509,7 +509,13 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
SettingsScreen(settingsSection, { settingsSection = it }, modifier, busy, message, HttpsEmbyTransport.SERVER, sample?.total,
arrivals = { detail, first -> NewArrivalsDetail(::loadArrivalsReport, first, detail) },
director = { detail, first -> DirectorChair(::loadDirectorReport, first, detail) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
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)
android.util.Log.i("OrbitSchedule", "director placed channel=${option.number} suggested=${option.number == choice.options.firstOrNull()?.number}")
}.onFailure { android.util.Log.w("OrbitSchedule", "director placeFailed=${it.javaClass.simpleName}") } } } },
drop = { choice -> scope.launch(Dispatchers.IO) { runCatching { dropFromChair(scheduleStore, choice.id, choice.title, choice.type, choice.options.firstOrNull()?.number) }
.onFailure { android.util.Log.w("OrbitSchedule", "director dropFailed=${it.javaClass.simpleName}") }; android.util.Log.i("OrbitSchedule", "director dropped") } }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on ->
val tots = scheduledChannels[TOTS_NUMBER]
val active = session
@@ -363,8 +363,9 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
checkActive()
if (pool.any { it.id == accepted.id || seriesKey(it.title) == seriesKey(accepted.title) }) continue
if (accepted.type == "Movie") {
val source = accepted.sourceId ?: continue
val duration = accepted.durationMs ?: continue
// A film placed from the Director's Chair carries no copy yet: its first copy this box can play is found here, or it waits.
val (source, duration) = if (accepted.sourceId != null && accepted.durationMs != null) accepted.sourceId to accepted.durationMs
else { requests++; playableFilm(accepted.id, programming.englishAudio) ?: continue }
pool += PoolSeries(accepted.id, accepted.title, listOf(PoolEpisode(accepted.id, source, EpisodeMark(1, 1), accepted.title, duration)), accepted.group)
} else {
val whole = readWholeSeries(transport, userId, ::currentToken, SeriesCandidate(accepted.id, accepted.title, accepted.year, ""), checkActive, onRequest = { requests++ }, canDecode = BoxDecoders::canDecode, englishAudio = programming.englishAudio)
@@ -374,6 +375,15 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
// An editorial withdrawal takes that exact item out, however it arrived: list, old line-up or accepted suggestion.
return PoolRead(withoutWithdrawn(pool, programming.withdrawn), requests, partial.carried)
}
/** One film's first copy this box can play, as (source id, runtime), by its exact library item; null if it has none. */
private fun playableFilm(id: String, englishAudio: Boolean): Pair<String, Long>? {
if (!EmbyClient.validId(id)) return null
val reply = runCatching { transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Ids=$id&Fields=MediaSources&EnableImages=false&EnableUserData=false",
token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS)) }.getOrNull() ?: return null
if (reply.status !in 200..299) return null
val row = EmbyClient.parse(reply.body).optJSONArray("Items")?.optJSONObject(0)?.takeIf { it.optString("Id") == id && it.optString("Type") == "Movie" } ?: return null
return playableSource(row, BoxDecoders::canDecode, englishAudio)
}
fun suggestFor(rules: List<SuggestionRule>, alreadyInPool: Set<String>, rejected: Set<String>, takenTitles: Set<String> = emptySet(), checkActive: () -> Unit = {}) =
findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive, BoxDecoders::canDecode, takenTitles, englishAudio = true)
fun fetchArtwork(path: String): ByteArray? = (transport as? HttpsEmbyTransport)?.fetchImage(path, currentToken())
@@ -54,6 +54,8 @@ internal class ScheduleStore(private val directory: File) {
fun collectionsFile() = File(directory, "collections-0.jsonl")
/** The Director's Chair's last report, so the screen opens straight away. */
fun directorFile() = File(directory, "director-0.json")
/** Your decisions on the Director's Chair ([ChairDecision]): what you placed where, and what you dropped. */
fun decisionsFile() = File(directory, "director-decisions-0.json")
/** When the channels' pools last changed, without reading them: their files' times together. */
fun poolsStamp(): Long = directory.listFiles { f -> f.name.startsWith("episodes-") && f.name.endsWith(".json") }?.sumOf { it.lastModified() } ?: 0L
@@ -96,6 +96,39 @@ class DirectorChairTest {
assertTrue(ev.exists())
}
@Test fun picksCarryTheirChoicesAndDecidedTitlesMakeWayForTheNext() {
val cands = listOf(line("101", "Ocean Life", "Series", listOf("Documentary"), "Wildlife of the ocean: reefs, sharks and the species of the deep sea."),
line("102", "Forest Life", "Series", listOf("Documentary"), "Wildlife of the forest: animals and species of the ancient woods and nature."))
val (ev, sets) = files(cands)
val released = listOf(arrival("101", "Ocean Life"), arrival("102", "Forest Life"))
val before = buildDeskReport(ev, sets, pools, released, emptySet(), emptyMap(), 2, true, DirectorMode.Balanced, 1_793_800_000_000)
val pick = before.picks.first()
assertEquals(1, before.picks.size); assertEquals("Series", pick.type) // one pick per channel: both are Terra's
assertEquals(201, pick.options.first().number) // the suggested channel first
assertTrue(pick.options.size <= 3); assertTrue(pick.options.none { it.number == 107 }) // never a channel the scoring ruled out (Evidence takes no documentaries)
assertTrue(pick.options.first().reason.isNotBlank())
// Once decided, a title isn't shown again; the counts stay as they were.
val after = buildDeskReport(ev, sets, pools, released, emptySet(), emptyMap(), 2, true, DirectorMode.Balanced, 1_793_800_000_000, decided = setOf(pick.id))
assertTrue(after.picks.none { it.id == pick.id }); assertEquals(before.strong, after.strong); assertEquals(1, after.decided)
assertEquals(setOf("101", "102") - pick.id, after.picks.map { it.id }.toSet()) // the next one takes its place
val back = DeskReport.of(before.toJson()).picks.first()
assertEquals(pick.id, back.id); assertEquals(pick.options.map { it.number }, back.options.map { it.number }); assertEquals(pick.year, back.year)
}
@Test fun placingAddsToTheChannelsOwnListAndDropOnlyRecords() {
val dir = kotlin.io.path.createTempDirectory("chair").toFile().apply { deleteOnExit() }
val store = ScheduleStore(dir); val user = "a1b2c3"
placeFromChair(store, user, "5000001", "Ocean Life", "Series", 2026, 201, 201, 10)
placeFromChair(store, user, "5000001", "Ocean Life", "Series", 2026, 201, 201, 11) // twice: still once on the list
assertEquals(listOf("5000001"), store.loadChoices(user, 201).accepted.map { it.id })
dropFromChair(store, "5000002", "Wedding Bells", "Movie", 609, 12)
assertTrue(store.loadChoices(user, 609).accepted.isEmpty())
val record = loadDecisions(store.decisionsFile())
assertEquals(listOf("5000001" to true, "5000002" to false), record.map { it.id to it.placed })
assertEquals(201, record.first().channel); assertNull(record.last().channel); assertEquals(609, record.last().suggested)
assertThrows(IllegalArgumentException::class.java) { placeFromChair(store, user, "5000003", "A Season", "Season", null, 201, 201) }
}
@Test fun aReportReadsBackAsItWasSaved() {
val r = DeskReport(10, 2, 9, 1, listOf("Terra" to 1), 1, listOf(DeskPick("A", "Terra", "STRONG MATCH", "Line")), listOf(DeskAsk("B", "Sci-Fi", "Horror")), 0, false, 0, 5, 6)
val back = DeskReport.of(r.toJson())