0.108.5: Stage D scoring in Kotlin (parity with Python), Emby collections read, Settings > Director's Chair read only; built, not installed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Claude
co-authored by
Claude Opus 5.5
parent
00153e25bd
commit
a17834aa66
@@ -77,6 +77,13 @@ internal class CatalogueStore private constructor(context: Context) : CatalogueS
|
||||
/** Whether the whole library has been read once: only after that is anything read new. */
|
||||
@Synchronized fun complete(): Boolean = summary().finished
|
||||
|
||||
/** Each item's original title, for the Director's Chair's language evidence. */
|
||||
@Synchronized fun originalTitles(ids: Collection<String>): Map<String, String> = ids.chunked(400).flatMap { chunk ->
|
||||
readableDatabase.rawQuery("SELECT id, original_title FROM items WHERE id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c ->
|
||||
generateSequence { if (c.moveToNext()) c else null }.map { it.getString(0) to it.getString(1) }.toList()
|
||||
}
|
||||
}.toMap()
|
||||
|
||||
@Synchronized fun summary(): CatalogueSummary {
|
||||
val db = readableDatabase
|
||||
fun count(where: String) = db.rawQuery("SELECT COUNT(*) FROM items WHERE $where", null).use { it.moveToFirst(); it.getLong(0) }
|
||||
|
||||
@@ -90,6 +90,21 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
|
||||
} catch (failure: EmbyCheckException) { failed++; if (failed > 3) break }
|
||||
}
|
||||
Log.i("OrbitSchedule", "evidence asked=${ids.distinct().size} missing=${missing.size} read=$read failedBatches=$failed")
|
||||
// Emby's collections, for verified franchise links: resumable from where the last read stopped, a bounded number per run.
|
||||
val collections = store.collectionsFile()
|
||||
val done = if (collections.exists()) collections.useLines { it.count() } else 0
|
||||
var start = done; var sets = 0; var total = -1
|
||||
try {
|
||||
while (sets < COLLECTIONS_MAX_PER_RUN) {
|
||||
val (page, all) = session.readCollections(start, COLLECTIONS_PAGE)
|
||||
total = all
|
||||
if (page.isEmpty()) break
|
||||
collections.appendText(page.joinToString("") { it.toString() + "\n" })
|
||||
start += page.size; sets += page.size
|
||||
if (start >= all) break
|
||||
}
|
||||
} catch (failure: EmbyCheckException) { Log.w("OrbitSchedule", "collections failed status=${failure.httpStatus}") }
|
||||
Log.i("OrbitSchedule", "collections read=$sets total=$total kept=${done + sets}")
|
||||
}
|
||||
|
||||
fun topUpDriveIn(userId: String, session: EmbySampleSession? = null): ChannelPlan? {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
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
|
||||
* 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)
|
||||
|
||||
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 computedMs: Long, val tookMs: Long) {
|
||||
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("collections", collections).put("catalogueComplete", catalogueComplete).put("computedMs", computedMs).put("tookMs", tookMs)
|
||||
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.optLong("computedMs"), o.optLong("tookMs"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Paul's standing editorial calls so far, shown so the director's reasoning is visible. Decisions on the Chair itself come later. */
|
||||
internal val DirectorRules = listOf("Made-for-TV romances: not for Orbit", "Tales from the Crypt: After Dark strand", "Black Mirror: Paradox from 21:00")
|
||||
|
||||
/** Emby collections as franchise links: each member's collection, and the channels its fellow members are on. */
|
||||
internal fun franchiseLinks(file: File, pools: Map<Int, List<String>>): Pair<FranchiseLinks, Int> {
|
||||
if (!file.exists()) return FranchiseLinks.Unknown to 0
|
||||
val onChannel = HashMap<String, MutableSet<Int>>().apply { pools.forEach { (ch, ids) -> ids.forEach { getOrPut(it) { HashSet() } += ch } } }
|
||||
val collectionOf = HashMap<String, String>(); val channelsOf = HashMap<String, Set<Int>>()
|
||||
var count = 0
|
||||
file.forEachLine { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@forEachLine
|
||||
count++
|
||||
val members = o.optJSONArray("members")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()
|
||||
val name = o.optString("name")
|
||||
channelsOf[name] = members.flatMap { onChannel[it].orEmpty() }.toSet()
|
||||
members.forEach { collectionOf[it] = name }
|
||||
}
|
||||
return FranchiseLinks(channelsOf, collectionOf) to count
|
||||
}
|
||||
|
||||
/**
|
||||
* The Chair's report from what's on the box: the evidence file, the channels' pools, Emby's collections and the catalogue.
|
||||
* 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 {
|
||||
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()
|
||||
val text = ChannelText(pools, evidence)
|
||||
val (links, collections) = franchiseLinks(collectionsFile, pools)
|
||||
val carriedIds = pools.values.flatten().toSet()
|
||||
val candidates = released.filter { it.id !in carriedIds && it.work !in carriedWorks }.distinctBy { it.work }
|
||||
val read = candidates.mapNotNull { evidence[it.id] }
|
||||
val placed = read.map { placeTitle(it, text, links) }
|
||||
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
|
||||
// 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 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")
|
||||
}
|
||||
val asks = placed.filter { it.tier == FitTier.NeedsEye }.sortedByDescending { it.s1 }.take(3).map { DeskAsk(it.evidence.name, it.top.name, it.second.name) }
|
||||
return DeskReport(candidates.size, newToLibrary, read.size, candidates.size - read.size, byChannel, strong.size, picks, asks, collections, catalogueComplete, nowMs,
|
||||
(System.nanoTime() - started) / 1_000_000)
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
|
||||
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. */
|
||||
@Composable
|
||||
internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequester, modifier: Modifier) {
|
||||
var mode by remember { mutableStateOf(DirectorMode.Balanced) }
|
||||
val report by produceState<DeskReport?>(null, mode) { value = withContext(Dispatchers.Default) { runCatching { load(mode) }.getOrNull() } }
|
||||
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)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
DirectorMode.values().forEachIndexed { i, m ->
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Text(m.label, Modifier.then(if (i == 1) Modifier.focusRequester(first) else Modifier).onFocusChanged { focused = it.isFocused; if (it.isFocused) mode = m }.focusable()
|
||||
.background(if (m == mode) ChairGold else ChairPanel, RoundedCornerShape(12.dp)).border(if (focused) 2.dp else 0.dp, Color.White, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp), color = if (m == mode) ChairNavy else Color.White, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Auto-approval: OFF", color = ChairMint, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
val r = report
|
||||
if (r == null) { Text("Reading the library's evidence…", color = Muted, fontSize = 12.sp); return@Column }
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Column(Modifier.weight(.9f).background(ChairPanel, RoundedCornerShape(8.dp)).padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text("THIS WEEK", color = Color.White, fontFamily = Anton, fontSize = 14.sp)
|
||||
Text("${r.newlyReleased} newly released", color = ChairGold, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text("${r.newToLibrary} new to the library (14 days)", color = Color.White, fontSize = 11.sp)
|
||||
Text("${r.strong} strong matches", color = ChairMint, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
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)
|
||||
// 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 },
|
||||
"library list still importing".takeIf { !r.catalogueComplete })
|
||||
Text(if (gaps.isEmpty()) "Scan complete: ${r.scanned} read, ${r.collections} collections" else "Scan: " + gaps.joinToString("; "), color = if (gaps.isEmpty()) Muted else ChairGold, fontSize = 10.sp)
|
||||
}
|
||||
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) }
|
||||
}
|
||||
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}?") }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChairCard(tag: String?, title: String, channel: String?, line: String) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Column(Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.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)
|
||||
Text(line, color = if (tag == null) ChairGold else Muted, fontSize = 10.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Stage D's channel-fit scoring (approved by Paul on 4 November 2026), ported from the Python reference
|
||||
* (docs/replays/stage-d-trial-5.py) and checked against it on the same saved inputs. Local and explainable: each channel's
|
||||
* editorial brief (docs/channels and Paul's definitions), themes for and against read from the description, word similarity
|
||||
* to what the channel actually shows, era, ratings, format and origin. Read only: it proposes, it never places or books.
|
||||
* Missing metadata is never held against a title; it's said so in the evidence.
|
||||
*/
|
||||
internal class Brief(val number: Int, val name: String, val kinds: Set<String>, val genresAny: Set<String>, val needAny: Set<String> = emptySet(),
|
||||
val themes: Regex, val against: Regex, val era: Pair<Int, Int>, val kids: Boolean = false, val uk: Boolean = false, val current: Boolean = false,
|
||||
val needTheme: Boolean = false)
|
||||
|
||||
private fun w(vararg words: String) = Regex("\\b(" + words.joinToString("|") + ")", RegexOption.IGNORE_CASE)
|
||||
|
||||
/** The briefs, in the reference's order (ties keep this order). */
|
||||
internal val Briefs: List<Brief> = listOf(
|
||||
Brief(101, "Orbit 1", setOf("Series"), setOf("Comedy"), themes = w("sitcom", "family", "household", "neighbou?r", "roommate", "parents", "dad", "mum", "mom", "sisters", "brothers", "college", "workplace"),
|
||||
against = w("murder", "killer", "documentary", "wedding of", "engagement"), era = 1970 to 2026, needTheme = true),
|
||||
Brief(102, "Orbit 2", setOf("Series"), setOf("Game Show", "Reality", "Comedy", "Talk"), themes = w("quiz", "game show", "contestants", "panel", "challenge", "teams compete"),
|
||||
against = w("murder", "documentary"), era = 1960 to 2015, uk = true),
|
||||
Brief(103, "Orbit Three", setOf("Series"), setOf("Comedy"), themes = w("sketch", "cult comedy", "surreal", "dark comedy", "scottish"), against = w("documentary"), era = 1990 to 2026, uk = true),
|
||||
Brief(104, "Orbit Gold", setOf("Series"), setOf("Comedy"), themes = w("sitcom", "classic comedy"), against = w("documentary"), era = 1960 to 1999, uk = true),
|
||||
Brief(105, "Riot", setOf("Series"), setOf("Animation", "Comedy"), setOf("Animation"), w("adult", "irreverent", "raunchy", "satir", "dysfunctional"), w("preschool", "children learn"), 1988 to 2026),
|
||||
Brief(106, "Paradox", setOf("Series"), setOf("Science Fiction", "Fantasy", "Mystery", "Horror"),
|
||||
themes = w("supernatural", "paranormal", "alien", "time travel", "parallel", "ghost", "vampire", "demon", "monster", "mysterious", "strange", "unexplained", "dimension", "future", "space", "dystopia", "cult", "prophecy", "witch"),
|
||||
against = w("documentary", "reality", "romance", "cooking"), era = 1955 to 2026),
|
||||
Brief(107, "Evidence", setOf("Series"), setOf("Crime", "Mystery"),
|
||||
themes = w("detective", "inspector", "police", "forensic", "pathologist", "homicide", "murder", "sergeant", "DCI", "sleuth", "constable", "cop", "FBI", "NCIS", "agent", "crime scene"),
|
||||
against = w("documentary", "true story of", "real-life", "archive footage", "interviews", "lawyer", "law firm", "solicitor", "attorney", "barrister", "courtroom", "divorce", "legal"),
|
||||
era = 1965 to 2026, needTheme = true),
|
||||
Brief(201, "Terra", setOf("Series"), setOf("Documentary"),
|
||||
themes = w("wildlife", "animal", "ocean", "sea", "reef", "planet", "nature", "ecosystem", "species", "forest", "jungle", "predator", "habitat", "natural world", "migration"),
|
||||
against = w("murder", "killer", "crime", "war", "music", "celebrity", "royal", "politic", "AI", "robot", "superhero"), era = 1975 to 2026, needTheme = true),
|
||||
Brief(202, "Odyssey", setOf("Series"), setOf("Documentary", "History"),
|
||||
themes = w("space", "universe", "astronom", "science", "engineer", "scientist", "history of", "historian", "ancient", "archaeolog", "exploration", "explorer", "technolog", "invention", "journey across", "civilisation", "civilization", "planet earth"),
|
||||
against = w("murder", "killer", "crime", "celebrit", "reality", "athlete", "boxer", "comedian", "sitcom", "band", "singer", "fame", "star of"), era = 1950 to 2026, needTheme = true),
|
||||
Brief(203, "Orbit Wild", setOf("Series"), setOf("Reality", "Documentary", "Adventure"),
|
||||
themes = w("surviv", "wilderness", "expedition", "endurance", "remote", "frontier", "alone in", "hunt", "fishing", "mountain", "arctic", "jungle", "desert"),
|
||||
against = w("murder", "dating", "cooking", "makeover", "celebrit", "villa", "luxury", "cash prize", "contestants compete"), era = 1985 to 2026, needTheme = true),
|
||||
Brief(204, "Dark Files", setOf("Series"), setOf("Documentary", "Crime", "Mystery"), setOf("Documentary"),
|
||||
w("murder", "killer", "disappear", "missing", "true crime", "unsolved", "investigat", "case", "paranormal", "haunt", "unexplained", "cult", "conspiracy", "serial", "detective", "victim", "trial"),
|
||||
w("wildlife", "cooking"), 1975 to 2026, needTheme = true),
|
||||
Brief(301, "Orbit Premiere", setOf("Movie"), setOf("Action", "Adventure", "Science Fiction", "Fantasy"), themes = w("epic", "hero", "battle", "world", "mission", "superhero", "franchise"),
|
||||
against = w("documentary", "concert"), era = 1975 to 2026, current = true, needTheme = true),
|
||||
Brief(302, "Orbit Action", setOf("Movie"), setOf("Action"), themes = w("cop", "agent", "assassin", "heist", "fight", "mercenar", "revenge", "martial", "spy", "chase", "soldier"),
|
||||
against = w("documentary", "concert", "UFC", "WWE"), era = 1960 to 2026, current = true),
|
||||
Brief(303, "Orbit Sci-Fi", setOf("Movie"), setOf("Science Fiction"), setOf("Science Fiction"), w("alien", "space", "future", "robot", "planet", "time", "dystopia", "android", "galaxy"), w("documentary"), 1950 to 2026, current = true),
|
||||
Brief(304, "Orbit Horror", setOf("Movie"), setOf("Horror"), setOf("Horror"), w("haunt", "demon", "possess", "killer", "evil", "terror", "ghost", "curse", "slasher", "nightmare"), w("documentary"), 1960 to 2026, current = true),
|
||||
Brief(305, "After Dark", setOf("Movie"), setOf("Horror", "Thriller", "Science Fiction"),
|
||||
themes = w("cult", "bizarre", "strange", "exploitation", "b-movie", "grindhouse", "creature", "mutant", "zombie", "cannibal", "obscure", "surreal", "underground"),
|
||||
against = w("documentary", "romcom", "family"), era = 1955 to 2018),
|
||||
Brief(306, "Orbit VHS", setOf("Movie"), setOf("Comedy", "Adventure", "Fantasy", "Family", "Science Fiction"), themes = w("kids", "teen", "adventure", "summer", "gang", "treasure"),
|
||||
against = w("documentary"), era = 1980 to 1995),
|
||||
Brief(307, "Orbit Romance", setOf("Movie"), setOf("Romance"), setOf("Romance"), w("love", "romance", "romantic", "falls for", "fall in love", "wedding", "sweetheart", "kiss", "date"),
|
||||
w("documentary", "murder", "horror", "outlaw", "gunfight", "sheriff", "war"), 1930 to 2026, current = true, needTheme = true),
|
||||
Brief(308, "Detour", setOf("Movie"), setOf("Comedy"), setOf("Comedy"), w("road trip", "party", "college", "bachelor", "wild night", "spring break", "frat", "hangover", "vegas"),
|
||||
w("documentary", "animated"), 1990 to 2026, current = true, needTheme = true),
|
||||
Brief(309, "BritX", setOf("Movie"), setOf("Crime", "Comedy", "Drama", "Thriller"),
|
||||
themes = w("gangster", "gang", "criminal", "crime", "heist", "drug", "hooligan", "council estate", "thug", "con", "robbery", "dealer"),
|
||||
against = w("documentary"), era = 1965 to 2026, uk = true, current = true, needTheme = true),
|
||||
Brief(401, "Ring X", setOf("Movie", "Series"), setOf("Sport", "Action", "Documentary"), themes = w("wrestl", "WWE", "AEW", "TNA", "UFC", "MMA", "mixed martial", "title match", "pay-per-view", "bout"),
|
||||
against = w("romance"), era = 1980 to 2026, current = true, needTheme = true),
|
||||
Brief(601, "Vortex", setOf("Series", "Movie"), setOf("Animation", "Action", "Anime"), setOf("Animation", "Anime"),
|
||||
w("anime", "fight", "power", "warrior", "battle", "hero", "martial", "saiyan", "ninja", "mecha", "demon"), w("preschool", "sitcom"), 1980 to 2026, needTheme = true),
|
||||
Brief(602, "Heroes", setOf("Series"), setOf("Animation", "Action"), setOf("Animation"), w("superhero", "batman", "spider", "x-men", "justice", "villain", "powers"), w("preschool"), 1980 to 2015, kids = true),
|
||||
Brief(603, "Orbit X", setOf("Series"), setOf("Animation", "Action", "Adventure"), setOf("Animation", "Children", "Family"), w("rangers", "robot", "team", "evil", "power", "adventure", "toy"),
|
||||
w("preschool", "adult"), 1980 to 2002, kids = true),
|
||||
Brief(604, "Rewind", setOf("Series"), setOf("Animation", "Comedy"), setOf("Animation"), w("cartoon", "kids", "adventures", "friends"), w("adult", "preschool"), 1990 to 2015, kids = true),
|
||||
Brief(605, "ToonZ", setOf("Series"), setOf("Animation", "Comedy"), setOf("Animation"), w("cartoon", "classic"), w("adult"), 1928 to 2005, kids = true),
|
||||
Brief(611, "Orbit Junior", setOf("Series"), setOf("Children", "Family", "Animation", "Comedy"), setOf("Children", "Kids", "Family"), w("children", "kids", "school", "friends", "adventure"),
|
||||
w("adult", "murder"), 1955 to 2026, kids = true, uk = true),
|
||||
Brief(612, "Wonder", setOf("Movie"), setOf("Animation", "Family", "Fantasy"), setOf("Animation"), w("princess", "magic", "fairy", "kingdom", "adventure", "tale"),
|
||||
w("adult", "horror", "preschool", "junior", "Disney Jr", "Spidey"), 1935 to 2026, kids = true),
|
||||
Brief(613, "PixelZ", setOf("Movie"), setOf("Animation", "Family", "Comedy"), setOf("Animation"), w("adventure", "friends", "family", "toy", "journey"), w("adult", "horror"), 1995 to 2026, kids = true, current = true),
|
||||
Brief(614, "Tots", setOf("Series"), setOf("Children", "Animation", "Family"), setOf("Children", "Kids"), w("preschool", "little", "learn", "friends", "gentle", "toddler", "young children"),
|
||||
w("adult", "violence", "fight", "battle"), 1980 to 2026, kids = true))
|
||||
|
||||
private val KidsOk = setOf("G", "PG", "TV-Y", "TV-Y7", "TV-G", "TV-PG", "U", "TV-Y7-FV", "E", "0+", "3+", "6+", "7+")
|
||||
private val Adult = setOf("R", "TV-MA", "NC-17", "18", "18+", "X", "NR18", "SG-R21", "SG-M18", "GB-18")
|
||||
private val GenreSynonyms = mapOf("Anime" to "Animation", "Martial Arts" to "Action", "Sci-Fi & Fantasy" to "Science Fiction", "Action & Adventure" to "Action",
|
||||
"Kids" to "Children", "Suspense" to "Thriller", "Sports" to "Sport")
|
||||
private val Uk = Regex("United Kingdom|England|Scotland|Wales|\\bUK\\b")
|
||||
private val UkStudio = Regex("\\bBBC\\b|\\bITV\\b|Channel 4|Film4|Sky\\b|Working Title|Ealing|Hammer|Aardman", RegexOption.IGNORE_CASE)
|
||||
private val EnglishCountries = Regex("United States|United Kingdom|Canada|Australia|Ireland|New Zealand|England|Scotland|Wales")
|
||||
private val ForeignBoard = Regex("^(SG|BR|PL|KR|JP|DE|FR|IT|ES|NL|TR|IN|MX|CN|HK|TW|TH|PH|MY|ID|RU|SE|NO|DK|FI|AR|CL|CO|PT|CZ|HU|RO|GR|IL|EG|SA|AE|VN)-", RegexOption.IGNORE_CASE)
|
||||
private val LiveSpecial = Regex("\\b(stand-up|standup|comedian|comedy special|unruly set|in this set|live from|in concert|tour film|takes aim at)\\b", RegexOption.IGNORE_CASE)
|
||||
private val EventFilmWords = Regex("superhero|epic|franchise|saga|blockbuster", RegexOption.IGNORE_CASE)
|
||||
private val International = setOf(305, 601, 401)
|
||||
private val AnimatedChannels = setOf(105, 601, 602, 603, 604, 605, 611, 612, 613, 614)
|
||||
private val FactualChannels = setOf(201, 202, 203, 204, 401)
|
||||
private val FictionChannels = setOf(101, 103, 104, 105, 106, 107, 301, 302, 303, 304, 305, 306, 307, 308, 309, 601, 602, 603, 604, 605, 612, 613)
|
||||
private val Stop = "a an the and or of to in on for with his her their its is are was be by as at from this that he she they it into who when after one two new life series film show story world".split(" ").toSet()
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun tokens(text: String?) = Token.findAll(text.orEmpty().lowercase()).map { it.value }.filter { it !in Stop }.toList()
|
||||
|
||||
/** What each channel actually shows, as word weights from its titles' descriptions (TF-IDF centroids). */
|
||||
internal class ChannelText(pools: Map<Int, List<String>>, evidence: Map<String, TitleEvidence>) {
|
||||
private val df = HashMap<String, Int>()
|
||||
private val n: Int
|
||||
private val centroids: Map<Int, Map<String, Double>>
|
||||
init {
|
||||
val docs = pools.mapValues { (_, ids) -> ids.mapNotNull { evidence[it]?.overview } }
|
||||
docs.values.forEach { ds -> ds.forEach { d -> tokens(d).toSet().forEach { df[it] = (df[it] ?: 0) + 1 } } }
|
||||
n = docs.values.sumOf { it.size }
|
||||
centroids = docs.mapValues { (_, ds) ->
|
||||
val acc = HashMap<String, Double>()
|
||||
ds.forEach { d -> norm(vec(d)).forEach { (k, x) -> acc[k] = (acc[k] ?: 0.0) + x } }
|
||||
norm(acc)
|
||||
}
|
||||
}
|
||||
fun vec(text: String): Map<String, Double> = tokens(text).groupingBy { it }.eachCount().mapValues { (word, count) -> (1 + ln(count.toDouble())) * ln((n + 1.0) / ((df[word] ?: 0) + 1.0)) }
|
||||
private fun norm(v: Map<String, Double>): Map<String, Double> { val s = sqrt(v.values.sumOf { it * it }).takeIf { it > 0 } ?: 1.0; return v.mapValues { it.value / s } }
|
||||
fun similarity(text: String, channel: Int): Double { val c = centroids[channel] ?: return 0.0; return norm(vec(text)).entries.sumOf { (k, x) -> x * (c[k] ?: 0.0) } }
|
||||
fun has(channel: Int) = channel in centroids
|
||||
}
|
||||
|
||||
/** A franchise or series relationship from Emby's own collections: the channels its fellow members are on. Unknown when no collection says. */
|
||||
internal class FranchiseLinks(private val channelsOf: Map<String, Set<Int>>, private val collectionOf: Map<String, String>) {
|
||||
fun channels(id: String): Set<Int>? = collectionOf[id]?.let { channelsOf[it] }
|
||||
fun collection(id: String): String? = collectionOf[id]
|
||||
companion object { val Unknown = FranchiseLinks(emptyMap(), emptyMap()) }
|
||||
}
|
||||
|
||||
private fun foreign(e: TitleEvidence): String? {
|
||||
e.rating?.takeIf { ForeignBoard.containsMatchIn(it) }?.let { return "rated by a non-English-language board ($it)" }
|
||||
val original = e.originalTitle.orEmpty()
|
||||
if (original.any { it.code > 0x24F && it.isLetter() }) return "original title not in English"
|
||||
val countries = e.countries.orEmpty().joinToString(" ")
|
||||
if (countries.isNotEmpty() && !EnglishCountries.containsMatchIn(countries)) return "made in " + countries.split(" ").first()
|
||||
if (original.isNotEmpty() && e.name.isNotEmpty() && original.trim().lowercase() != e.name.trim().lowercase() && !Regex("[A-Za-z]{3}").containsMatchIn(original)) return "original title differs"
|
||||
return null
|
||||
}
|
||||
|
||||
/** One title's fit for one channel, with the evidence for it. Format, identity and age are hard limits, even for a franchise. */
|
||||
internal fun channelScore(e: TitleEvidence, b: Brief, text: ChannelText, links: FranchiseLinks = FranchiseLinks.Unknown): Pair<Double, List<String>> {
|
||||
val ev = mutableListOf<String>(); val g = e.genreSet; val ov = e.overview.orEmpty(); val rating = e.rating.orEmpty(); val ch = b.number
|
||||
val all = ov + " " + e.name + " " + e.taglines.orEmpty().joinToString(" ")
|
||||
if (e.type !in b.kinds) return 0.0 to listOf("wrong kind")
|
||||
if (b.kids && rating in Adult) return 0.0 to listOf("rated $rating")
|
||||
var s = 0.0
|
||||
val animated = "Animation" in g; val doc = "Documentary" in g
|
||||
if (animated && ch !in AnimatedChannels) return 0.0 to listOf("animation, not for this channel")
|
||||
if (!animated && ch in AnimatedChannels && g.isNotEmpty() && ch != 611 && ch != 601) return 0.0 to listOf("live action, not for this channel")
|
||||
if (doc && ch in FictionChannels) return 0.0 to listOf("a documentary, not fiction")
|
||||
if (g.isNotEmpty() && !doc && ch in FactualChannels && ch != 401 && !("Reality" in g && (ch == 203 || ch == 204))) return 0.0 to listOf("fiction, not factual")
|
||||
if (ch == 107 && (doc || "Reality" in g)) return 0.0 to listOf("factual, and Evidence is scripted only")
|
||||
if (LiveSpecial.containsMatchIn(ov) && ch != 401) return 0.0 to listOf("a live or stand-up special")
|
||||
if (g.isNotEmpty()) {
|
||||
if (b.needAny.isNotEmpty() && (g intersect b.needAny).isEmpty()) return 0.0 to listOf("not " + b.needAny.sorted().joinToString("/"))
|
||||
val hits = (g intersect b.genresAny).sorted()
|
||||
s += .25 * minOf(1.0, hits.size / 1.5); ev += "genres " + hits.joinToString(", ").ifEmpty { "none of its" }
|
||||
} else { s += .1; ev += "no genres given (not held against it)" }
|
||||
val pos = b.themes.findAll(all).map { it.value.lowercase() }.toSortedSet().toList()
|
||||
val neg = b.against.findAll(all).map { it.value.lowercase() }.toSortedSet().toList()
|
||||
if (ov.isNotEmpty()) {
|
||||
s += .35 * minOf(1.0, pos.size / 2.0); ev += if (pos.isNotEmpty()) "themes: " + pos.take(4).joinToString(", ") else "no brief themes in its description"
|
||||
if (neg.isNotEmpty()) { s -= .25 * minOf(1.0, neg.size / 2.0); ev += "against: " + neg.take(3).joinToString(", ") }
|
||||
val sim = if (text.has(ch)) text.similarity(ov, ch) else 0.0
|
||||
val short = ov.length < 200
|
||||
if (short && g.isNotEmpty() && (g intersect b.needAny.ifEmpty { b.genresAny }).isNotEmpty() && (pos.isNotEmpty() || b.needAny.isNotEmpty())) {
|
||||
s += .4 * maxOf(minOf(1.0, sim / .25), .6); ev += "short description, matched on genre (%.2f)".format(sim)
|
||||
} else { s += .4 * minOf(1.0, sim / .25); ev += "description like its titles (%.2f)".format(sim) }
|
||||
} else { s += .15; ev += "no description (not held against it)" }
|
||||
e.year?.let { year ->
|
||||
val (lo, hi) = b.era
|
||||
if (year < lo - 3 || year > hi + 3) { if (!b.current) { s *= .3; ev += "$year outside its era $lo-$hi" } }
|
||||
}
|
||||
if (ch == 101 && (g intersect setOf("Game Show", "Reality", "Talk")).isNotEmpty()) return 0.0 to listOf("a game, reality or talk show, not a sitcom")
|
||||
val countries = e.countries.orEmpty().joinToString(" "); val studios = e.studios.orEmpty().joinToString(" ")
|
||||
val british = Uk.containsMatchIn(countries) || UkStudio.containsMatchIn(studios)
|
||||
if (ch == 309 && !british) return 0.0 to listOf(if (e.countries.isNullOrEmpty() && e.studios.isNullOrEmpty()) "British origin not verified" else "not British by its countries or studios")
|
||||
if (b.uk) {
|
||||
if (british) { s += .1; ev += "British" }
|
||||
else if (!e.countries.isNullOrEmpty() || !e.studios.isNullOrEmpty()) { s *= .6; ev += "not British by its studios or countries" }
|
||||
}
|
||||
if (b.kids && rating.isNotEmpty() && rating !in KidsOk) { s *= .5; ev += "rated $rating" }
|
||||
if (b.kids && rating.isEmpty()) { s *= .8; ev += "no rating to confirm it suits children" }
|
||||
if ("Horror" in g && (ch == 304 || ch == 305)) { s += .12; ev += "a horror film" }
|
||||
if ("Horror" in g && ch in setOf(308, 307, 612, 613)) { s *= .5; ev += "horror is not for this channel" }
|
||||
if (b.needAny.isNotEmpty() && (g intersect b.needAny).isNotEmpty() && ov.length < 160) { s += .1; ev += "short description, its defining genre carries more" }
|
||||
if (b.needTheme && pos.isEmpty()) { s *= .5; ev += "none of its themes" }
|
||||
// Paul, 4 November 2026: made-for-TV romances (the Hallmark kind) aren't Orbit material.
|
||||
if ("TV Movie" in g) { s *= .4; ev += "a made-for-TV film" }
|
||||
if (ch == 301 && "Action" in g && !EventFilmWords.containsMatchIn(all)) { s *= .8; ev += "action, but not an event film" }
|
||||
foreign(e)?.let { why -> if (ch !in International) { s *= .5; ev += "$why (this channel is English-language)" } }
|
||||
// A verified franchise link (Emby's own collections) strengthens a channel the title already passes; it never overrides the limits above.
|
||||
links.channels(e.id)?.let { linked -> if (ch in linked && s > 0) { s += .25; ev += "in the ${links.collection(e.id)} collection, already on this channel" } }
|
||||
return maxOf(0.0, s) to ev.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
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>) {
|
||||
/** 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
|
||||
s1 >= .35 && s2 < s1 * .85 -> FitTier.Possible
|
||||
s1 >= .35 -> FitTier.NeedsEye
|
||||
else -> FitTier.None
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
internal fun closeEnough(a: Double, b: Double) = abs(a - b) < 1e-6
|
||||
@@ -1,5 +1,7 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.LocalActivity
|
||||
@@ -500,8 +502,29 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
val works = catalogue.worksOf(poolIds).entries.associate { (id, work) -> work to id }
|
||||
return arrivalsReport(arrivals, released, scheduleStore.loadNewEpisodes(active.accountId), works, scheduleStore.loadBooked(active.accountId), System.currentTimeMillis())
|
||||
}
|
||||
/** The Director's Chair's report: read back if nothing it reads has changed in the last six hours, else worked out afresh. Read only. */
|
||||
fun loadDirectorReport(mode: DirectorMode): DeskReport? {
|
||||
val active = session ?: return null
|
||||
val now = System.currentTimeMillis()
|
||||
val evidence = scheduleStore.evidenceFile(); val collections = scheduleStore.collectionsFile(); val cache = scheduleStore.directorFile()
|
||||
runCatching { JSONObject(cache.readText()) }.getOrNull()?.takeIf { it.optString("mode") == mode.name }?.let { saved ->
|
||||
val report = DeskReport.of(saved)
|
||||
if (now - report.computedMs < 6 * 60 * 60_000L && report.computedMs > evidence.lastModified() && report.computedMs > collections.lastModified()) return report
|
||||
}
|
||||
val today = java.time.LocalDate.now()
|
||||
val released = catalogue.releasedBetween(today.minusDays(NEWLY_RELEASED_DAYS).toString(), today.toString(), 2_000)
|
||||
val pools = SmartChannels.associate { p -> p.number to scheduleStore.loadPool(active.accountId, p.number)?.series.orEmpty().map { it.id } }.filterValues { it.isNotEmpty() }
|
||||
val carriedWorks = catalogue.worksOf(pools.values.flatten()).values.toSet()
|
||||
val (arrivals, _) = scheduleStore.loadArrivals(active.accountId)
|
||||
val newToLibrary = arrivals.count { now - it.firstSeenMs < NEW_TO_LIBRARY_DAYS * 24 * 60 * 60_000L }
|
||||
val report = buildDeskReport(evidence, collections, pools, released, carriedWorks, catalogue.originalTitles(released.map { it.id }), newToLibrary, catalogue.complete(), mode, now)
|
||||
runCatching { cache.writeText(report.toJson().put("mode", mode.name).toString()) }
|
||||
android.util.Log.i("OrbitSchedule", "director mode=${mode.name} newlyReleased=${report.newlyReleased} read=${report.scanned} strong=${report.strong} picks=${report.picks.size} asks=${report.asks.size} tookMs=${report.tookMs}")
|
||||
return report
|
||||
}
|
||||
SettingsScreen(settingsSection, { settingsSection = it }, modifier, busy, message, HttpsEmbyTransport.SERVER, sample?.total,
|
||||
arrivals = { detail, first -> NewArrivalsDetail(::loadArrivalsReport, first, detail) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
|
||||
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) },
|
||||
onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on ->
|
||||
val tots = scheduledChannels[TOTS_NUMBER]
|
||||
val active = session
|
||||
|
||||
@@ -252,6 +252,25 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
|
||||
return (0 until (rows?.length() ?: 0)).mapNotNull { rows!!.optJSONObject(it)?.let(::evidenceLine) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby's own collections (BoxSets), read only, for verified franchise and series relationships: a page of collections,
|
||||
* each with its members' IDs. A relationship no collection supplies stays unknown; nothing is inferred from titles.
|
||||
*/
|
||||
fun readCollections(start: Int, limit: Int): Pair<List<JSONObject>, Int> {
|
||||
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&IncludeItemTypes=BoxSet&Recursive=true&StartIndex=$start&Limit=$limit&Fields=ProviderIds" +
|
||||
"&SortBy=SortName&SortOrder=Ascending&EnableImages=false&EnableUserData=false", token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS))
|
||||
EmbyClient.expectSuccess(reply.status, false)
|
||||
val body = EmbyClient.parse(reply.body)
|
||||
val rows = body.optJSONArray("Items")
|
||||
val sets = (0 until (rows?.length() ?: 0)).mapNotNull { rows!!.optJSONObject(it) }.filter { EmbyClient.validId(it.optString("Id")) }
|
||||
return sets.map { set ->
|
||||
val members = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&ParentId=${set.optString("Id")}&Limit=300&Fields=ProviderIds&EnableImages=false&EnableUserData=false",
|
||||
token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS)).let { r -> EmbyClient.expectSuccess(r.status, false); EmbyClient.parse(r.body).optJSONArray("Items") }
|
||||
JSONObject().put("id", set.optString("Id")).put("name", EmbyClient.clean(set.optString("Name"), 120))
|
||||
.put("members", org.json.JSONArray((0 until (members?.length() ?: 0)).mapNotNull { members!!.optJSONObject(it)?.optString("Id")?.takeIf(EmbyClient::validId) }))
|
||||
} to body.optInt("TotalRecordCount", sets.size)
|
||||
}
|
||||
|
||||
internal class PoolRead(val series: List<PoolSeries>, val requests: Int,
|
||||
/** Shows whose read failed this time and kept their copy from the previous pool instead. */
|
||||
val carried: Int = 0)
|
||||
|
||||
@@ -19,6 +19,8 @@ internal object EvidenceRequest {
|
||||
|
||||
internal const val EVIDENCE_BATCH = 40
|
||||
internal const val EVIDENCE_MAX_PER_RUN = 4_000
|
||||
internal const val COLLECTIONS_PAGE = 50
|
||||
internal const val COLLECTIONS_MAX_PER_RUN = 1_000
|
||||
|
||||
/** One item's evidence as kept: only what the provider gave, trimmed, with people limited to leads and directors. */
|
||||
internal fun evidenceLine(row: JSONObject): JSONObject? {
|
||||
|
||||
@@ -50,6 +50,10 @@ internal class ScheduleStore(private val directory: File) {
|
||||
private fun file(kind: String, number: Int) = File(directory, "$kind-$number.json")
|
||||
/** Stage D's evidence lines ([evidenceLine]), one item a line. */
|
||||
fun evidenceFile() = File(directory, "evidence-0.jsonl")
|
||||
/** Emby's collections, one a line ([EmbySampleSession.readCollections]), and how far the read got. */
|
||||
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")
|
||||
|
||||
/** Replaces in one step, keeping the last good copy. An interrupted write can't damage either. */
|
||||
private fun write(target: File, root: JSONObject) {
|
||||
|
||||
@@ -77,6 +77,7 @@ internal enum class SettingsSection(val label: String, val icon: Int) {
|
||||
Channels("Channels", R.drawable.orbit_icon_all),
|
||||
Viewing("Watching", R.drawable.orbit_icon_entertainment),
|
||||
Arrivals("New arrivals", R.drawable.orbit_icon_movies),
|
||||
Director("Director's Chair", R.drawable.orbit_icon_entertainment),
|
||||
Account("Emby account", R.drawable.orbit_icon_profile),
|
||||
}
|
||||
|
||||
@@ -227,6 +228,7 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit,
|
||||
@Composable
|
||||
internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSection) -> Unit, modifier: Modifier, busy: Boolean, message: String?, server: String, titles: Long?,
|
||||
arrivals: @Composable (Modifier, FocusRequester) -> Unit,
|
||||
director: @Composable (Modifier, FocusRequester) -> Unit,
|
||||
isOn: (Int) -> Boolean, onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, onSignOut: () -> Unit,
|
||||
windingDown: Boolean, onWindDown: (Boolean) -> Unit) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
@@ -250,6 +252,7 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio
|
||||
SettingsSection.Channels -> ChannelsDetail(busy, message, isOn, onSwitch, onSuggestions, firstSwitch, channelList, detail)
|
||||
SettingsSection.Viewing -> ViewingDetail(isOn(TOTS_NUMBER), windingDown, onWindDown, firstSwitch, detail)
|
||||
SettingsSection.Arrivals -> arrivals(detail, firstSwitch)
|
||||
SettingsSection.Director -> director(detail, firstSwitch)
|
||||
SettingsSection.Account -> AccountDetail(server, titles, onSignOut, firstSwitch, detail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/** The Director's Chair: read only, counts and a few picks, franchise links only from Emby's collections, and scan gaps shown. */
|
||||
class DirectorChairTest {
|
||||
private fun line(id: String, name: String, type: String, genres: List<String>?, overview: String?, rating: String? = null, countries: List<String>? = null, studios: List<String>? = null) =
|
||||
JSONObject().put("id", id).put("type", type).put("name", name).put("year", 2026).put("overview", overview ?: JSONObject.NULL)
|
||||
.put("genres", genres?.let(::JSONArray) ?: JSONObject.NULL).put("studios", studios?.let(::JSONArray) ?: JSONObject.NULL)
|
||||
.put("rating", rating ?: JSONObject.NULL).put("countries", countries?.let(::JSONArray) ?: JSONObject.NULL).put("taglines", JSONObject.NULL)
|
||||
private val channelTitles = listOf(
|
||||
line("e1", "Inspector Show", "Series", listOf("Crime", "Drama"), "A detective inspector and his sergeant investigate murders in a quiet town, with police procedure and forensic work."),
|
||||
line("d1", "True Crime Show", "Series", listOf("Documentary", "Crime"), "A true crime documentary series about unsolved murders and missing people, with interviews and archive footage."),
|
||||
line("t1", "Nature Show", "Series", listOf("Documentary"), "Wildlife and nature across the planet: oceans, reefs, forests and the animals and species that live there."))
|
||||
private val pools = mapOf(107 to listOf("e1"), 204 to listOf("d1"), 201 to listOf("t1"))
|
||||
|
||||
private fun files(candidates: List<JSONObject>, collections: List<JSONObject> = emptyList()): Pair<File, File> {
|
||||
val evidence = File.createTempFile("evidence", ".jsonl").apply { deleteOnExit(); writeText((channelTitles + candidates).joinToString("") { "$it\n" }) }
|
||||
val sets = File.createTempFile("collections", ".jsonl").apply { delete(); deleteOnExit(); if (collections.isNotEmpty()) writeText(collections.joinToString("") { "$it\n" }) }
|
||||
return evidence to sets
|
||||
}
|
||||
private fun arrival(id: String, name: String) = Arrival(id, "Series", name, 2026, "2026-10-30", "tmdb:$id", 0)
|
||||
|
||||
@Test fun countsAFewPicksAndNoDecisions() {
|
||||
val cands = listOf(line("c1", "Ocean Life", "Series", listOf("Documentary"), "Wildlife of the ocean: reefs, sharks and the species of the deep sea."),
|
||||
line("c2", "Missing in Maine", "Series", listOf("Documentary", "Crime"), "A true crime documentary about a disappearance and an unsolved murder case."),
|
||||
line("c3", "Stand-up Night", "Movie", listOf("Comedy"), "A comedian takes aim at modern life in his debut special."))
|
||||
val (ev, sets) = files(cands)
|
||||
val report = buildDeskReport(ev, sets, pools, listOf(arrival("c1", "Ocean Life"), arrival("c2", "Missing in Maine"), arrival("c3", "Stand-up Night"), arrival("c4", "Not read yet")),
|
||||
emptySet(), emptyMap(), 2, true, DirectorMode.Balanced, 1_793_800_000_000)
|
||||
assertEquals(4, report.newlyReleased)
|
||||
assertEquals(3, report.scanned)
|
||||
assertEquals(1, report.unread) // shown, never hidden
|
||||
assertTrue(report.picks.size <= DirectorMode.Balanced.picks)
|
||||
assertEquals(report.picks.map { it.channel }.distinct().size, report.picks.size) // one per channel
|
||||
assertTrue(report.picks.none { it.title == "Stand-up Night" })
|
||||
assertEquals(0, report.collections)
|
||||
}
|
||||
|
||||
@Test fun titlesAlreadyOnAChannelAreLeftOut() {
|
||||
val (ev, sets) = files(listOf(line("c1", "Ocean Life", "Series", listOf("Documentary"), "Wildlife of the ocean.")))
|
||||
val report = buildDeskReport(ev, sets, pools + (201 to listOf("t1", "c1")), listOf(arrival("c1", "Ocean Life"), Arrival("x2", "Series", "Other copy", 2026, "2026-10-30", "tmdb:c1", 0)),
|
||||
setOf("tmdb:c1"), emptyMap(), 0, true, DirectorMode.Balanced, 1_793_800_000_000)
|
||||
assertEquals(0, report.newlyReleased)
|
||||
}
|
||||
|
||||
@Test fun franchiseLinksComeOnlyFromCollectionsAndNeverBreakTheLimits() {
|
||||
val spinoff = line("c5", "Inspector Show: New York", "Series", listOf("Crime", "Drama"), "A new team.")
|
||||
val docOfIt = line("c6", "Inside Inspector Show", "Series", listOf("Documentary"), "The making of the show.")
|
||||
val (ev, sets) = files(listOf(spinoff, docOfIt), listOf(JSONObject().put("id", "b1").put("name", "Inspector Show Collection").put("members", JSONArray(listOf("e1", "c5", "c6")))))
|
||||
val (links, count) = franchiseLinks(sets, pools)
|
||||
assertEquals(1, count)
|
||||
assertEquals(setOf(107), links.channels("c5"))
|
||||
assertNull(links.channels("unlisted")) // unknown, never guessed from the title
|
||||
val evidence = (channelTitles + listOf(spinoff, docOfIt)).associate { it.getString("id") to TitleEvidence.of(it) }
|
||||
val text = ChannelText(pools, evidence)
|
||||
val with = channelScore(evidence.getValue("c5"), Briefs.first { it.number == 107 }, text, links)
|
||||
val without = channelScore(evidence.getValue("c5"), Briefs.first { it.number == 107 }, text)
|
||||
assertTrue(with.first > without.first)
|
||||
assertTrue(with.second.any { "collection" in it })
|
||||
// The documentary is in the same collection, but Evidence is scripted only: the link can't put it there.
|
||||
assertEquals(0.0, channelScore(evidence.getValue("c6"), Briefs.first { it.number == 107 }, text, links).first, 0.0)
|
||||
assertTrue(ev.exists())
|
||||
}
|
||||
|
||||
@Test fun britxNeedsVerifiedBritishOriginAndEvidenceIsNotLegalDrama() {
|
||||
val evidence = mapOf("b" to TitleEvidence.of(line("b", "Gangland", "Movie", listOf("Crime"), "A London gangster plans one last heist.")),
|
||||
"u" to TitleEvidence.of(line("u", "Gangland UK", "Movie", listOf("Crime"), "A London gangster plans one last heist.", countries = listOf("United Kingdom"))),
|
||||
"l" to TitleEvidence.of(line("l", "The Firm", "Series", listOf("Drama", "Crime"), "Divorce lawyers at a top law firm fight a courtroom battle.")))
|
||||
val text = ChannelText(emptyMap(), evidence)
|
||||
val britx = Briefs.first { it.number == 309 }
|
||||
assertEquals(0.0, channelScore(evidence.getValue("b"), britx, text).first, 0.0)
|
||||
assertTrue(channelScore(evidence.getValue("b"), britx, text).second.single().contains("not verified"))
|
||||
assertTrue(channelScore(evidence.getValue("u"), britx, text).first > 0)
|
||||
assertTrue(channelScore(evidence.getValue("l"), Briefs.first { it.number == 107 }, text).first < .35)
|
||||
}
|
||||
|
||||
@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, 5, 6)
|
||||
val back = DeskReport.of(r.toJson())
|
||||
assertEquals(r.picks.single().title, back.picks.single().title); assertEquals("Horror", back.asks.single().second); assertFalse(back.catalogueComplete); assertEquals(1, back.unread)
|
||||
assertEquals("A detective returns home.", sentence("A detective returns home. Then more happens."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The Kotlin scoring against the Python reference on the same saved inputs, run by hand:
|
||||
* ORBIT_DIRECTOR_FIXTURE=<folder with evidence-0.jsonl, episodes-*.json, candidates.json> ./gradlew :app:testDebugUnitTest --tests '*DirectorParityTest*'
|
||||
* Writes kotlin-placements.json there, to compare with python-reference.json (docs/replays/stage-d-parity.py), and the time taken.
|
||||
*/
|
||||
class DirectorParityTest {
|
||||
private val dir = System.getenv("ORBIT_DIRECTOR_FIXTURE")?.let(::File)
|
||||
|
||||
@Test fun sameInputsSamePlacements() {
|
||||
assumeTrue(dir != null && File(dir, "candidates.json").exists())
|
||||
val started = System.nanoTime()
|
||||
val evidence = File(dir, "evidence-0.jsonl").readLines().map { JSONObject(it) }
|
||||
val input = JSONObject(File(dir, "candidates.json").readText())
|
||||
val originals = input.getJSONObject("originals")
|
||||
val byId = evidence.associate { it.getString("id") to TitleEvidence.of(it, originals.optString(it.getString("id")).takeIf { o -> o.isNotEmpty() }) }
|
||||
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, byId)
|
||||
val built = (System.nanoTime() - started) / 1_000_000
|
||||
val ids = input.getJSONArray("candidates").let { a -> (0 until a.length()).map { a.getString(it) } }
|
||||
val out = JSONArray()
|
||||
ids.forEach { id -> val p = placeTitle(byId.getValue(id), text); out.put(JSONObject().put("id", id).put("top", p.top.name).put("s1", p.s1).put("second", p.second.name).put("s2", p.s2).put("tier", p.tier.name)) }
|
||||
val total = (System.nanoTime() - started) / 1_000_000
|
||||
File(dir, "kotlin-placements.json").writeText(out.toString())
|
||||
File(dir, "kotlin-timing.txt").writeText("profiles ${built}ms, all ${ids.size} titles ${total}ms, evidence ${evidence.size}\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# 0.108.5: Stage D scoring in the app and Settings › Director's Chair (read only), built on the installed 0.108.4, NOT installed
|
||||
|
||||
Paul approved this on 4 November 2026 at 23:49: the BritX, Evidence, Orbit 1 and Romance corrections; a read-only Emby collections read; the scoring ported to Kotlin; and Settings › Director's Chair, read only. **Building and verification only, not installation.** No approval buttons, no automatic placement, and no pool or schedule change. Auto-approval is off.
|
||||
|
||||
## The scoring corrections, on genuinely unseen samples
|
||||
|
||||
- **BritX:** British origin must be verified, from the production countries or a British studio. Unverified means it isn't a BritX title.
|
||||
- **Evidence:** police and forensic procedurals only. It needs police, detective or forensic themes; lawyers, law firms, solicitors, courtrooms and divorce count against it; and anything factual is ruled out.
|
||||
- **Orbit 1:** needs family-sitcom themes. Game, reality and talk shows are ruled out.
|
||||
- **Romance:** romance must be the main theme. Westerns, war films and outlaws count against it.
|
||||
|
||||
**Fresh sample 5:** 60 titles never seen before, labelled before scoring (`docs/replays/stage-d-regression-sample-5.json`); 11 clear homes, 14 either-way, 35 none.
|
||||
|
||||
| Model | Suggestions | Precision | Recall of clear fits | Titles with no home placed anyway |
|
||||
|---|---|---|---|---|
|
||||
| Genre only (trial 1) | 22 | 32% | 5 of 11 | 11 of 35 |
|
||||
| **This release, Strong tier** | 11 | **91%** | **8 of 11 (73%)** | **1 of 35** |
|
||||
| This release, Strong plus Possible | 15 | 80% | 8 of 11 | 3 of 35 |
|
||||
|
||||
**Regressions, samples 1 to 4 (Strong tier):** 92%, 100%, 92% and 89% precision, with at most 1 title with no home placed anyway in any sample.
|
||||
|
||||
**The trend on unseen samples:** 60% (trial 3) → 76% (trial 4) → 91% (this release) precision, and 50% → 61% → 73% recall.
|
||||
|
||||
The labels are my editorial judgement and are kept for Paul to check. Evaluate with `docs/replays/stage-d-evaluate.py`.
|
||||
|
||||
## The Kotlin port and its parity with Python
|
||||
|
||||
- `DirectorScoring.kt` holds the same briefs, rules and local TF-IDF similarity as `docs/replays/stage-d-trial-5.py`.
|
||||
- **Parity:** `DirectorParityTest` runs the Kotlin scoring on the same saved inputs: the 2,431 evidence lines, the box's pools and the original titles. **All 392 candidate titles get the same best and second channel, with scores equal to within 0.01: 0 differences** (`docs/replays/stage-d-parity.py`, on both the main build and the release build).
|
||||
- **Speed on this computer (JVM):** channel profiles built in about 0.5 s, all 392 titles scored in about 0.9 s. **On the Xiaomi box it hasn't been measured, because that needs the install.** The Chair logs its own `tookMs` each time it works the report out, so the first open after install gives the real figure.
|
||||
|
||||
## Franchise links: Emby's collections only
|
||||
|
||||
- **Where they come from:** the evidence read (`--ez orbit_read_evidence true`) now also reads Emby's own collections (BoxSets) and each one's members. It's read only and resumable, 50 collections a request and at most 1,000 a run, into `collections-0.jsonl`.
|
||||
- **How they're used:** a title in a collection whose other members are on a channel gets a **verified** link to that channel. The link only strengthens a channel the title already passes. Format, identity, age and British origin stay hard limits, so a documentary in an Evidence collection still can't go on Evidence (tested).
|
||||
- **When there's no link:** a title in no collection is **unknown**. Nothing is inferred from similar titles.
|
||||
- **When nothing has been read:** with no collections read yet, the Chair says so ("no collections read, so franchises unknown").
|
||||
|
||||
## Settings › Director's Chair (read only, as approved)
|
||||
|
||||
**What it shows:**
|
||||
- **Mode:** Familiar, Balanced (the default) and Surprise Me. Each mode only changes how many discoveries are shown, 3, 4 or 5, and Surprise Me reaches into the possibles for channels the strong matches missed.
|
||||
- **Auto-approval: OFF**, always shown.
|
||||
- **This week:**
|
||||
- newly released titles not already on a channel (by work, whichever copy);
|
||||
- new to the library in the last 14 days, from Stage C, kept separate;
|
||||
- the strong matches **as counts per channel**;
|
||||
- "Held as proposals. Nothing is booked."
|
||||
- **Scan status, always visible:** titles not read yet, collections not read, or the library list still importing. Each gap is named, not hidden, and missing metadata is said in the evidence ("no description, not held against it").
|
||||
- **Worth a look:** the strongest strong match per channel, up to the mode's count, each with its tag (Just released, Strong match, Something different), its channel and the first line of its own description.
|
||||
- **Needs your eye:** up to three genuine either-or calls, such as "Terra or Odyssey?".
|
||||
- **Rules in force:** Paul's standing calls. The Chair's own decisions come later, with approval.
|
||||
|
||||
**How it stays light on the box:** the report is worked out off the main thread, saved to `director-0.json`, and read back for six hours unless the evidence or collections change. The screen itself only lays out counts and a few cards.
|
||||
|
||||
## Checks
|
||||
|
||||
- **Tests:** 495 unit tests run, 0 failures; lint has no errors. New tests:
|
||||
- `DirectorChairTest` (5): counts, a few picks one per channel, no decisions; titles already on a channel left out; franchise links only from collections and never breaking the limits; BritX verified origin and Evidence not legal drama; the report saved and read back.
|
||||
- `DirectorParityTest` (hand-run, against Python).
|
||||
- `EvidenceReaderTest` (3, from 0.108.4).
|
||||
- **Schedules untouched:** the four-week replay from the box's files is **identical on all 30 channels** to 0.108.4. Nothing in this release books, places or changes a pool. The network schedule, Drive-In, seasonal events and presentation are unchanged.
|
||||
|
||||
APK: `outputs/revision-0.108.5`, signed, versionCode 165. **Not installed.**
|
||||
|
||||
**After install, with Paul's approval:**
|
||||
1. Run the evidence read once more, to fetch the collections.
|
||||
2. Open Settings › Director's Chair, and read its `director ... tookMs=` log line for the real on-box speed.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare the Kotlin scoring (DirectorParityTest's kotlin-placements.json) with the Python reference on the same saved inputs."""
|
||||
import json,sys
|
||||
ref={o['id']:o for o in json.load(open(sys.argv[1]))}; kt={o['id']:o for o in json.load(open(sys.argv[2]))}
|
||||
diff=[i for i in ref if ref[i]['top']!=kt[i]['top'] or ref[i]['second']!=kt[i]['second'] or abs(ref[i]['s1']-round(kt[i]['s1'],2))>0.011 or abs(ref[i]['s2']-round(kt[i]['s2'],2))>0.011]
|
||||
print('titles',len(ref),'differences',len(diff))
|
||||
for i in diff: print(ref[i],kt[i])
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user