diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 8fbc482..e3ba891 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -121,15 +121,19 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl val zone = java.time.ZoneId.systemDefault() val today = java.time.Instant.ofEpochMilli(now).atZone(zone).toLocalDate() val file = store.eventIdeasFile(); val historyFile = store.eventIdeaHistoryFile() - if (runCatching { JSONObject(file.readText()).optString("day") }.getOrNull() == today.toString()) return + if (runCatching { JSONObject(file.readText()).let { it.optString("day") == today.toString() && it.optInt("rules") == EVENT_IDEA_RULES } }.getOrNull() == true) return + EventIdeaHistory.withdrawOld(historyFile) val weekStart = today.with(java.time.DayOfWeek.MONDAY).atStartOfDay(zone).toInstant().toEpochMilli() - val shownThisWeek = EventIdeaHistory.load(historyFile).count { it.optLong("shownMs") >= weekStart } + fun live(h: JSONObject) = h.optLong("shownMs") >= weekStart && !h.optString("decision").startsWith("withdrawn") + val shownThisWeek = EventIdeaHistory.load(historyFile).count(::live) val evidenceFile = store.evidenceFile() val lines = if (evidenceFile.exists()) evidenceFile.readLines().mapNotNull { runCatching { JSONObject(it) }.getOrNull() }.filter { it.optString("type") == "Movie" }.associateBy { it.optString("id") }.toMutableMap() else mutableMapOf() + val notable = listMembers(store.collectionsFile(), TrendingLists) fun enrich(f: IdeaFilm): IdeaFilm = lines[f.id]?.let { e -> fun names(key: String) = e.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() - IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, names("directors"), names("leads"), if (e.isNull("community")) null else e.optDouble("community").takeIf { !it.isNaN() }) - } ?: f + IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, names("directors"), names("leads"), if (e.isNull("community")) null else e.optDouble("community").takeIf { !it.isNaN() }, + names("countries"), f.id in notable) + } ?: IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, notable = f.id in notable) val collections = if (store.collectionsFile().exists()) store.collectionsFile().readLines().mapNotNull { runCatching { JSONObject(it) }.getOrNull() }.map { o -> o.optString("name") to o.optJSONArray("members")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty() } else emptyList() val studios = EventStudios.associateWith { catalogue.filmsByStudio(it.first) } @@ -160,8 +164,8 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl val ideas = weeklyEventIdeas(verifiedSeeds, films, unavailable, { format, taken -> ideaEvening(format, today.plusDays(1), drivePlanEnd, taken, format.films * 2 * 60 * 60_000L, zone) }, EventIdeaHistory.dismissed(historyFile), limit = (EVENT_IDEAS_PER_WEEK - shownThisWeek).coerceAtLeast(0)) EventIdeaHistory.record(historyFile, ideas, now) - val thisWeek = EventIdeaHistory.load(historyFile).filter { it.optLong("shownMs") >= weekStart } - runCatching { file.writeText(JSONObject().put("day", today.toString()).put("ideas", JSONArray(thisWeek.map { h -> + val thisWeek = EventIdeaHistory.load(historyFile).filter(::live) + runCatching { file.writeText(JSONObject().put("day", today.toString()).put("rules", EVENT_IDEA_RULES).put("ideas", JSONArray(thisWeek.map { h -> val ids = h.getJSONArray("films").let { a -> (0 until a.length()).map { a.getString(it) } } JSONObject(h.toString()).put("details", JSONArray(ids.mapNotNull { films[it] ?: read.firstOrNull { r -> r.id == it } }.map { f -> JSONObject().put("title", f.title).put("year", f.year ?: JSONObject.NULL).put("community", f.community ?: JSONObject.NULL).put("rating", f.rating) })) diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt b/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt index 9141813..120878c 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt @@ -88,15 +88,15 @@ internal val GenreSynonyms = mapOf("Anime" to "Animation", "Martial Arts" to "Ac "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") +internal 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) /** Mainstream audience ratings: a horror-comedy rated these (or for children) keeps its usual channels. */ private val MainstreamRatings = setOf("PG-13", "12", "12A", "TV-14", "GB-12", "GB-12A", "GB-15", "15") /** Paul, 5 November 2026: Ring X is professional wrestling. A verified promotion, by studio, counts for it; MMA events go on no channel. */ -private val WrestlingPromotion = Regex("\\b(WWE|World Wrestling|All Elite Wrestling|AEW|TNA|Total Nonstop|Impact Wrestling|New Japan|NJPW|Ring of Honor|ROH|NWA|WCW|ECW|Lucha|Stardom|Game Changer Wrestling)\\b", RegexOption.IGNORE_CASE) -private val MmaEvent = Regex("\\b(UFC|MMA|mixed martial|Bellator|PFL|Ultimate Fighting|cage (fight|bout)|octagon)", RegexOption.IGNORE_CASE) +internal val WrestlingPromotion = Regex("\\b(WWE|World Wrestling|All Elite Wrestling|AEW|TNA|Total Nonstop|Impact Wrestling|New Japan|NJPW|Ring of Honor|ROH|NWA|WCW|ECW|Lucha|Stardom|Game Changer Wrestling)\\b", RegexOption.IGNORE_CASE) +internal val MmaEvent = Regex("\\b(UFC|MMA|mixed martial|Bellator|PFL|Ultimate Fighting|cage (fight|bout)|octagon)", RegexOption.IGNORE_CASE) private val HorrorComedy = Regex("\\b(body horror|body-horror|gross-out|splatter|horror-comedy|horror comedy)", 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) +internal 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) diff --git a/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt b/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt index b7895d3..5b3d551 100644 --- a/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt +++ b/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt @@ -22,19 +22,61 @@ import java.time.ZoneId */ internal const val EVENT_IDEAS_PER_WEEK = 2 /** An idea needs this average viewer rating across the films that have one, to be worth an evening. */ -internal const val EVENT_IDEA_BAR = 6.4 +internal const val EVENT_IDEA_BAR = 6.8 +/** No film on a bill below this rating, however strong the rest. */ +internal const val EVENT_IDEA_FLOOR = 6.2 +/** The idea rules' version: ideas shown under older rules are withdrawn (and kept in the history as such) when the rules change. */ +internal const val EVENT_IDEA_RULES = 3 /** How many candidate films' evidence the idea step may read in a day, read only. */ internal const val EVENT_IDEA_READS_PER_DAY = 400 -internal enum class IdeaFormat(val label: String, val hour: Int, val films: Int, val days: Set) { - SaturdayTriple("Saturday triple bill", 19, 3, setOf(DayOfWeek.SATURDAY)), - FridayDouble("Friday double bill", 20, 2, setOf(DayOfWeek.FRIDAY)), - LateShow("The Late Show double", 23, 2, setOf(DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY)) +/** Each format's evening, its number of films and the longest it may run in all (a Late Show ends by about 3am). */ +internal enum class IdeaFormat(val label: String, val hour: Int, val films: Int, val days: Set, val maxMinutes: Long) { + SaturdayTriple("Saturday triple bill", 19, 3, setOf(DayOfWeek.SATURDAY), 360), + FridayDouble("Friday double bill", 20, 2, setOf(DayOfWeek.FRIDAY), 270), + LateShow("The Late Show double", 23, 2, setOf(DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY), 240) +} + +/** A film's place in a numbered series ("Part 2", "Vol. 1", "II"): its series and number, or null for a stand-alone title. */ +internal fun seriesPart(title: String): Pair? { + val m = Regex("^(.*?)[\\s:,-]*(?:Part|Vol\\.?|Volume|Chapter)\\s*(\\d+|I{1,3}|IV|V)\\b", RegexOption.IGNORE_CASE).find(title) + ?: Regex("^(.*?)\\s+(\\d|II|III|IV|V)$").find(title) ?: return null + val n = when (val p = m.groupValues[2].uppercase()) { "I" -> 1; "II" -> 2; "III" -> 3; "IV" -> 4; "V" -> 5; else -> p.toIntOrNull() ?: return null } + return seriesKey(m.groupValues[1]) to n +} + +/** + * The films a bill takes from [ranked] (best first): never a later part without every earlier part of its series alongside it, + * at most [format]'s number of films and running time, shown in story order (by year, then part). + */ +internal fun pickFilms(ranked: List, format: IdeaFormat): List { + val parts = ranked.associateWith { seriesPart(it.title) } + val chosen = mutableListOf() + for (f in ranked) { + if (chosen.size >= format.films) break + val part = parts[f] + // A later part comes with its earlier parts, or not at all. + val needed = if (part == null || part.second <= 1) listOf(f) else { + // Part 1 is often just the title ("Haunt" before "Haunt II"). + val earlier = (1 until part.second).map { n -> ranked.firstOrNull { parts[it] == part.first to n || (n == 1 && parts[it] == null && seriesKey(it.title) == part.first) } } + if (earlier.any { it == null }) continue + earlier.filterNotNull() + f + } + val adding = needed.filter { it !in chosen } + if (chosen.size + adding.size > format.films) continue + if ((chosen + adding).sumOf { (it.runtimeMs ?: 0L) / 60_000 } > format.maxMinutes) continue + chosen += adding + } + return chosen.sortedWith(compareBy { it.year ?: 9999 }.thenBy { parts[it]?.second ?: 0 }) } /** A film an idea can use, from the catalogue and (where read) its evidence. */ internal class IdeaFilm(val id: String, val title: String, val year: Int?, val imdb: String?, val runtimeMs: Long?, val rating: String, val genres: Set, - val studios: List, val directors: List = emptyList(), val leads: List = emptyList(), val community: Double? = null) + val studios: List, val directors: List = emptyList(), val leads: List = emptyList(), val community: Double? = null, + /** Production countries, where read: a person's night is English-language unless a studio or franchise makes it an occasion. */ + val countries: List = emptyList(), + /** On the library's acclaim or trending lists (IMDB Top 250, Letterboxd Top 500, Trending): what makes a name an occasion. */ + val notable: Boolean = false) internal class EventIdea(val key: String, val theme: String, val source: String, val format: IdeaFormat, val evening: LocalDateTime?, val films: List, val reasons: List, val score: Double) { @@ -62,8 +104,8 @@ internal val EventStudios: List> = listOf( internal class IdeaSeed(val key: String, val theme: String, val source: String, val members: List, val sharedGenre: String? = null, val chronological: Boolean = false) /** - * The seeds a theme can come from: franchise collections, the studios above, and directors and leading performers with three or - * more films whose evidence has been read. A seed that touches an existing Drive-In event's films is left out: that occasion + * The seeds a theme can come from: franchise collections, the studios above, and directors and leading performers with four or + * more films whose evidence has been read. Collections arrive in story order, as the library lists them. A seed that touches an existing Drive-In event's films is left out: that occasion * has been had. Curated lists are never seeds. */ internal fun ideaSeeds(collections: List>>, studios: Map, List>, read: List, eventFilms: Set): List { @@ -72,21 +114,28 @@ internal fun ideaSeeds(collections: List>>, studios: M seeds += IdeaSeed("franchise:$name", name.removeSuffix(" Collection").trim() + " Night", "franchise", members, chronological = true) } studios.forEach { (studio, members) -> seeds += IdeaSeed("studio:${studio.first}", studio.second, "studio", members, sharedGenre = studio.third) } - read.flatMap { f -> f.directors.take(2).map { it to f.id } }.groupBy({ it.first }, { it.second }).filterValues { it.size >= 3 }.forEach { (who, ids) -> + // A person's night uses their stand-alone films and the first of any franchise: a later part belongs to its franchise's night. + val laterParts = collections.filter { FranchiseCollection.containsMatchIn(it.first) }.flatMap { (_, members) -> members.drop(1) }.toSet() + val own = read.filter { it.id !in laterParts } + own.flatMap { f -> f.directors.take(2).map { it to f.id } }.groupBy({ it.first }, { it.second }).filterValues { it.size >= 4 }.forEach { (who, ids) -> seeds += IdeaSeed("director:$who", "A $who Night", "director", ids) } - read.mapNotNull { f -> f.leads.firstOrNull()?.let { it to f.id } }.groupBy({ it.first }, { it.second }).filterValues { it.size >= 3 }.forEach { (who, ids) -> + own.mapNotNull { f -> f.leads.firstOrNull()?.let { it to f.id } }.groupBy({ it.first }, { it.second }).filterValues { it.size >= 4 }.forEach { (who, ids) -> seeds += IdeaSeed("performer:$who", "$who Night", "performer", ids) } return seeds.filter { seed -> seed.members.none { it in eventFilms } } } -private fun adult(f: IdeaFilm) = f.rating in Adult || "Horror" in f.genres +/** Adult: an adult rating, or horror not rated for families and mainstream audiences. Adult films only ever go late. */ +private fun adult(f: IdeaFilm) = f.rating in Adult || ("Horror" in f.genres && f.rating !in KidsOk && f.rating !in setOf("PG-13", "12", "12A", "TV-14", "GB-12", "GB-12A")) private fun family(f: IdeaFilm) = f.rating in KidsOk && ("Family" in f.genres || "Animation" in f.genres) /** Whether a film suits the Drive-In at all: a feature film, verified identity, not a documentary, special or TV movie. */ internal fun ideaSuitable(f: IdeaFilm): String? = when { f.imdb.isNullOrBlank() -> "no IMDb ID to verify it by" + // Wrestling and fight cards are Ring X's, and concerts and stand-up specials aren't a night at the pictures. + "Sport" in f.genres || WrestlingPromotion.containsMatchIn(f.title + " " + f.studios.joinToString(" ")) || MmaEvent.containsMatchIn(f.title) -> "a sport event" + LiveSpecial.containsMatchIn(f.title) || Regex("\\b(Live|Concert|Tour|One Night in)\\b").containsMatchIn(f.title) || "Music" in f.genres -> "a concert or live special" f.runtimeMs == null || f.runtimeMs < 70 * 60_000L || f.runtimeMs > 200 * 60_000L -> "not a feature length" "Documentary" in f.genres || "TV Movie" in f.genres -> "a documentary or TV movie" else -> null @@ -108,15 +157,34 @@ internal fun billFor(seed: IdeaSeed, films: Map, unavailable: ?: group.flatMap { it.genres }.filter { it != "Drama" }.groupingBy { it }.eachCount().filterValues { it >= 2 }.maxByOrNull { it.value }?.key ?: continue val pool = group.filter { genre in it.genres } val format = if (format0 == IdeaFormat.FridayDouble && pool.size >= 3) IdeaFormat.SaturdayTriple else format0 - val ranked = pool.sortedWith(compareByDescending { it.community ?: 0.0 }.thenBy { it.year ?: 9999 }).take(format.films) - if (ranked.size < 2) continue - val chosen = if (seed.chronological) ranked.sortedBy { it.year ?: 9999 } else ranked + val person = seed.source == "director" || seed.source == "performer" + // English-language, by its read production countries: unknown isn't enough. + val english = { f: IdeaFilm -> f.countries.isNotEmpty() && EnglishCountries.containsMatchIn(f.countries.joinToString(" ")) } + // A person's night needs films the audience knows them by: animation is themed by its franchise or studio instead, + // and the night is English-language (a studio or franchise makes a foreign-language occasion). + if (person && pool.count { "Animation" in it.genres } * 2 >= pool.size) { why = "animation is themed by franchise or studio"; continue } + // English-language, unless a studio makes the occasion (Toho, Shaw Brothers) or the films are on the acclaim lists. + val people = (if (seed.source == "studio") pool else pool.filter { f -> english(f) || f.notable }) + // A person's night is their best work: every film 7.0 or more. Any other bill: none below the floor. + .filter { (it.community ?: 10.0) >= if (person) 7.0 else EVENT_IDEA_FLOOR } + // A short franchise is a story: its night starts at the beginning. A long one (Godzilla) can pick its best. + val story = seed.source == "franchise" && seed.members.size <= 8 + val ranked = if (story) people.sortedBy { it.year ?: 9999 } + else people.sortedWith(compareByDescending { it.community ?: 0.0 }.thenBy { it.year ?: 9999 }) + val first = seed.members.mapNotNull { films[it] }.filter { ideaSuitable(it) == null }.minByOrNull { it.year ?: 9999 } + if (story && ranked.firstOrNull()?.id != first?.id) { why = "the franchise's first film isn't available"; continue } + val picked = pickFilms(ranked, format) + if (picked.size < 2) { why = "no two films fit the evening"; continue } + // A triple needs three films; two make a double, on the evening that suits their audience. + val chosen = picked + val billFormat = if (format == IdeaFormat.SaturdayTriple && chosen.size < 3) IdeaFormat.FridayDouble else format + if (person && chosen.none { it.notable }) { why = "nothing on the acclaim or trending lists to make the name an occasion"; continue } val rated = chosen.mapNotNull { it.community } if (rated.size * 2 < chosen.size) { why = "too few viewer ratings to judge it"; continue } val average = rated.average() if (average < EVENT_IDEA_BAR) { why = "rated %.1f on average, under the bar".format(average); continue } val score = average + when (seed.source) { "franchise" -> .6; "director" -> .5; "performer" -> .4; else -> .3 } + if (chosen.size == 3) .2 else 0.0 - val idea = EventIdea("${seed.key}|${format.name}", seed.theme, seed.source, format, null, chosen, + val idea = EventIdea("${seed.key}|${billFormat.name}", seed.theme, seed.source, billFormat, null, chosen, listOf("${seed.source}: ${seed.theme}", "every film is $genre", "%.1f average viewer rating".format(average)), score) if (best == null || idea.score > best.score) best = idea } @@ -144,11 +212,11 @@ internal fun weeklyEventIdeas(seeds: List, films: Map, filmsMs: Long, zone: ZoneId = ZoneId.systemDefault()): LocalDateTime? { var day = from - repeat(28) { + repeat(42) { if (day.dayOfWeek in format.days && DriveInEvents.none { it.evening == day }) { val at = day.atTime(format.hour, 0); val ms = at.atZone(zone).toInstant().toEpochMilli() if (ms > planEndMs && at !in taken && competingEvent(ms, ms + filmsMs, zone) == null) return at @@ -162,11 +230,17 @@ internal fun ideaEvening(format: IdeaFormat, from: LocalDate, planEndMs: Long, t internal object EventIdeaHistory { fun load(file: File): List = runCatching { JSONArray(file.readText()).let { a -> (0 until a.length()).map { a.getJSONObject(it) } } }.getOrDefault(emptyList()) fun dismissed(file: File): Set = load(file).filter { it.optString("decision") == "dismissed" }.map { it.optString("signature") }.toSet() + /** Ideas shown under older rules and never decided are withdrawn, kept in the history with the reason. */ + fun withdrawOld(file: File) = runCatching { + val all = load(file) + if (all.none { it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed" }) return@runCatching + file.writeText(JSONArray(all.map { if (it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed") JSONObject(it.toString()).put("decision", "withdrawn: rules changed") else it }).toString()) + } fun record(file: File, ideas: List, nowMs: Long) = runCatching { val all = load(file); val known = all.map { it.optString("signature") }.toSet() val added = ideas.filter { it.signature !in known }.map { i -> JSONObject().put("signature", i.signature).put("theme", i.theme).put("source", i.source).put("format", i.format.name).put("evening", i.evening?.toString()) - .put("films", JSONArray(i.films.map { it.id })).put("titles", JSONArray(i.films.map { it.title })).put("reasons", JSONArray(i.reasons)).put("shownMs", nowMs).put("decision", "proposed") + .put("films", JSONArray(i.films.map { it.id })).put("titles", JSONArray(i.films.map { it.title })).put("reasons", JSONArray(i.reasons)).put("shownMs", nowMs).put("decision", "proposed").put("rules", EVENT_IDEA_RULES) } if (added.isNotEmpty()) file.writeText(JSONArray(all + added).toString()) } diff --git a/android/app/src/test/java/tv/orbit/classic/EventIdeasReplayTest.kt b/android/app/src/test/java/tv/orbit/classic/EventIdeasReplayTest.kt new file mode 100644 index 0000000..11c401a --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/EventIdeasReplayTest.kt @@ -0,0 +1,48 @@ +package tv.orbit.classic + +import org.json.JSONObject +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.time.LocalDate +import java.time.ZoneId + +/** + * The event idea rules on copies of the box's files, run by hand: + * ORBIT_IDEAS_DIR= ./gradlew :app:testDebugUnitTest --tests '*EventIdeasReplayTest*' + * idea-input.json holds the catalogue's candidate films, the films booked around now and the Drive-In's plan end. Writes + * event-ideas-replay.txt: the strongest bills and this week's two, before the box's playability check. + */ +class EventIdeasReplayTest { + private val dir = System.getenv("ORBIT_IDEAS_DIR")?.let(::File) + + @Test fun replay() { + assumeTrue(dir != null && File(dir, "idea-input.json").exists()) + val input = JSONObject(File(dir, "idea-input.json").readText()) + fun strings(o: JSONObject, key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty() + val evidence = File(dir, "no_backup/schedule/evidence-0.jsonl").readLines().map { JSONObject(it) }.filter { it.optString("type") == "Movie" }.associateBy { it.getString("id") } + val notable = File(dir, "no_backup/schedule/collections-0.jsonl").readLines().map { JSONObject(it) }.filter { it.getString("name") in TrendingLists }.flatMap { strings(it, "members") }.toSet() + val films = input.getJSONArray("films").let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.map { o -> + val e = evidence[o.getString("id")] + IdeaFilm(o.getString("id"), o.getString("title"), o.optInt("year").takeIf { it > 0 }, if (o.isNull("imdb")) null else o.getString("imdb"), if (o.isNull("runtime")) null else o.getLong("runtime"), + o.optString("rating"), strings(o, "genres").toSet(), strings(o, "studios"), e?.let { strings(it, "directors") }.orEmpty(), e?.let { strings(it, "leads") }.orEmpty(), + e?.takeIf { !it.isNull("community") }?.optDouble("community"), e?.let { strings(it, "countries") }.orEmpty(), o.getString("id") in notable) + }.associateBy { it.id } + val collections = File(dir, "no_backup/schedule/collections-0.jsonl").readLines().map { JSONObject(it) }.map { it.getString("name") to strings(it, "members") } + val studios = EventStudios.associateWith { st -> films.values.filter { f -> f.studios.any { it.contains(st.first) } }.map { it.id } } + val read = films.values.filter { it.id in evidence } + val held = AdventDoors.map { it.film.itemId } + NaughtyList.map { it.film.itemId } + ApprovedDebuts.map { it.film.itemId } + val seeds = ideaSeeds(collections, studios, read, DriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet() + held) + val busy = strings(input, "busy").toSet() + val unavailable = { f: IdeaFilm -> if (f.id in busy) "booked" else if (f.id in held) "held" else null } + val bills = seeds.mapNotNull { billFor(it, films, unavailable).first }.sortedByDescending { it.score } + val zone = ZoneId.of("Europe/London") + val week = weeklyEventIdeas(seeds, films, unavailable, { format, taken -> ideaEvening(format, LocalDate.of(2026, 11, 6), input.getLong("drivePlanEnd"), taken, format.films * 2 * 3_600_000L, zone) }, emptySet()) + val out = StringBuilder("seeds ${seeds.size}, bills ${bills.size}\n\nstrongest bills:\n") + bills.take(20).forEach { b -> out.appendLine("%.2f %-34s %-22s %s".format(b.score, b.theme.take(34), b.format.name, b.films.joinToString(" / ") { "${it.title} (${it.year}, ${it.community}, ${(it.runtimeMs ?: 0) / 60_000}m)" })) } + out.appendLine("\nthis week's ideas:") + week.forEach { i -> out.appendLine("${i.theme}: ${i.format.label}, ${i.evening}\n ${i.films.joinToString(" / ") { "${it.title} (${it.year})" }}\n ${i.reasons}") } + File(dir, "event-ideas-replay.txt").writeText(out.toString()) + println(out) + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt b/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt index 888063f..9845adf 100644 --- a/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt @@ -9,8 +9,9 @@ import java.time.ZoneId /** Drive-In event ideas (phase 1): coherent themes, the right evening for the audience, a bar to clear, and no repeats. */ class EventIdeasTest { private val london = ZoneId.of("Europe/London") - private fun film(id: String, title: String, genres: Set, rating: String = "R", community: Double? = 7.0, year: Int = 1985, minutes: Long = 95, imdb: String? = "tt$id") = - IdeaFilm(id, title, year, imdb, minutes * 60_000, rating, genres, emptyList(), community = community) + private fun film(id: String, title: String, genres: Set, rating: String = "R", community: Double? = 7.0, year: Int = 1985, minutes: Long = 95, imdb: String? = "tt$id", + countries: List = listOf("United States"), notable: Boolean = true) = + IdeaFilm(id, title, year, imdb, minutes * 60_000, rating, genres, emptyList(), community = community, countries = countries, notable = notable) private val none = { _: IdeaFilm -> null as String? } @Test fun aFranchiseOfAdultHorrorIsALateShowInStoryOrder() { @@ -23,18 +24,27 @@ class EventIdeasTest { } @Test fun aFamilyFranchiseGetsAFamilyEveningAndLowBudgetCultIsWelcomeLate() { - val family = listOf(film("4", "Clay Pals", setOf("Animation", "Family"), "U", 7.6), film("5", "Clay Pals 2", setOf("Animation", "Family"), "U", 7.3), - film("6", "Clay Pals 3", setOf("Animation", "Family"), "PG", 7.1)).associateBy { it.id } + val family = listOf(film("4", "Clay Pals", setOf("Animation", "Family"), "U", 7.6, year = 1980), film("5", "Clay Pals 2", setOf("Animation", "Family"), "U", 7.3), + film("6", "Clay Pals 3", setOf("Animation", "Family"), "PG", 7.1, year = 1990)).associateBy { it.id } assertEquals(IdeaFormat.SaturdayTriple, billFor(IdeaSeed("studio:Clay", "Clay Saturday", "studio", family.keys.toList(), "Animation"), family, none).first!!.format) - val cult = listOf(film("7", "Puppet Terror", setOf("Horror"), "R", 6.5), film("8", "Puppet Terror 2", setOf("Horror"), "R", 6.6)).associateBy { it.id } + val cult = listOf(film("7", "Puppet Terror", setOf("Horror"), "R", 6.9, notable = false), film("8", "Puppet Terror II", setOf("Horror"), "R", 7.0, year = 1989, notable = false)).associateBy { it.id } assertNotNull(billFor(IdeaSeed("studio:Full Moon Features", "Full Moon After Midnight", "studio", cult.keys.toList(), "Horror"), cult, none).first) } @Test fun noCoherentThemeOrTooWeakMeansNoBill() { val mixed = listOf(film("9", "A", setOf("Comedy"), "PG-13"), film("10", "B", setOf("Western"), "PG-13")).associateBy { it.id } assertNull(billFor(IdeaSeed("director:X", "A X Night", "director", mixed.keys.toList()), mixed, none).first) - val weak = listOf(film("11", "C", setOf("Action"), "PG-13", 5.2), film("12", "D", setOf("Action"), "PG-13", 5.5)).associateBy { it.id } - assertTrue(billFor(IdeaSeed("performer:Y", "Y Night", "performer", weak.keys.toList()), weak, none).second.contains("under the bar")) + val weak = listOf(film("11", "C", setOf("Action"), "PG-13", 6.5), film("12", "D", setOf("Action"), "PG-13", 6.6)).associateBy { it.id } + assertTrue(billFor(IdeaSeed("studio:Y", "Y Night", "studio", weak.keys.toList(), "Action"), weak, none).second.contains("under the bar")) + // A name alone isn't an occasion: nothing acclaimed or trending, no night. + val obscure = listOf(film("17", "I", setOf("Action"), "PG-13", 7.5, notable = false), film("18", "J", setOf("Action"), "PG-13", 7.6, notable = false)).associateBy { it.id } + assertTrue(billFor(IdeaSeed("director:Q", "A Q Night", "director", obscure.keys.toList()), obscure, none).second.contains("acclaim")) + // A franchise story starts at its beginning; a foreign-language one needs a studio or acclaim. + val story = listOf(film("19", "Saga", setOf("Action"), "PG-13", 6.9, year = 1990), film("20", "Saga II", setOf("Action"), "PG-13", 7.8, year = 1992), film("21", "Saga III", setOf("Action"), "PG-13", 7.7, year = 1995)).associateBy { it.id } + assertEquals(listOf("Saga", "Saga II", "Saga III"), billFor(IdeaSeed("franchise:Saga Collection", "Saga Night", "franchise", story.keys.toList()), story, none).first!!.films.map { it.title }) + val abroad = story.mapValues { (_, f) -> IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, community = f.community, countries = listOf("Indonesia")) } + assertNull(billFor(IdeaSeed("franchise:Saga Collection", "Saga Night", "franchise", abroad.keys.toList()), abroad, none).first) + assertEquals("a sport event", ideaSuitable(film("22", "WWE SummerSlam 2013", setOf("Action")))) val unrated = listOf(film("13", "E", setOf("Action"), "PG-13", null), film("14", "F", setOf("Action"), "PG-13", null)).associateBy { it.id } assertTrue(billFor(IdeaSeed("performer:Z", "Z Night", "performer", unrated.keys.toList()), unrated, none).second.contains("ratings")) // Unverifiable or unsuitable films don't count: no IMDb ID, a short, a TV movie. @@ -43,14 +53,14 @@ class EventIdeasTest { } @Test fun atMostTwoAWeekNoneWhenNothingIsWorthItAndDismissedThemesStayDismissed() { - val films = (1..12).map { film("$it", "Film $it", setOf("Action"), "PG-13", 7.0 + it / 20.0) }.associateBy { it.id } + val films = (1..12).map { film("$it", "Picture ${'A' + it}", setOf("Action"), "PG-13", 7.0 + it / 20.0) }.associateBy { it.id } val seeds = (0 until 4).map { k -> IdeaSeed("director:D$k", "A D$k Night", "director", (1..12).filter { it % 4 == k }.map { "$it" }) } var day = LocalDate.of(2026, 12, 1) val evening = { _: IdeaFormat, taken: Set -> generateSequence(day.atTime(20, 0)) { it.plusDays(1) }.first { it !in taken } } val week = weeklyEventIdeas(seeds, films, none, evening, emptySet()) assertEquals(2, week.size); assertNotEquals(week[0].evening, week[1].evening) assertTrue(weeklyEventIdeas(seeds, films, none, evening, emptySet(), limit = 0).isEmpty()) - val weak = films.mapValues { (_, f) -> IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, community = 5.0) } + val weak = films.mapValues { (_, f) -> IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, community = 6.5, notable = true) } assertTrue(weeklyEventIdeas(seeds, weak, none, evening, emptySet()).isEmpty()) // no filler val dismissed = setOf(week[0].signature) assertTrue(weeklyEventIdeas(seeds, films, none, evening, dismissed).none { it.signature == week[0].signature }) diff --git a/docs/replays/event-ideas-replay-2026-11-05.txt b/docs/replays/event-ideas-replay-2026-11-05.txt new file mode 100644 index 0000000..8083e42 --- /dev/null +++ b/docs/replays/event-ideas-replay-2026-11-05.txt @@ -0,0 +1,18 @@ +seeds 323, bills 7 + +strongest bills: +7.70 Ant-Man Night FridayDouble Ant-Man (2015, 7.2, 117m) / Ant-Man and the Wasp (2018, 7.0, 118m) +7.60 1981 Night FridayDouble 1991 (2018, 7.1, 101m) / 1995 (2024, 6.9, 119m) +7.60 Toho Monster Night LateShow Godzilla (1954, 7.6, 96m) / Godzilla, Mothra and King Ghidorah: Giant Monsters All-Out Attack (2001, 7.0, 105m) +7.55 Addams Family Night FridayDouble The Addams Family (1991, 7.0, 101m) / Addams Family Values (1993, 6.9, 94m) +7.50 300 Night LateShow 300 (2007, 7.6, 116m) / 300: Rise of an Empire (2014, 6.2, 102m) +7.40 Arsène Lupin Night FridayDouble Arsène Lupin (1932, 6.9, 84m) / Arsène Lupin Returns (1938, 6.7, 80m) +7.20 New World Midnight Movies LateShow The Velvet Vampire (1971, null, 80m) / Hellraiser (1987, 6.9, 93m) + +this week's ideas: +Ant-Man Night: Friday double bill, 2026-12-04T20:00 + Ant-Man (2015) / Ant-Man and the Wasp (2018) + [franchise: Ant-Man Night, every film is Science Fiction, 7.1 average viewer rating] +Toho Monster Night: The Late Show double, 2026-11-18T23:00 + Godzilla (1954) / Godzilla, Mothra and King Ghidorah: Giant Monsters All-Out Attack (2001) + [studio: Toho Monster Night, every film is Science Fiction, 7.3 average viewer rating]