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 f04f021..0bf81f1 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -254,13 +254,15 @@ internal class ChannelScheduler(private val store: ScheduleStore, */ private fun eventFilms(userId: String, session: EmbySampleSession?, events: List, now: Long): List { if (events.isEmpty()) return emptyList() - // Every copy of a curated film is read, so a duplicate this box can't play doesn't sink the event. + // Every copy of a curated film is read, so a duplicate this box can't play doesn't sink the event. An approved event's + // films are pinned by IMDb ID, so playing one needs only the right film and a copy that plays: a person the event is + // about (Arnie) was a rule for choosing its films, not for showing them, and a library's cast list can be incomplete. fun copiesOf(event: DriveInEvent, film: EventFilm) = eventFilmCopies(event, film, CuratedDriveInEvents.copies) val signature = eventSignature(events, CuratedDriveInEvents.copies) val saved = store.loadPool(userId, DRIVE_IN_NUMBER)?.takeIf { it.signature == signature } if (session == null || (saved != null && now - saved.fetchedAtMs < 24 * 60 * 60_000L)) return saved?.series.orEmpty() return try { - val checks = session.readFilmChecks(events.flatMap { event -> event.films.flatMap { f -> copiesOf(event, f).map { EventFilm(f.title, f.year, f.imdb, it) to event.person } } }) + val checks = session.readFilmChecks(events.flatMap { event -> event.films.flatMap { f -> copiesOf(event, f).map { EventFilm(f.title, f.year, f.imdb, it) to null } } }) Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER eventFilms=${checks.size} " + checks.values.groupingBy { it.reason }.eachCount().entries.joinToString(" ") { "${it.key.replace(' ', '_')}=${it.value}" }) checks.values.mapNotNull { it.film }.also { store.savePool(userId, DRIVE_IN_NUMBER, ScheduleStore.SavedPool(it, now, signature)) } } catch (failure: EmbyCheckException) { diff --git a/android/app/src/main/java/tv/orbit/classic/DriveInEvents.kt b/android/app/src/main/java/tv/orbit/classic/DriveInEvents.kt index f2696cf..51866cc 100644 --- a/android/app/src/main/java/tv/orbit/classic/DriveInEvents.kt +++ b/android/app/src/main/java/tv/orbit/classic/DriveInEvents.kt @@ -6,8 +6,8 @@ import java.time.LocalDate /** * Curated Drive-In events (Living Network Stage F; the ten concepts and dates approved by Paul on 4 November 2026, from * docs/drive-in-curation-suggestions-2026-11-04.md). Each is a fixed bill on its evening, booked only when every film is - * verified: the exact library item, its IMDb ID, a copy this box can play, its runtime, rested, not on another channel - * then and, where an event names a person (Arnie), that person in the film's own cast from Emby's People data. An event + * verified: the film by its IMDb ID, a copy this box can play, its runtime, rested and not on another channel then. (Where + * an event names a person, as Arnie does, that chose its films; it isn't checked again when booking.) An event * that fails any of these isn't booked at all and the evening is booked as usual; nothing already booked is ever changed. */ internal class EventFilm(val title: String, val year: Int, val imdb: String, @@ -17,7 +17,10 @@ internal class EventFilm(val title: String, val year: Int, val imdb: String, internal class DriveInEvent(val key: String, val name: String, val evening: LocalDate, val hour: Int, val minute: Int, val films: List, /** An approved takeover identity, or "" for the Drive-In's own. */ val presentation: String = "", - /** Someone every film must have in its cast, checked against Emby's People data. */ + /** + * The person the event is built around, a rule for choosing its films (discovery), not for showing them: once approved, + * the films are pinned by IMDb ID and book on that alone (Paul, 6 November 2026). + */ val person: String? = null) { /** A late double after the evening's main film, rather than the main bill itself. */ val late get() = hour >= 22 diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index 3007d4d..e742f7f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -232,12 +232,9 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v } /** - * The curated Drive-In events' films, read by their exact library items in one request, with their media, IMDb IDs and + * Exact films, each with the person its cast must include (or null), read in one request with their media, IMDb IDs and * cast, each checked by [checkEventFilm]. Read only: nothing is changed in the library. */ - fun readEventFilms(events: List): Map = readFilmChecks(events.flatMap { event -> event.films.map { it to event.person } }) - - /** Exact films, each with the person its cast must include (or null), read and checked as [readEventFilms] does. */ fun readFilmChecks(listed: List>): Map { val films = listed.distinctBy { it.first.itemId } val ids = films.map { it.first.itemId }.filter(EmbyClient::validId) diff --git a/android/app/src/test/java/tv/orbit/classic/FriendsLibraryTest.kt b/android/app/src/test/java/tv/orbit/classic/FriendsLibraryTest.kt index 7c234be..17599fc 100644 --- a/android/app/src/test/java/tv/orbit/classic/FriendsLibraryTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/FriendsLibraryTest.kt @@ -37,7 +37,7 @@ class FriendsLibraryTest { // Universal: every film, Dracula twice, one copy unplayable. Film("m-drac-a", "Dracula", 1931, "tt0021814", container = "iso"), Film("m-drac-b", "Dracula", 1931, "tt0021814"), Film("m-frank", "Frankenstein", 1931, "tt0021884"), Film("m-wolf", "The Wolf Man", 1941, "tt0034398"), - // Arnie: all three, but the library doesn't list him in The Running Man's cast. + // Arnie: all three, though the library doesn't list him in The Running Man's cast. Film("m-commando", "Commando", 1985, "tt0088944", cast = listOf("Arnold Schwarzenegger")), Film("m-recall", "Total Recall", 1990, "tt0100802", cast = listOf("Arnold Schwarzenegger")), Film("m-running", "The Running Man", 1987, "tt0093894"), Film("m-bluey", "Bluey: The Sign", 2024, null)) @@ -136,21 +136,27 @@ class FriendsLibraryTest { assertEquals(setOf("universal", "arnie"), CuratedDriveInEvents.list.map { it.key }.toSet()) assertEquals(listOf("tt0051554"), CuratedDriveInEvents.missing["hammer"]) assertEquals(listOf("m-drac-a", "m-drac-b"), CuratedDriveInEvents.copies["tt0021814"]) - val listed = CuratedDriveInEvents.list.flatMap { e -> e.films.flatMap { f -> eventFilmCopies(e, f, CuratedDriveInEvents.copies).map { EventFilm(f.title, f.year, f.imdb, it) to e.person } } } + val listed = CuratedDriveInEvents.list.flatMap { e -> e.films.flatMap { f -> eventFilmCopies(e, f, CuratedDriveInEvents.copies).map { EventFilm(f.title, f.year, f.imdb, it) to null } } } val checks = session.readFilmChecks(listed) assertEquals("unplayable", checks.getValue("m-drac-a").reason); assertEquals("verified", checks.getValue("m-drac-b").reason) - assertEquals("person not in cast", checks.getValue("m-running").reason) + // The Running Man is the right film by its IMDb ID: a cast list without Arnie in it doesn't stop an approved event. + assertEquals("verified", checks.getValue("m-running").reason) val verified = checks.values.mapNotNull { it.film }.associateBy { it.id } val booking = chooseEventCopies(CuratedDriveInEvents.list, CuratedDriveInEvents.copies, verified.keys) assertEquals("m-drac-b", booking.single { it.key == "universal" }.films.first().itemId) - // Booked as the box books: Universal on its Saturday from the copies that play; Arnie and Hammer not at all, not even in part. + assertEquals(listOf("m-commando", "m-recall", "m-running"), booking.single { it.key == "arnie" }.films.map { it.itemId }) + // Booked as the box books: Universal on its Saturday from the copies that play; Hammer not at all, not even in part. val pool = (1..40).map { n -> PoolSeries("p$n", "Film $n", listOf(PoolEpisode("p$n", "s$n", EpisodeMark(1, 1), "Film $n", 100 * 60_000L))) } var plan: ChannelPlan? = null; var now = LocalDateTime.of(2026, 11, 12, 12, 0).atZone(zone).toInstant().toEpochMilli() repeat(4) { plan = planDriveIn(plan, pool, pool, pool, now, zone, events = booking, eventFilms = verified); now += 24 * 60 * 60_000L } val events = plan!!.entries.filter { it.why.startsWith(DRIVE_IN_EVENT_WHY) } assertEquals(listOf("m-drac-b", "m-frank", "m-wolf"), events.map { it.seriesId }) - assertTrue(plan!!.entries.none { it.seriesId in setOf("m-curse", "m-commando", "m-recall", "m-running", "m-drac-a") }) + assertTrue(plan!!.entries.none { it.seriesId in setOf("m-curse", "m-drac-a") }) + // Arnie's Saturday (28 November) books all three once the Drive-In plans that far. + val aired = plan!!.entries.toMutableList() + repeat(16) { plan = planDriveIn(plan, pool, pool, pool, now, zone, events = booking, eventFilms = verified); aired += plan!!.entries; now += 24 * 60 * 60_000L } + assertEquals(listOf("m-commando", "m-recall", "m-running"), aired.distinctBy { it.startMs }.filter { it.seriesId in setOf("m-commando", "m-recall", "m-running") }.map { it.seriesId }) // Everything Sam's server produced is in Sam's folder; the box's own data is as it was. assertEquals("paul's drive-in", java.io.File(noBackup, "schedule/schedule-300.json").readText())