0.108.0: curated Drive-In events (Stage F), built and replayed, not installed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 156
|
||||
versionName = "0.107.0-evidence-stories"
|
||||
versionCode = 157
|
||||
versionName = "0.108.0-drive-in-events"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -70,19 +70,41 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
|
||||
Log.i("OrbitSchedule", "arrivals pages=${run.pages} read=${found.size} new=${arrivals.size} baseline=$baseline")
|
||||
}
|
||||
|
||||
fun topUpDriveIn(userId: String): ChannelPlan? {
|
||||
fun topUpDriveIn(userId: String, session: EmbySampleSession? = null): ChannelPlan? {
|
||||
val now = clock()
|
||||
val existing = store.loadPlan(userId, DRIVE_IN_NUMBER)
|
||||
if (driveInBookedAhead(existing, now)) return existing
|
||||
val failed = store.loadFailed(userId)
|
||||
fun films(numbers: List<Int>) = numbers.flatMap { number -> withoutFailed(store.loadPool(userId, number)?.series.orEmpty().filter { it.isFilm }, failed) }.distinctBy { seriesKey(it.title) }
|
||||
val avoid = clashesWith(SmartChannels.mapNotNull { store.loadPlan(userId, it.number) })
|
||||
val plan = planDriveIn(existing, films(DriveInMarqueeChannels), films(DriveInLateChannels), films(DriveInBillChannels), now, avoid = avoid) ?: return existing
|
||||
val events = DriveInEvents.filter { !it.evening.isBefore(java.time.Instant.ofEpochMilli(now).atZone(java.time.ZoneId.systemDefault()).toLocalDate().minusDays(1)) }
|
||||
val eventFilms = withoutFailed(eventFilms(userId, session, events, now), failed).associateBy { it.id }
|
||||
val plan = planDriveIn(existing, films(DriveInMarqueeChannels), films(DriveInLateChannels), films(DriveInBillChannels), now, avoid = avoid,
|
||||
events = events, eventFilms = eventFilms, note = { Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER $it") }) ?: return existing
|
||||
if (plan !== existing) store.savePlan(userId, DRIVE_IN_NUMBER, plan)
|
||||
Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER programmes=${plan.entries.size}")
|
||||
return plan
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated events' verified films, read at most once a day, and only when the Drive-In is about to book. A read that
|
||||
* fails keeps the last verified list; with no session, the saved list is used as it is.
|
||||
*/
|
||||
private fun eventFilms(userId: String, session: EmbySampleSession?, events: List<DriveInEvent>, now: Long): List<PoolSeries> {
|
||||
if (events.isEmpty()) return emptyList()
|
||||
val signature = "events:" + events.joinToString("|") { event -> event.key + "=" + event.films.joinToString(",") { it.itemId } }
|
||||
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.readEventFilms(events)
|
||||
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) {
|
||||
Log.w("OrbitSchedule", "channel=$DRIVE_IN_NUMBER eventFilms failed=${failure.javaClass.simpleName} status=${failure.httpStatus}")
|
||||
saved?.series.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** A file that wouldn't play. It stays in what's already planned, and is left out from here on. */
|
||||
fun markFailed(userId: String, itemId: String) {
|
||||
store.saveFailed(userId, store.loadFailed(userId) + itemId)
|
||||
|
||||
@@ -73,8 +73,12 @@ internal class DriveInBill(val kind: DriveInKind, val entries: List<ScheduleEntr
|
||||
val startMs get() = entries.first().startMs
|
||||
val endMs get() = entries.last().endMs
|
||||
val films get() = entries.map { it.media.item.title }
|
||||
/** A bill's own name, such as The Toy Story Trilogy, or the film's title. */
|
||||
val title get() = (DoubleBills + TripleBills).firstOrNull { it.films.map(::seriesKey) == films.map(::seriesKey) }?.name ?: films.joinToString(" + ")
|
||||
/** A curated event's name, from its booking, or null for the weekly rotation. */
|
||||
val event get() = entries.first().why.takeIf { it.startsWith(DRIVE_IN_EVENT_WHY) }?.removePrefix(DRIVE_IN_EVENT_WHY)?.substringBefore(';')?.trim()
|
||||
/** The event's approved takeover, if it has one; otherwise the Drive-In's own identity. */
|
||||
val presentation get() = entries.first().presentation
|
||||
/** A bill's own name: the event's, such as Arnie Triple Bill, or one such as The Toy Story Trilogy, or the film's title. */
|
||||
val title get() = event ?: (DoubleBills + TripleBills).firstOrNull { it.films.map(::seriesKey) == films.map(::seriesKey) }?.name ?: films.joinToString(" + ")
|
||||
}
|
||||
|
||||
/** An evening runs past midnight, so the small hours belong to the night before. */
|
||||
@@ -93,12 +97,27 @@ private fun filmEntry(film: PoolSeries, startMs: Long): ScheduleEntry {
|
||||
return ScheduleEntry(film.id, copy.mark, ScheduledMedia(SampleItem(film.title, "Movie", copy.durationMs / 60_000, "", copy.id, copy.durationMs), copy.sourceId, copy.durationMs), startMs)
|
||||
}
|
||||
|
||||
/** The Late Show's limit: longer than this is a compilation reel or a marathon, not a late film. */
|
||||
internal const val LATE_SHOW_MAX_MS = 3 * 60 * 60_000L
|
||||
/** Compilations by their own titles: trailer reels and samplers. A film's missing studio or rating never counts against it. */
|
||||
private val Compilation = Regex("""\b(sampler|trailers?|trailer reel|compilation|supercut)\b""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* Whether a film may be a Late Show (Paul, 4 November 2026): not over [LATE_SHOW_MAX_MS] and not a compilation by its
|
||||
* title. Independent and cult films with no studio or rating are fine; this only reads the runtime and the title.
|
||||
*/
|
||||
internal fun lateShowEligible(film: PoolSeries) = film.episodes.first().durationMs <= LATE_SHOW_MAX_MS && !Compilation.containsMatchIn(film.title)
|
||||
|
||||
/**
|
||||
* Books each evening out to a week ahead, once, so a bill never changes after it's shown.
|
||||
* Friday is a double bill, Saturday a triple, and other nights a big film. A late show follows, except after a triple.
|
||||
* An approved curated event ([events], its films verified in [eventFilms] by item) takes its evening's main bill, or its
|
||||
* late slot, at its own time; if any film is missing, unrested or on elsewhere then, the evening is booked as usual.
|
||||
* Evenings already booked are kept exactly as they are, events or not. [note] hears counts, never titles.
|
||||
*/
|
||||
internal fun planDriveIn(existing: ChannelPlan?, marquee: List<PoolSeries>, late: List<PoolSeries>, bills: List<PoolSeries>, nowMs: Long,
|
||||
zone: ZoneId = ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false }): ChannelPlan? {
|
||||
zone: ZoneId = ZoneId.systemDefault(), avoid: ProgrammeClash = { _, _, _ -> false },
|
||||
events: List<DriveInEvent> = emptyList(), eventFilms: Map<String, PoolSeries> = emptyMap(), note: (String) -> Unit = {}): ChannelPlan? {
|
||||
val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) }
|
||||
if (driveInBookedAhead(kept, nowMs, zone)) return kept
|
||||
if (marquee.isEmpty()) return kept
|
||||
@@ -107,33 +126,68 @@ internal fun planDriveIn(existing: ChannelPlan?, marquee: List<PoolSeries>, late
|
||||
val entries = kept?.entries.orEmpty().toMutableList()
|
||||
var date = kept?.entries?.lastOrNull()?.let { eveningOf(it.startMs, zone).plusDays(1) } ?: eveningOf(nowMs, zone)
|
||||
val lastDate = eveningOf(nowMs + SCHEDULE_AHEAD_MS, zone)
|
||||
fun book(film: PoolSeries, startMs: Long): Long = filmEntry(film, startMs).let { entry -> entries += entry; shown[film.id] = startMs; entry.endMs }
|
||||
fun eventStart(event: DriveInEvent) = event.evening.atTime(event.hour, event.minute).atZone(zone).toInstant().toEpochMilli()
|
||||
// An event's films are held back from the weekly rotation for a rest either side of it, so the event finds them rested.
|
||||
val held = events.filter { !it.evening.isBefore(date) }.flatMap { event -> event.films.map { film -> film to eventStart(event) } }
|
||||
fun isHeld(film: PoolSeries, startMs: Long) = held.any { (event, at) ->
|
||||
(film.id == event.itemId || seriesKey(film.title) == seriesKey(event.title)) && kotlin.math.abs(at - startMs) < DRIVE_IN_REST_MS }
|
||||
fun free(pool: List<PoolSeries>, startMs: Long) = pool.filter { !isHeld(it, startMs) }.ifEmpty { pool }
|
||||
fun book(film: PoolSeries, startMs: Long, why: String = "", presentation: String = ""): Long =
|
||||
filmEntry(film, startMs).copy(why = why, presentation = presentation).let { entry -> entries += entry; shown[film.id] = startMs; entry.endMs }
|
||||
/** A hand-picked bill, turn and turn about by week, whose films are all here, rested and not on elsewhere. */
|
||||
fun billFor(choices: List<NamedBill>, startMs: Long, week: Long): List<PoolSeries>? = choices.indices.asSequence().map { choices[Math.floorMod(week + it, choices.size.toLong()).toInt()] }.mapNotNull { bill ->
|
||||
val films = bill.films.map { byTitle[seriesKey(it)] ?: return@mapNotNull null }
|
||||
val films = bill.films.map { byTitle[seriesKey(it)]?.takeIf { film -> !isHeld(film, startMs) } ?: return@mapNotNull null }
|
||||
var at = startMs
|
||||
films.takeIf { it.all { film ->
|
||||
val end = at + film.episodes.first().durationMs
|
||||
(shown[film.id]?.let { seen -> startMs - seen >= DRIVE_IN_REST_MS } ?: true) && !avoid(film, at, end).also { at = end }
|
||||
} }
|
||||
}.firstOrNull()
|
||||
/** The event's films in order if every one is verified, rested and clear of the other channels, else null. */
|
||||
fun eventBill(event: DriveInEvent): List<PoolSeries>? {
|
||||
val films = event.films.map { eventFilms[it.itemId] ?: return null.also { note("event=${event.key} skipped=unverified") } }
|
||||
var at = eventStart(event)
|
||||
for (film in films) {
|
||||
val end = at + film.episodes.first().durationMs
|
||||
if (shown[film.id]?.let { seen -> eventStart(event) - seen < DRIVE_IN_REST_MS } == true) return null.also { note("event=${event.key} skipped=rest") }
|
||||
if (avoid(film, at, end)) return null.also { note("event=${event.key} skipped=clash") }
|
||||
at = end
|
||||
}
|
||||
return films
|
||||
}
|
||||
val lateFilms = late.filter(::lateShowEligible)
|
||||
if (lateFilms.size < late.size) note("late excluded=${late.size - lateFilms.size}")
|
||||
while (!date.isAfter(lastDate)) {
|
||||
val week = Math.floorDiv(date.toEpochDay(), 7L)
|
||||
fun at(hour: Int, minute: Int = 0) = date.atTime(hour, minute).atZone(zone).toInstant().toEpochMilli()
|
||||
val triple = if (date.dayOfWeek == DayOfWeek.SATURDAY) billFor(TripleBills, at(19), week) else null
|
||||
val double = if (date.dayOfWeek == DayOfWeek.FRIDAY) billFor(DoubleBills, at(20), week) else null
|
||||
val event = events.firstOrNull { it.evening == date }
|
||||
val eventFilmsTonight = event?.let(::eventBill)
|
||||
val why = event?.let { "$DRIVE_IN_EVENT_WHY${it.name}; curated, approved 4 November 2026" }.orEmpty()
|
||||
fun bookEvent(start: Long) = eventFilmsTonight!!.fold(start) { from, film -> book(film, from, why, event!!.presentation) }
|
||||
val main = eventFilmsTonight?.takeIf { !event!!.late }
|
||||
val lateEvent = eventFilmsTonight?.takeIf { event!!.late }
|
||||
val triple = if (main == null && date.dayOfWeek == DayOfWeek.SATURDAY) billFor(TripleBills, at(19), week) else null
|
||||
val double = if (main == null && date.dayOfWeek == DayOfWeek.FRIDAY) billFor(DoubleBills, at(20), week) else null
|
||||
var end = when {
|
||||
main != null -> bookEvent(eventStart(event!!))
|
||||
triple != null -> triple.fold(at(19)) { start, film -> book(film, start) }
|
||||
double != null -> double.fold(at(20)) { start, film -> book(film, start) }
|
||||
else -> book(nextFilm(marquee, shown, at(20), DRIVE_IN_REST_MS, avoid), at(20))
|
||||
// Before a late event, a main film short enough to leave a break before the event's own start.
|
||||
lateEvent != null -> book(nextFilm(free(marquee, at(20)).filter { at(20) + it.episodes.first().durationMs + 30 * 60_000L <= eventStart(event!!) }
|
||||
.ifEmpty { free(marquee, at(20)) }, shown, at(20), DRIVE_IN_REST_MS, avoid), at(20))
|
||||
else -> book(nextFilm(free(marquee, at(20)), shown, at(20), DRIVE_IN_REST_MS, avoid), at(20))
|
||||
}
|
||||
if (triple == null && late.isNotEmpty()) {
|
||||
if (lateEvent != null) {
|
||||
if (end <= eventStart(event!!)) { bookEvent(eventStart(event)); note("event=${event.key} booked") }
|
||||
else note("event=${event.key} skipped=late main")
|
||||
} else if ((main ?: triple)?.size?.let { it >= 3 } != true && lateFilms.isNotEmpty()) {
|
||||
val quarter = 15 * 60_000L
|
||||
// Always a break first, which is also how a late show is told apart from the bill before it.
|
||||
val start = maxOf(at(23), (end + 2 * quarter - 1) / quarter * quarter)
|
||||
// Too late to be a late show by then: the night ends with the main feature.
|
||||
if (start < at(23) + 4 * 60 * 60_000L) end = book(nextFilm(late, shown, start, DRIVE_IN_REST_MS, avoid), start)
|
||||
if (start < at(23) + 4 * 60 * 60_000L) end = book(nextFilm(free(lateFilms, start), shown, start, DRIVE_IN_REST_MS, avoid), start)
|
||||
}
|
||||
if (main != null) note("event=${event!!.key} booked")
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
return ChannelPlan(entries, ScheduleCursor(null, emptyMap(), shown))
|
||||
@@ -148,10 +202,11 @@ internal fun driveInBills(plan: ChannelPlan, zone: ZoneId = ZoneId.systemDefault
|
||||
return runs.map { run ->
|
||||
val hour = Instant.ofEpochMilli(run.first().startMs).atZone(zone).hour
|
||||
DriveInBill(when {
|
||||
// A late double (an event after midnight's edge) is still the Late Show.
|
||||
hour !in 6..21 -> DriveInKind.LateShow
|
||||
run.size >= 3 -> DriveInKind.Triple
|
||||
run.size == 2 -> DriveInKind.Double
|
||||
hour in 6..21 -> DriveInKind.Marquee
|
||||
else -> DriveInKind.LateShow
|
||||
else -> DriveInKind.Marquee
|
||||
}, run)
|
||||
}
|
||||
}
|
||||
@@ -324,7 +379,10 @@ internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit
|
||||
Box(Modifier.fillMaxSize().background(Brush.horizontalGradient(0f to Navy.copy(alpha = .96f), .42f to Navy.copy(alpha = .9f), .62f to Navy.copy(alpha = .55f), 1f to Navy.copy(alpha = .35f))))
|
||||
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Navy.copy(alpha = .9f), .2f to Navy.copy(alpha = .2f), .75f to Navy.copy(alpha = .25f), 1f to Navy.copy(alpha = .95f))))
|
||||
Column(Modifier.padding(start = 36.dp, top = 78.dp).width(540.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Image(painterResource(R.drawable.orbit_drive_in_epg), "Orbit Drive-In", Modifier.width(110.dp).height(34.dp), alignment = Alignment.CenterStart)
|
||||
// A booked event with an approved takeover shows its board; everything else, the Drive-In's own mark.
|
||||
val takeover = selected?.presentation?.let { DriveInTakeovers[it] }
|
||||
if (takeover != null) Image(painterResource(takeover), selected.title, Modifier.width(160.dp).height(50.dp), alignment = Alignment.CenterStart)
|
||||
else Image(painterResource(R.drawable.orbit_drive_in_epg), "Orbit Drive-In", Modifier.width(110.dp).height(34.dp), alignment = Alignment.CenterStart)
|
||||
if (selected == null) {
|
||||
Text("Tonight's bill is on its way.", color = Color.White, fontFamily = Barlow, fontSize = 20.sp)
|
||||
return@Column
|
||||
@@ -368,7 +426,7 @@ private fun BillRow(bill: DriveInBill, nowMs: Long, selected: Boolean, ticketed:
|
||||
Text(clock(bill.startMs), color = if (selected) Navy else Yellow, fontFamily = BarlowCondensed, fontSize = 18.sp)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(bill.kind.label.uppercase(java.util.Locale.UK) + if (ticketed) " · TICKET HELD" else "", color = if (selected) Navy else if (ticketed) Mint else Yellow, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 9.sp, letterSpacing = .5.sp, maxLines = 1)
|
||||
Text((if (bill.event != null) "SPECIAL EVENT · " else "") + bill.kind.label.uppercase(java.util.Locale.UK) + if (ticketed) " · TICKET HELD" else "", color = if (selected) Navy else if (ticketed) Mint else Yellow, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 9.sp, letterSpacing = .5.sp, maxLines = 1)
|
||||
Text(bill.title, color = ink, fontFamily = Barlow, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONObject
|
||||
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
|
||||
* 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, val itemId: String)
|
||||
|
||||
internal class DriveInEvent(val key: String, val name: String, val evening: LocalDate, val hour: Int, val minute: Int, val films: List<EventFilm>,
|
||||
/** 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. */
|
||||
val person: String? = null) {
|
||||
/** A late double after the evening's main film, rather than the main bill itself. */
|
||||
val late get() = hour >= 22
|
||||
}
|
||||
|
||||
/** Every event booking's reason starts with this, so a bill can be named and told apart from the weekly rotation. */
|
||||
internal const val DRIVE_IN_EVENT_WHY = "event: "
|
||||
|
||||
/** The Batch 5 takeovers, approved as presentation templates, waiting for a booked event (roadmap 3C). */
|
||||
internal val DriveInTakeovers: Map<String, Int> = mapOf(
|
||||
"drivein.fright" to R.drawable.takeover_fright, "drivein.arnie" to R.drawable.takeover_arnie, "drivein.christmas" to R.drawable.takeover_christmas)
|
||||
|
||||
private fun night(date: String) = LocalDate.parse(date)
|
||||
|
||||
internal val DriveInEvents: List<DriveInEvent> = listOf(
|
||||
DriveInEvent("hammer", "Hammer Horror Double Bill", night("2026-11-13"), 20, 0, listOf(
|
||||
EventFilm("The Curse of Frankenstein", 1957, "tt0050280", "5706532"), EventFilm("Dracula", 1958, "tt0051554", "5705114")), "drivein.fright"),
|
||||
DriveInEvent("universal", "Universal Monsters Takeover", night("2026-11-14"), 19, 0, listOf(
|
||||
EventFilm("Dracula", 1931, "tt0021814", "5661535"), EventFilm("Frankenstein", 1931, "tt0021884", "5658574"), EventFilm("The Wolf Man", 1941, "tt0034398", "5848283")), "drivein.fright"),
|
||||
DriveInEvent("amicus", "Amicus Tales of Terror", night("2026-11-17"), 23, 0, listOf(
|
||||
EventFilm("Tales from the Crypt", 1972, "tt0069341", "6047716"), EventFilm("The House That Dripped Blood", 1971, "tt0065854", "6036576")), "drivein.fright"),
|
||||
DriveInEvent("jhorror", "J-Horror After Midnight", night("2026-11-19"), 23, 0, listOf(
|
||||
EventFilm("Ring", 1998, "tt0178868", "7956704"), EventFilm("Ju-on: The Grudge", 2002, "tt0364385", "5662234")), "drivein.fright"),
|
||||
DriveInEvent("shaw", "Shaw Brothers Kung Fu Double", night("2026-11-20"), 20, 0, listOf(
|
||||
EventFilm("The 36th Chamber of Shaolin", 1978, "tt0078243", "6332423"), EventFilm("Five Fingers of Death", 1972, "tt0070800", "6336439"))),
|
||||
DriveInEvent("troma", "Troma Midnight Madness", night("2026-11-26"), 23, 0, listOf(
|
||||
EventFilm("The Toxic Avenger", 1984, "tt0090190", "5672180"), EventFilm("Class of Nuke 'Em High", 1986, "tt0090849", "5706569")), "drivein.fright"),
|
||||
DriveInEvent("arnie", "Arnie Triple Bill", night("2026-11-28"), 19, 0, listOf(
|
||||
EventFilm("Commando", 1985, "tt0088944", "6048687"), EventFilm("Total Recall", 1990, "tt0100802", "5848135"), EventFilm("The Running Man", 1987, "tt0093894", "5671824")),
|
||||
"drivein.arnie", person = "Arnold Schwarzenegger"),
|
||||
DriveInEvent("ghibli", "Studio Ghibli Saturday", night("2026-12-05"), 19, 0, listOf(
|
||||
EventFilm("My Neighbor Totoro", 1988, "tt0096283", "5844712"), EventFilm("Kiki's Delivery Service", 1989, "tt0097814", "7883574"), EventFilm("Spirited Away", 2001, "tt0245429", "7950220"))),
|
||||
DriveInEvent("rambo", "Rambo Marathon", night("2026-12-12"), 19, 0, listOf(
|
||||
EventFilm("First Blood", 1982, "tt0083944", "5642223"), EventFilm("Rambo: First Blood Part II", 1985, "tt0089880", "5848906"), EventFilm("Rambo III", 1988, "tt0095956", "5848907"))),
|
||||
DriveInEvent("christmas", "Christmas at the Drive-In", night("2026-12-19"), 19, 0, listOf(
|
||||
EventFilm("It's a Wonderful Life", 1946, "tt0038650", "6175183"), EventFilm("Home Alone", 1990, "tt0099785", "7416999"), EventFilm("Elf", 2003, "tt0319343", "6952195")),
|
||||
"drivein.christmas"))
|
||||
|
||||
/** What reading an event film found: the film to book, or why it can't be (for the playback notes, never the screen). */
|
||||
internal class EventFilmCheck(val film: PoolSeries?, val reason: String)
|
||||
|
||||
/**
|
||||
* One event film checked from its library row: the same item, the same IMDb ID (a copy without one doesn't count, even
|
||||
* of the same title), a year within one, a copy this box can play with a runtime, and [person] in its cast if named.
|
||||
*/
|
||||
internal fun checkEventFilm(film: EventFilm, row: JSONObject?, person: String?, canDecode: (String) -> Boolean): EventFilmCheck {
|
||||
if (row == null) return EventFilmCheck(null, "missing")
|
||||
val candidate = filmCandidate(row) ?: return EventFilmCheck(null, "not a film")
|
||||
if (candidate.id != film.itemId) return EventFilmCheck(null, "another item")
|
||||
if (candidate.imdb?.lowercase() != film.imdb) return EventFilmCheck(null, if (candidate.imdb == null) "unverified: no IMDb ID" else "another film")
|
||||
if (candidate.year == null || kotlin.math.abs(candidate.year - film.year) > 1) return EventFilmCheck(null, "another year")
|
||||
if (person != null) {
|
||||
val cast = row.optJSONArray("People")?.let { people -> (0 until people.length()).mapNotNull { people.optJSONObject(it) } }.orEmpty()
|
||||
if (cast.none { it.optString("Name").equals(person, ignoreCase = true) && it.optString("Type").equals("Actor", ignoreCase = true) }) return EventFilmCheck(null, "person not in cast")
|
||||
}
|
||||
val lookup = resolveFilm(FilmIdentity(candidate.name, film.year, film.imdb), listOf(candidate), canDecode)
|
||||
return EventFilmCheck(lookup.film, if (lookup.film == null) "unplayable" else "verified")
|
||||
}
|
||||
@@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.107.0\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.108.0\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -265,7 +265,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
} catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (_: Exception) { /* Tried again next hour. */ }
|
||||
}
|
||||
try { driveInPlan = withContext(Dispatchers.IO) { scheduler.topUpDriveIn(active.accountId) } }
|
||||
try { driveInPlan = withContext(Dispatchers.IO) { scheduler.topUpDriveIn(active.accountId, active) } }
|
||||
catch (cancelled: CancellationException) { throw cancelled }
|
||||
catch (_: Exception) { /* Tried again next hour. */ }
|
||||
// Stage C's light daily library refresh, in the background. A failure changes nothing and is tried next hour.
|
||||
|
||||
@@ -216,6 +216,22 @@ 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
|
||||
* cast, each checked by [checkEventFilm]. Read only: nothing is changed in the library.
|
||||
*/
|
||||
fun readEventFilms(events: List<DriveInEvent>): Map<String, EventFilmCheck> {
|
||||
val films = events.flatMap { event -> event.films.map { it to event.person } }.distinctBy { it.first.itemId }
|
||||
val ids = films.map { it.first.itemId }.filter(EmbyClient::validId)
|
||||
if (ids.isEmpty()) return emptyMap()
|
||||
val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Ids=${ids.joinToString(",")}&Fields=MediaSources,ProviderIds,ProductionYear,People" +
|
||||
"&EnableImages=false&EnableUserData=false", token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS))
|
||||
EmbyClient.expectSuccess(reply.status, false)
|
||||
val rows = EmbyClient.parse(reply.body).optJSONArray("Items")
|
||||
val byId = (0 until (rows?.length() ?: 0)).mapNotNull { rows!!.optJSONObject(it) }.associateBy { it.optString("Id") }
|
||||
return films.associate { (film, person) -> film.itemId to checkEventFilm(film, byId[film.itemId], person, BoxDecoders::canDecode) }
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B
|
||||
next.instanceFollowRedirects = false
|
||||
next.connectTimeout = 10_000
|
||||
next.readTimeout = 20_000
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.107.0")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.108.0")
|
||||
next.setRequestProperty("Accept-Encoding", "identity")
|
||||
session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) }
|
||||
if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) {
|
||||
|
||||
@@ -50,8 +50,11 @@ internal fun whatsOnRule(channel: Int, entry: ScheduleEntry, zone: ZoneId): Trip
|
||||
}
|
||||
}
|
||||
|
||||
private fun driveInItem(bill: DriveInBill) = WhatsOnItem(DRIVE_IN_NUMBER, bill.entries.first(), "AT THE DRIVE-IN", "A Drive-In bill",
|
||||
if (bill.kind == DriveInKind.Double || bill.kind == DriveInKind.Triple) 70 else 50, bill)
|
||||
/** A booked curated event outranks the weekly bills; a proposed one isn't in the plan, so it can't appear at all. */
|
||||
private fun driveInItem(bill: DriveInBill) = when {
|
||||
bill.event != null -> WhatsOnItem(DRIVE_IN_NUMBER, bill.entries.first(), "DRIVE-IN EVENT", "A curated Drive-In event", 85, bill)
|
||||
else -> WhatsOnItem(DRIVE_IN_NUMBER, bill.entries.first(), "AT THE DRIVE-IN", "A Drive-In bill", if (bill.kind == DriveInKind.Double || bill.kind == DriveInKind.Triple) 70 else 50, bill)
|
||||
}
|
||||
|
||||
/**
|
||||
* The page for [nowMs]. [plans] are the channels' committed plans by number; [driveIn] the Drive-In's. Tonight is this
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,73 @@
|
||||
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
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* The curated events on copies of the box's own files, run by hand:
|
||||
* ORBIT_REPLAY_DIR=<copies of the box's schedule folder, plus event-films.json> ./gradlew :app:testDebugUnitTest --tests '*DriveInEventsReplayTest*'
|
||||
* event-films.json is the events' films as the library catalogue has them ([{id, title, ms}]); on the box the same films
|
||||
* also pass the decoder and People checks first. Books the Drive-In day by day to 21 December, with and without the events,
|
||||
* and writes drivein-events.txt and drivein-baseline.txt there: every screening booked, and why.
|
||||
*/
|
||||
class DriveInEventsReplayTest {
|
||||
private val dir = System.getenv("ORBIT_REPLAY_DIR")?.let(::File)
|
||||
private val zone = ZoneId.of("Europe/London")
|
||||
|
||||
private fun plan(file: File): ChannelPlan? {
|
||||
if (!file.exists()) return null
|
||||
val root = JSONObject(file.readText()); val rows = root.getJSONArray("entries"); val t = root.getJSONObject("tail")
|
||||
val entries = (0 until rows.length()).map { i ->
|
||||
val o = rows.getJSONObject(i); val d = o.getLong("d"); val n = o.getJSONArray("n")
|
||||
ScheduleEntry(o.getString("s"), EpisodeMark(n.getInt(0), n.getInt(1)), ScheduledMedia(SampleItem(o.getString("t"), o.optString("k", "Episode"), d / 60_000, "", o.getString("i"), d), o.getString("c"), d),
|
||||
o.getLong("a"), o.optString("w"), o.optString("p"))
|
||||
}
|
||||
return ChannelPlan(entries, ScheduleCursor(null, emptyMap(), t.optJSONObject("shown")?.let { s -> s.keys().asSequence().associateWith { s.getLong(it) } }.orEmpty()))
|
||||
}
|
||||
|
||||
private fun pool(number: Int): List<PoolSeries> {
|
||||
val file = File(dir, "episodes-$number.json").takeIf { it.exists() } ?: return emptyList()
|
||||
val rows = JSONObject(file.readText()).getJSONArray("series")
|
||||
return (0 until rows.length()).map { i ->
|
||||
val o = rows.getJSONObject(i); val eps = o.getJSONArray("episodes")
|
||||
PoolSeries(o.getString("id"), o.getString("title"), (0 until eps.length()).map { j ->
|
||||
val e = eps.getJSONArray(j); PoolEpisode(e.getString(0), e.getString(1), EpisodeMark(e.getInt(2), e.getInt(3)), e.getString(4), e.getLong(5))
|
||||
}, o.optString("group"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun toChristmas() {
|
||||
assumeTrue(dir != null && File(dir, "event-films.json").exists())
|
||||
val failed = File(dir, "failed-0.json").takeIf { it.exists() }?.let { f -> JSONObject(f.readText()).optJSONArray("ids")?.let { a -> (0 until a.length()).map { a.getString(it) }.toSet() } }.orEmpty()
|
||||
fun films(numbers: List<Int>) = numbers.flatMap { withoutFailed(pool(it).filter { s -> s.isFilm }, failed) }.distinctBy { seriesKey(it.title) }
|
||||
val avoid = clashesWith(SmartChannels.mapNotNull { plan(File(dir, "schedule-${it.number}.json")) })
|
||||
val listed = JSONArray(File(dir, "event-films.json").readText())
|
||||
val eventFilms = (0 until listed.length()).map { listed.getJSONObject(it) }.associate { o ->
|
||||
o.getString("id") to PoolSeries(o.getString("id"), o.getString("title"), listOf(PoolEpisode(o.getString("id"), "src", EpisodeMark(1, 1), o.getString("title"), o.getLong("ms"))))
|
||||
}
|
||||
val committed = plan(File(dir, "schedule-300.json"))!!
|
||||
val start = System.getenv("ORBIT_REPLAY_NOW")?.toLong() ?: committed.entries.first().startMs
|
||||
for ((name, events) in listOf("drivein-events" to DriveInEvents, "drivein-baseline" to emptyList())) {
|
||||
val notes = mutableListOf<String>()
|
||||
var current: ChannelPlan? = committed
|
||||
val all = committed.entries.toMutableList()
|
||||
var now = start
|
||||
val end = java.time.LocalDate.of(2026, 12, 21).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
while (now < end) {
|
||||
current = planDriveIn(current, films(DriveInMarqueeChannels), films(DriveInLateChannels), films(DriveInBillChannels), now, zone, avoid, events, eventFilms) { notes += it }
|
||||
current!!.entries.filter { e -> all.none { it.startMs == e.startMs } }.forEach { all += it }
|
||||
now += 24 * 60 * 60_000L
|
||||
}
|
||||
val text = all.sortedBy { it.startMs }.joinToString("\n") { e ->
|
||||
val at = Instant.ofEpochMilli(e.startMs).atZone(zone)
|
||||
"${at.toLocalDate()} ${at.dayOfWeek.toString().take(3)} %02d:%02d %3d min %s | %s | %s".format(at.hour, at.minute, e.media.durationMs / 60_000, e.media.item.title, e.why, e.presentation)
|
||||
}
|
||||
File(dir, "$name.txt").writeText(text + "\n\nnotes: " + notes.joinToString("; ") + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/** Stage F's curated Drive-In events: fixed bills on their evenings, only when every film is verified, never disturbing anything booked. */
|
||||
class DriveInEventsTest {
|
||||
private val london = ZoneId.of("Europe/London")
|
||||
private val minute = 60_000L
|
||||
private fun film(title: String, minutes: Long = 120, id: String = title.replace(Regex("[^A-Za-z0-9]"), "")) =
|
||||
PoolSeries(id, title, listOf(PoolEpisode(id, "s$id", EpisodeMark(1, 1), title, minutes * minute)))
|
||||
private fun at(month: Int, day: Int, h: Int, m: Int = 0) = ZonedDateTime.of(2026, month, day, h, m, 0, 0, london).toInstant().toEpochMilli()
|
||||
private fun local(ms: Long) = Instant.ofEpochMilli(ms).atZone(london)
|
||||
private val big = (1..40).map { film("Big $it", 110) }
|
||||
private val late = (1..40).map { film("Late $it", 95) }
|
||||
private val bills = listOf(film("Alien"), film("Aliens"))
|
||||
|
||||
// A Saturday triple, a Friday double, and a late double on the Thursday between.
|
||||
private val triple = DriveInEvent("monsters", "Monster Night", LocalDate.of(2026, 11, 14), 19, 0, listOf(
|
||||
EventFilm("Dracula", 1931, "tt1", "d31"), EventFilm("Frankenstein", 1931, "tt2", "f31"), EventFilm("The Wolf Man", 1941, "tt3", "w41")), "drivein.fright")
|
||||
private val double = DriveInEvent("hammer", "Hammer Night", LocalDate.of(2026, 11, 13), 20, 0, listOf(
|
||||
EventFilm("The Curse of Frankenstein", 1957, "tt4", "c57"), EventFilm("Dracula", 1958, "tt5", "d58")))
|
||||
private val lateDouble = DriveInEvent("jhorror", "J-Horror After Midnight", LocalDate.of(2026, 11, 12), 23, 0, listOf(
|
||||
EventFilm("Ring", 1998, "tt6", "r98"), EventFilm("Ju-on: The Grudge", 2002, "tt7", "j02")))
|
||||
private val events = listOf(triple, double, lateDouble)
|
||||
private val verified = mapOf("d31" to film("Dracula", 74, "d31"), "f31" to film("Frankenstein", 70, "f31"), "w41" to film("The Wolf Man", 69, "w41"),
|
||||
"c57" to film("The Curse of Frankenstein", 83, "c57"), "d58" to film("Dracula", 82, "d58"), "r98" to film("Ring", 95, "r98"), "j02" to film("Ju-on: The Grudge", 92, "j02"))
|
||||
|
||||
/**
|
||||
* Day by day, as the box does it, from the 9th to the end of the month. Returns every booking made on the way (the plan
|
||||
* itself drops evenings once they're past), in order, as one plan.
|
||||
*/
|
||||
private fun run(existing: ChannelPlan? = null, from: Long = at(11, 9, 12), films: Map<String, PoolSeries> = verified, avoid: ProgrammeClash = { _, _, _ -> false },
|
||||
notes: MutableList<String> = mutableListOf()): ChannelPlan {
|
||||
var plan = existing
|
||||
var now = from
|
||||
val all = existing?.entries.orEmpty().toMutableList()
|
||||
while (now < at(11, 30, 12)) {
|
||||
plan = planDriveIn(plan, big, late, bills, now, london, avoid, events, films) { notes += it }
|
||||
plan!!.entries.filter { e -> all.none { it.startMs == e.startMs } }.forEach { all += it }
|
||||
now += 24 * 60 * minute
|
||||
}
|
||||
last = plan
|
||||
return ChannelPlan(all.sortedBy { it.startMs }, plan!!.tail)
|
||||
}
|
||||
private var last: ChannelPlan? = null
|
||||
|
||||
@Test fun eventsBookOnTheirEveningsInOrderAtTheirOwnTimes() {
|
||||
val bills = driveInBills(run(), london)
|
||||
val monsters = bills.single { it.event == "Monster Night" }
|
||||
assertEquals(listOf("Dracula", "Frankenstein", "The Wolf Man"), monsters.films)
|
||||
assertEquals(at(11, 14, 19), monsters.startMs)
|
||||
assertEquals(DriveInKind.Triple, monsters.kind)
|
||||
assertEquals("drivein.fright", monsters.presentation)
|
||||
assertEquals("Monster Night", monsters.title)
|
||||
monsters.entries.zipWithNext { a, b -> assertEquals(a.endMs, b.startMs) }
|
||||
// A triple ends the night; a double is followed by the Late Show as usual.
|
||||
assertTrue(bills.none { it.startMs > monsters.startMs && it.startMs < at(11, 15, 12) })
|
||||
val hammer = bills.single { it.event == "Hammer Night" }
|
||||
assertEquals(at(11, 13, 20), hammer.startMs)
|
||||
assertEquals("", hammer.presentation)
|
||||
assertTrue(bills.any { it.kind == DriveInKind.LateShow && it.startMs > hammer.endMs && it.startMs < at(11, 14, 4) })
|
||||
}
|
||||
|
||||
@Test fun aLateDoubleFollowsAShortEnoughMainFilmAtItsOwnTime() {
|
||||
val plan = run()
|
||||
val night = driveInBills(plan, london).filter { it.startMs in at(11, 12, 18)..at(11, 13, 6) }
|
||||
assertEquals(listOf(DriveInKind.Marquee, DriveInKind.LateShow), night.map { it.kind })
|
||||
assertEquals(at(11, 12, 23), night[1].startMs)
|
||||
assertEquals("J-Horror After Midnight", night[1].title)
|
||||
assertTrue(night[0].endMs + 30 * minute <= night[1].startMs)
|
||||
// Past midnight, and nothing booked on top of it.
|
||||
assertTrue(local(night[1].endMs).dayOfMonth == 13)
|
||||
plan.entries.zipWithNext { a, b -> assertTrue(a.endMs <= b.startMs) }
|
||||
}
|
||||
|
||||
@Test fun anEventWithAnUnverifiedFilmIsntBookedAndTheEveningIsAsUsual() {
|
||||
val notes = mutableListOf<String>()
|
||||
val plan = run(films = verified - "w41", notes = notes)
|
||||
val saturday = driveInBills(plan, london).filter { it.startMs in at(11, 14, 18)..at(11, 15, 6) }
|
||||
assertTrue(saturday.none { it.event != null })
|
||||
assertTrue(saturday.isNotEmpty())
|
||||
assertTrue("event=monsters skipped=unverified" in notes)
|
||||
assertTrue(notes.none { "Dracula" in it })
|
||||
}
|
||||
|
||||
@Test fun anEventClashingWithAChannelIsntBooked() {
|
||||
val notes = mutableListOf<String>()
|
||||
val plan = run(avoid = { film, _, _ -> film.id == "c57" }, notes = notes)
|
||||
assertTrue(driveInBills(plan, london).none { it.event == "Hammer Night" })
|
||||
assertTrue("event=hammer skipped=clash" in notes)
|
||||
}
|
||||
|
||||
@Test fun restIsKeptBothWays() {
|
||||
// Shown at the Drive-In a week before: not rested, so the event isn't booked.
|
||||
val earlier = ChannelPlan(listOf(ScheduleEntry("r98", EpisodeMark(1, 1), ScheduledMedia(SampleItem("Ring", "Movie", 95, "", "r98", 95 * minute), "sr98", 95 * minute), at(11, 5, 23))),
|
||||
ScheduleCursor(null, emptyMap(), mapOf("r98" to at(11, 5, 23))))
|
||||
val notes = mutableListOf<String>()
|
||||
val plan = run(existing = earlier, from = at(11, 5, 12), notes = notes)
|
||||
assertTrue("event=jhorror skipped=rest" in notes)
|
||||
assertTrue(driveInBills(plan, london).none { it.event == "J-Horror After Midnight" })
|
||||
// And the weekly rotation keeps an event's film for the event: Alien isn't an event film, but a pool copy of Dracula is held back.
|
||||
val withCopy = (1..40).map { film("Big $it", 110) } + film("Dracula", 82, "pool-dracula")
|
||||
var p: ChannelPlan? = null; var now = at(11, 9, 12)
|
||||
while (now < at(11, 30, 12)) { p = planDriveIn(p, withCopy, late, bills, now, london, events = events, eventFilms = verified); now += 24 * 60 * minute }
|
||||
assertTrue(p!!.entries.none { it.media.item.id == "pool-dracula" && kotlin.math.abs(it.startMs - at(11, 13, 21)) < 14 * 24 * 60 * minute })
|
||||
}
|
||||
|
||||
@Test fun everythingAlreadyBookedAndEveryTicketIsKept() {
|
||||
// Booked up to the 14th before the events existed: the 12th, 13th and 14th stay exactly as they were.
|
||||
var before: ChannelPlan? = null; var now = at(11, 7, 12)
|
||||
while (now < at(11, 8, 12)) { before = planDriveIn(before, big, late, bills, now, london); now += 24 * 60 * minute }
|
||||
val booked = before!!
|
||||
val tickets = driveInBills(booked, london).filter { it.startMs > at(11, 12, 0) }.associate { it.startMs to 0 }
|
||||
val after = run(existing = booked, from = at(11, 8, 12))
|
||||
assertEquals(booked.entries, after.entries.take(booked.entries.size))
|
||||
assertTrue(driveInBills(after, london).filter { it.startMs <= booked.entries.last().startMs }.none { it.event != null })
|
||||
val afterStarts = driveInBills(after, london).map { it.startMs }.toSet()
|
||||
assertTrue(tickets.keys.all { it in afterStarts })
|
||||
// The first event after what was booked still books.
|
||||
assertTrue(driveInBills(after, london).none { it.event == "Monster Night" } || booked.entries.last().startMs < at(11, 14, 19))
|
||||
}
|
||||
|
||||
@Test fun restartingChangesNothing() {
|
||||
run()
|
||||
val saved = last!!
|
||||
// Opened again the same day: the saved plan is already booked ahead, so nothing changes.
|
||||
assertEquals(saved.entries, planDriveIn(saved, big, late, bills, at(11, 29, 13), london, events = events, eventFilms = verified)!!.entries)
|
||||
// A day later, everything still to air is kept exactly.
|
||||
val again = planDriveIn(saved, big, late, bills, at(11, 30, 13), london, events = events, eventFilms = verified)!!
|
||||
assertTrue(saved.entries.filter { it.endMs > at(11, 30, 13) }.all { it in again.entries })
|
||||
}
|
||||
|
||||
@Test fun lateShowLeavesOutCompilationsAndReelsButNotFilmsWithoutMetadata() {
|
||||
assertFalse(lateShowEligible(film("Extra Weird Sampler", 222)))
|
||||
assertFalse(lateShowEligible(film("Grindhouse Trailer Reel", 90)))
|
||||
assertFalse(lateShowEligible(film("A Four-Hour Cut", 240)))
|
||||
assertTrue(lateShowEligible(film("Braindead", 97)))
|
||||
assertTrue(lateShowEligible(film("Thesis", 124)))
|
||||
val notes = mutableListOf<String>()
|
||||
val plan = planDriveIn(null, big, late + film("Extra Weird Sampler", 222), bills, at(11, 2, 12), london) { notes += it }!!
|
||||
assertTrue(plan.entries.none { it.media.item.title == "Extra Weird Sampler" })
|
||||
assertTrue("late excluded=1" in notes)
|
||||
}
|
||||
|
||||
@Test fun whatsOnShowsOnlyBookedEvents() {
|
||||
val plan = run()
|
||||
val page = whatsOnPage(emptyMap(), plan, at(11, 14, 15), london)
|
||||
assertEquals("Monster Night", page.pick!!.title)
|
||||
assertEquals("DRIVE-IN EVENT", page.pick!!.tag)
|
||||
// A proposed event that isn't booked (no verified films) appears nowhere.
|
||||
val unbooked = run(films = emptyMap())
|
||||
val page2 = whatsOnPage(emptyMap(), unbooked, at(11, 14, 15), london)
|
||||
assertTrue((listOfNotNull(page2.pick) + page2.tonight + page2.comingUp).none { it.tag == "DRIVE-IN EVENT" })
|
||||
}
|
||||
|
||||
private fun row(id: String, imdb: String?, year: Int, people: List<Pair<String, String>> = emptyList()) = JSONObject().put("Id", id).put("Type", "Movie").put("Name", "Commando")
|
||||
.put("ProductionYear", year).put("RunTimeTicks", 91 * 60 * 10_000_000L)
|
||||
.put("ProviderIds", JSONObject().apply { if (imdb != null) put("Imdb", imdb) })
|
||||
.put("MediaSources", JSONArray().put(JSONObject().put("Id", "src$id").put("Container", "mkv").put("RunTimeTicks", 91 * 60 * 10_000_000L)
|
||||
.put("MediaStreams", JSONArray().put(JSONObject().put("Type", "Video").put("Codec", "h264")))))
|
||||
.put("People", JSONArray().apply { people.forEach { (name, type) -> put(JSONObject().put("Name", name).put("Type", type)) } })
|
||||
|
||||
@Test fun eventFilmsAreCheckedByItemImdbAndCast() {
|
||||
val commando = EventFilm("Commando", 1985, "tt0088944", "6048687")
|
||||
val arnie = "Arnold Schwarzenegger"
|
||||
assertEquals("verified", checkEventFilm(commando, row("6048687", "tt0088944", 1985, listOf(arnie to "Actor")), arnie) { true }.reason)
|
||||
assertEquals("person not in cast", checkEventFilm(commando, row("6048687", "tt0088944", 1985, listOf(arnie to "Producer")), arnie) { true }.reason)
|
||||
assertEquals("person not in cast", checkEventFilm(commando, row("6048687", "tt0088944", 1985), arnie) { true }.reason)
|
||||
// The library's unlabelled copy of the same title and year is never taken for it.
|
||||
assertEquals("another item", checkEventFilm(commando, row("7859713", null, 1985, listOf(arnie to "Actor")), arnie) { true }.reason)
|
||||
assertEquals("unverified: no IMDb ID", checkEventFilm(commando, row("6048687", null, 1985, listOf(arnie to "Actor")), arnie) { true }.reason)
|
||||
assertEquals("another film", checkEventFilm(commando, row("6048687", "tt9999999", 1985), null) { true }.reason)
|
||||
assertEquals("missing", checkEventFilm(commando, null, null) { true }.reason)
|
||||
assertEquals("unplayable", checkEventFilm(commando, row("6048687", "tt0088944", 1985), null) { false }.reason)
|
||||
}
|
||||
|
||||
@Test fun theApprovedTenAreAsAgreed() {
|
||||
assertEquals(10, DriveInEvents.size)
|
||||
assertEquals(DriveInEvents.size, DriveInEvents.map { it.evening }.distinct().size)
|
||||
assertTrue(DriveInEvents.all { it.presentation.isEmpty() || it.presentation in DriveInTakeovers })
|
||||
assertEquals("Arnold Schwarzenegger", DriveInEvents.single { it.key == "arnie" }.person)
|
||||
assertTrue(DriveInEvents.flatMap { it.films }.all { it.imdb.matches(Regex("tt\\d{7,8}")) && EmbyClient.validId(it.itemId) })
|
||||
// Three triples run past midnight; nothing else is booked on top of them by design (a triple has no late show).
|
||||
assertTrue(DriveInEvents.filter { it.films.size == 3 }.all { it.hour == 19 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# 0.108.0: curated Drive-In events (Stage F booking release), built and replayed, NOT installed
|
||||
|
||||
Paul approved the ten event concepts and dates on 4 November 2026 at 19:40, and asked for this release to be built and verified, with nothing installed or booked until he approves separately.
|
||||
|
||||
## What it does
|
||||
|
||||
The ten events are fixed in `DriveInEvents.kt`. Each film is pinned by its Emby item and IMDb ID, read from the box's catalogue at 19:42 today, which holds 132,674 items.
|
||||
|
||||
**When the Drive-In next books an evening**, it reads those 25 items from Emby in one request, together with their media, IMDb IDs and cast. Each film is then checked by `checkEventFilm`:
|
||||
- the same item and the same IMDb ID. A copy without an ID doesn't count, so the library's unlabelled copies of Commando and Spirited Away are never used;
|
||||
- a year within one of the listed year;
|
||||
- a copy this box can decode, with a runtime;
|
||||
- for the Arnie Triple Bill, **Arnold Schwarzenegger listed as an actor in each film's own Emby People data**. Title recognition alone isn't enough.
|
||||
|
||||
The verified list is saved as the Drive-In's pool and re-read at most once a day, only when the Drive-In is about to book. If a read fails, the last verified list is kept.
|
||||
|
||||
**On an event's evening**, the event takes the main bill at its own time, or the late slot for the three late doubles. It is booked only if every film is:
|
||||
- verified;
|
||||
- rested 14 days at the Drive-In;
|
||||
- clear of every other channel's bookings.
|
||||
|
||||
If any film fails, the event isn't booked at all and the evening is booked as usual. The log carries counts and reasons only, such as `event=arnie skipped=unverified`, never titles.
|
||||
|
||||
**On a late double's night**, the 20:00 film is chosen short enough to leave a 30-minute break before 23:00.
|
||||
|
||||
**Triples end the night.** Three of them run past midnight (Arnie 00:05, Ghibli 00:14, Christmas 00:29), and nothing else is booked that night.
|
||||
|
||||
**In the weekly rotation:**
|
||||
- Each event's films are held back for 14 days either side of the event, so the event always finds them rested.
|
||||
- The hand-written doubles and triples respect the same hold.
|
||||
|
||||
**Late Show eligibility:** `lateShowEligible` leaves out anything over 3 hours, and compilations by their title (sampler, trailer reel, compilation, supercut). It never reads studio or rating, so independent and cult films without that metadata stay eligible. Braindead and Thesis are both tested.
|
||||
|
||||
**Nothing already booked is touched:**
|
||||
- Evenings already on the calendar are kept exactly, including Extra Weird Sampler on Monday 9 November.
|
||||
- Tickets are keyed by start time, and every booked start is unchanged.
|
||||
|
||||
**Presentation:**
|
||||
- Events booked with an approved Batch 5 takeover show its board on the Drive-In screen in place of the Drive-In mark: Orbit Fright Nights for Hammer, Universal, Amicus, J-Horror and Troma; Arnie Triple Bill; Christmas at Orbit.
|
||||
- Shaw Brothers, Ghibli and Rambo use the normal Drive-In identity.
|
||||
- Bill rows say SPECIAL EVENT.
|
||||
- The Drive-In has no idents of its own today, so the takeover videos aren't played. That would be a separate change.
|
||||
|
||||
**What's On:**
|
||||
- A booked event is a DRIVE-IN EVENT card, which outranks the weekly bills.
|
||||
- It reads only the Drive-In's saved plan, so an event that isn't booked appears nowhere.
|
||||
|
||||
## Checks
|
||||
|
||||
- **Tests:** 479 unit tests run, 0 failures; lint clean (the 4 unused-resource warnings were there before). `DriveInEventsTest` adds 11 tests:
|
||||
- event ordering and times;
|
||||
- a late double after a short enough main film, past midnight;
|
||||
- an unverified film skips the whole event, and the log carries no titles;
|
||||
- a clash with a channel skips the event;
|
||||
- rest both ways: an event isn't booked if a film is unrested, and the rotation holds event films back;
|
||||
- everything booked and every ticket start is kept;
|
||||
- restart changes nothing;
|
||||
- Late Show eligibility;
|
||||
- What's On shows only booked events;
|
||||
- the item, IMDb and cast checks, including the unlabelled Commando copy;
|
||||
- the ten as approved.
|
||||
- **Replay on the box's files** (copied at 19:27 today; Orbit hasn't run since) with `DriveInEventsReplayTest`, booking day by day from 4 November 19:30 to 21 December. Outputs: `docs/replays/drivein-events-0.108.0.txt`, and the same run without events in `drivein-baseline-0.108.0.txt`.
|
||||
- All 10 events are booked on their dates at their times. The full lists are in the file.
|
||||
- No screenings overlap.
|
||||
- The 20 committed screenings are identical, Extra Weird Sampler included.
|
||||
- No film repeats within 14 days. The only repeated title, Dracula on the 13th and the 14th, is two different films: Hammer's 1958 and Universal's 1931.
|
||||
- No Late Show after the committed ones is over 3 hours. The log notes one excluded film, the Sampler.
|
||||
- Late doubles: the 20:00 films on those nights are Mr. & Mrs. Smith (125 min), Hard Target (99) and Mad Max: Fury Road (120), each ending at least 30 minutes before 23:00.
|
||||
- Evenings after 11 November that aren't events differ from the baseline. They are future bookings that don't exist yet, and they shift because event films are held back from the rotation.
|
||||
- **Not provable off the box, so they run on the box at booking time:**
|
||||
- the decoder check of each film's actual file;
|
||||
- Emby's People data for the Arnie films;
|
||||
- clashes with channel bookings beyond the current week. Channels book 7 days ahead, as the Drive-In does, and avoid its films once they're booked.
|
||||
|
||||
An event that fails any of these on the box simply isn't booked, and the log says why.
|
||||
|
||||
**Rollback:** reinstall 0.107.0. The Drive-In's events pool (`episodes-300.json`) is ignored by older versions, and plans keep their format.
|
||||
|
||||
APK: `outputs/revision-0.108.0`, signed. **Not installed.**
|
||||
@@ -0,0 +1,128 @@
|
||||
2026-11-03 TUE 20:00 123 min Godzilla | |
|
||||
2026-11-03 TUE 23:00 106 min Predator | |
|
||||
2026-11-04 WED 20:00 113 min Edge of Tomorrow | |
|
||||
2026-11-04 WED 23:00 115 min Alien | |
|
||||
2026-11-05 THU 20:00 116 min Back to the Future | |
|
||||
2026-11-05 THU 23:00 127 min Bram Stoker's Dracula | |
|
||||
2026-11-06 FRI 20:00 106 min Gremlins | |
|
||||
2026-11-06 FRI 21:46 102 min Gremlins 2: The New Batch | |
|
||||
2026-11-06 FRI 23:45 124 min Thesis | |
|
||||
2026-11-07 SAT 19:00 81 min Toy Story | |
|
||||
2026-11-07 SAT 20:21 92 min Toy Story 2 | |
|
||||
2026-11-07 SAT 21:53 102 min Toy Story 3 | |
|
||||
2026-11-08 SUN 20:00 137 min Terminator 2: Judgment Day | |
|
||||
2026-11-08 SUN 23:00 115 min Invasion of the Body Snatchers | |
|
||||
2026-11-09 MON 20:00 100 min Dark City | |
|
||||
2026-11-09 MON 23:00 222 min Extra Weird Sampler | |
|
||||
2026-11-10 TUE 20:00 112 min Cliffhanger | |
|
||||
2026-11-10 TUE 23:00 97 min Braindead | |
|
||||
2026-11-11 WED 20:00 93 min First Blood | |
|
||||
2026-11-11 WED 23:00 86 min Evil Dead II | |
|
||||
2026-11-12 THU 20:00 108 min Deadpool | |
|
||||
2026-11-12 THU 23:00 104 min Deep Red | |
|
||||
2026-11-13 FRI 20:00 126 min The Karate Kid | |
|
||||
2026-11-13 FRI 22:06 113 min The Karate Kid Part II | |
|
||||
2026-11-14 SAT 00:30 108 min The Thing | |
|
||||
2026-11-14 SAT 19:00 93 min Mad Max | |
|
||||
2026-11-14 SAT 20:33 95 min Mad Max 2 | |
|
||||
2026-11-14 SAT 22:08 120 min Mad Max: Fury Road | |
|
||||
2026-11-15 SUN 20:00 128 min Live Free or Die Hard | |
|
||||
2026-11-15 SUN 23:00 92 min Repo Man | |
|
||||
2026-11-16 MON 20:00 136 min The Matrix | |
|
||||
2026-11-16 MON 23:00 113 min Rapine | |
|
||||
2026-11-17 TUE 20:00 101 min Mortal Kombat | |
|
||||
2026-11-17 TUE 23:00 95 min The Cabin in the Woods | |
|
||||
2026-11-18 WED 20:00 127 min The Empire Strikes Back | |
|
||||
2026-11-18 WED 23:00 97 min Mr. Vampire | |
|
||||
2026-11-19 THU 20:00 122 min Star Trek Beyond | |
|
||||
2026-11-19 THU 23:00 111 min Cure | |
|
||||
2026-11-20 FRI 20:00 101 min John Wick | |
|
||||
2026-11-20 FRI 21:41 122 min John Wick: Chapter 2 | |
|
||||
2026-11-21 SAT 00:00 96 min Terror Train | |
|
||||
2026-11-21 SAT 19:00 99 min The Hangover | |
|
||||
2026-11-21 SAT 20:39 101 min The Hangover Part II | |
|
||||
2026-11-21 SAT 22:21 100 min The Hangover Part III | |
|
||||
2026-11-22 SUN 20:00 125 min Mrs. Doubtfire | |
|
||||
2026-11-22 SUN 23:00 88 min Tucker and Dale vs. Evil | |
|
||||
2026-11-23 MON 20:00 98 min London Has Fallen | |
|
||||
2026-11-23 MON 23:00 127 min Dawn of the Dead | |
|
||||
2026-11-24 TUE 20:00 92 min The Transporter | |
|
||||
2026-11-24 TUE 23:00 97 min Repo! The Genetic Opera | |
|
||||
2026-11-25 WED 20:00 101 min The Raid | |
|
||||
2026-11-25 WED 23:00 108 min Terrorizers | |
|
||||
2026-11-26 THU 20:00 103 min Ferris Bueller's Day Off | |
|
||||
2026-11-26 THU 23:00 88 min Kliwon Friday Night | |
|
||||
2026-11-27 FRI 20:00 110 min Kill Bill: Vol. 1 | |
|
||||
2026-11-27 FRI 21:50 137 min Kill Bill: Vol. 2 | |
|
||||
2026-11-28 SAT 00:30 85 min Critters | |
|
||||
2026-11-28 SAT 19:00 178 min The Lord of the Rings: The Fellowship of the Ring | |
|
||||
2026-11-28 SAT 21:58 235 min The Lord of the Rings: The Two Towers | |
|
||||
2026-11-29 SUN 01:53 201 min The Lord of the Rings: The Return of the King | |
|
||||
2026-11-29 SUN 20:00 100 min The Last Starfighter | |
|
||||
2026-11-29 SUN 23:00 100 min It Follows | |
|
||||
2026-11-30 MON 20:00 93 min The Dark Crystal | |
|
||||
2026-11-30 MON 23:00 106 min The Vanishing | |
|
||||
2026-12-01 TUE 20:00 106 min Gremlins | |
|
||||
2026-12-01 TUE 23:00 103 min Little Shop of Horrors | |
|
||||
2026-12-02 WED 20:00 125 min Her | |
|
||||
2026-12-02 WED 23:00 89 min Poison for the Fairies | |
|
||||
2026-12-03 THU 20:00 119 min Tomorrow Never Dies | |
|
||||
2026-12-03 THU 23:00 105 min Insidious: Chapter 2 | |
|
||||
2026-12-04 FRI 20:00 121 min Spider-Man | |
|
||||
2026-12-04 FRI 22:01 127 min Spider-Man 2 | |
|
||||
2026-12-05 SAT 00:30 80 min It Came from Outer Space | |
|
||||
2026-12-05 SAT 19:00 116 min Back to the Future | |
|
||||
2026-12-05 SAT 20:56 108 min Back to the Future Part II | |
|
||||
2026-12-05 SAT 22:44 118 min Back to the Future Part III | |
|
||||
2026-12-06 SUN 20:00 132 min V for Vendetta | |
|
||||
2026-12-06 SUN 23:00 74 min The Night Stalker | |
|
||||
2026-12-07 MON 20:00 169 min John Wick: Chapter 4 | |
|
||||
2026-12-07 MON 23:15 90 min Final Destination 2 | |
|
||||
2026-12-08 TUE 20:00 94 min Can't Buy Me Love | |
|
||||
2026-12-08 TUE 23:00 93 min Hostel | |
|
||||
2026-12-09 WED 20:00 93 min Rocky IV | |
|
||||
2026-12-09 WED 23:00 84 min Frankenhooker | |
|
||||
2026-12-10 THU 20:00 114 min E.T. the Extra-Terrestrial | |
|
||||
2026-12-10 THU 23:00 111 min The Haunting | |
|
||||
2026-12-11 FRI 20:00 102 min Home Alone | |
|
||||
2026-12-11 FRI 21:42 120 min Home Alone 2: Lost in New York | |
|
||||
2026-12-12 SAT 00:00 124 min Red Dragon | |
|
||||
2026-12-12 SAT 19:00 122 min Star Wars | |
|
||||
2026-12-12 SAT 21:02 127 min The Empire Strikes Back | |
|
||||
2026-12-12 SAT 23:09 133 min Return of the Jedi | |
|
||||
2026-12-13 SUN 20:00 132 min The Blues Brothers | |
|
||||
2026-12-13 SUN 23:00 87 min Zombieland | |
|
||||
2026-12-14 MON 20:00 132 min Die Hard | |
|
||||
2026-12-14 MON 23:00 97 min The Hitcher | |
|
||||
2026-12-15 TUE 20:00 99 min Uncle Buck | |
|
||||
2026-12-15 TUE 23:00 131 min It | |
|
||||
2026-12-16 WED 20:00 118 min Bad Boys | |
|
||||
2026-12-16 WED 23:00 85 min Critters | |
|
||||
2026-12-17 THU 20:00 110 min Kill Bill: Vol. 1 | |
|
||||
2026-12-17 THU 23:00 113 min Angel Heart | |
|
||||
2026-12-18 FRI 20:00 109 min Lethal Weapon | |
|
||||
2026-12-18 FRI 21:49 114 min Lethal Weapon 2 | |
|
||||
2026-12-19 SAT 00:00 92 min Final Destination 3 | |
|
||||
2026-12-19 SAT 19:00 115 min Raiders of the Lost Ark | |
|
||||
2026-12-19 SAT 20:55 118 min Indiana Jones and the Temple of Doom | |
|
||||
2026-12-19 SAT 22:53 126 min Indiana Jones and the Last Crusade | |
|
||||
2026-12-20 SUN 20:00 96 min Back to School | |
|
||||
2026-12-20 SUN 23:00 134 min Twin Peaks: Fire Walk with Me | |
|
||||
2026-12-21 MON 20:00 120 min Collateral | |
|
||||
2026-12-21 MON 23:00 105 min The Orphanage | |
|
||||
2026-12-22 TUE 20:00 117 min Lethal Weapon 3 | |
|
||||
2026-12-22 TUE 23:00 134 min Lost Highway | |
|
||||
2026-12-23 WED 20:00 96 min 48 Hrs. | |
|
||||
2026-12-23 WED 23:00 83 min Hatchet | |
|
||||
2026-12-24 THU 20:00 93 min Source Code | |
|
||||
2026-12-24 THU 23:00 86 min The Stuff | |
|
||||
2026-12-25 FRI 20:00 93 min Trainspotting | |
|
||||
2026-12-25 FRI 21:33 117 min T2 Trainspotting | |
|
||||
2026-12-26 SAT 00:00 91 min Harold and Maude | |
|
||||
2026-12-26 SAT 19:00 99 min Shaun of the Dead | |
|
||||
2026-12-26 SAT 20:39 120 min Hot Fuzz | |
|
||||
2026-12-26 SAT 22:40 109 min The World's End | |
|
||||
2026-12-27 SUN 20:00 89 min Bill & Ted's Excellent Adventure | |
|
||||
2026-12-27 SUN 23:00 150 min Count Dracula | |
|
||||
|
||||
notes: late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1
|
||||
@@ -0,0 +1,131 @@
|
||||
2026-11-03 TUE 20:00 123 min Godzilla | |
|
||||
2026-11-03 TUE 23:00 106 min Predator | |
|
||||
2026-11-04 WED 20:00 113 min Edge of Tomorrow | |
|
||||
2026-11-04 WED 23:00 115 min Alien | |
|
||||
2026-11-05 THU 20:00 116 min Back to the Future | |
|
||||
2026-11-05 THU 23:00 127 min Bram Stoker's Dracula | |
|
||||
2026-11-06 FRI 20:00 106 min Gremlins | |
|
||||
2026-11-06 FRI 21:46 102 min Gremlins 2: The New Batch | |
|
||||
2026-11-06 FRI 23:45 124 min Thesis | |
|
||||
2026-11-07 SAT 19:00 81 min Toy Story | |
|
||||
2026-11-07 SAT 20:21 92 min Toy Story 2 | |
|
||||
2026-11-07 SAT 21:53 102 min Toy Story 3 | |
|
||||
2026-11-08 SUN 20:00 137 min Terminator 2: Judgment Day | |
|
||||
2026-11-08 SUN 23:00 115 min Invasion of the Body Snatchers | |
|
||||
2026-11-09 MON 20:00 100 min Dark City | |
|
||||
2026-11-09 MON 23:00 222 min Extra Weird Sampler | |
|
||||
2026-11-10 TUE 20:00 112 min Cliffhanger | |
|
||||
2026-11-10 TUE 23:00 97 min Braindead | |
|
||||
2026-11-11 WED 20:00 93 min First Blood | |
|
||||
2026-11-11 WED 23:00 86 min Evil Dead II | |
|
||||
2026-11-12 THU 20:00 108 min Deadpool | |
|
||||
2026-11-12 THU 23:00 105 min Insidious: Chapter 2 | |
|
||||
2026-11-13 FRI 20:00 83 min The Curse of Frankenstein | event: Hammer Horror Double Bill; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-13 FRI 21:23 82 min Dracula | event: Hammer Horror Double Bill; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-13 FRI 23:15 114 min Let the Right One In | |
|
||||
2026-11-14 SAT 19:00 74 min Dracula | event: Universal Monsters Takeover; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-14 SAT 20:14 70 min Frankenstein | event: Universal Monsters Takeover; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-14 SAT 21:24 69 min The Wolf Man | event: Universal Monsters Takeover; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-15 SUN 20:00 115 min Barbie | |
|
||||
2026-11-15 SUN 23:00 108 min Terrorizers | |
|
||||
2026-11-16 MON 20:00 137 min Logan | |
|
||||
2026-11-16 MON 23:00 102 min Barbarian | |
|
||||
2026-11-17 TUE 20:00 125 min Mr. & Mrs. Smith | |
|
||||
2026-11-17 TUE 23:00 92 min Tales from the Crypt | event: Amicus Tales of Terror; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-18 WED 00:32 101 min The House That Dripped Blood | event: Amicus Tales of Terror; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-18 WED 20:00 118 min Star Trek IV: The Voyage Home | |
|
||||
2026-11-18 WED 23:00 107 min Misery | |
|
||||
2026-11-19 THU 20:00 99 min Hard Target | |
|
||||
2026-11-19 THU 23:00 95 min Ring | event: J-Horror After Midnight; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-20 FRI 00:35 92 min Ju-on: The Grudge | event: J-Horror After Midnight; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-20 FRI 20:00 116 min The 36th Chamber of Shaolin | event: Shaw Brothers Kung Fu Double; curated, approved 4 November 2026 |
|
||||
2026-11-20 FRI 21:56 105 min Five Fingers of Death | event: Shaw Brothers Kung Fu Double; curated, approved 4 November 2026 |
|
||||
2026-11-21 SAT 00:00 92 min Children of the Corn | |
|
||||
2026-11-21 SAT 19:00 99 min The Hangover | |
|
||||
2026-11-21 SAT 20:39 101 min The Hangover Part II | |
|
||||
2026-11-21 SAT 22:21 100 min The Hangover Part III | |
|
||||
2026-11-22 SUN 20:00 137 min Close Encounters of the Third Kind | |
|
||||
2026-11-22 SUN 23:00 104 min Re-Animator | |
|
||||
2026-11-23 MON 20:00 144 min Casino Royale | |
|
||||
2026-11-23 MON 23:00 133 min The Conjuring 2 | |
|
||||
2026-11-24 TUE 20:00 126 min Star Trek | |
|
||||
2026-11-24 TUE 23:00 147 min Mulholland Drive | |
|
||||
2026-11-25 WED 20:00 170 min Heat | |
|
||||
2026-11-25 WED 23:15 90 min The Return of the Living Dead | |
|
||||
2026-11-26 THU 20:00 120 min Mad Max: Fury Road | |
|
||||
2026-11-26 THU 23:00 82 min The Toxic Avenger | event: Troma Midnight Madness; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-27 FRI 00:22 85 min Class of Nuke 'Em High | event: Troma Midnight Madness; curated, approved 4 November 2026 | drivein.fright
|
||||
2026-11-27 FRI 20:00 110 min Kill Bill: Vol. 1 | |
|
||||
2026-11-27 FRI 21:50 137 min Kill Bill: Vol. 2 | |
|
||||
2026-11-28 SAT 00:30 104 min The Others | |
|
||||
2026-11-28 SAT 19:00 91 min Commando | event: Arnie Triple Bill; curated, approved 4 November 2026 | drivein.arnie
|
||||
2026-11-28 SAT 20:31 113 min Total Recall | event: Arnie Triple Bill; curated, approved 4 November 2026 | drivein.arnie
|
||||
2026-11-28 SAT 22:25 100 min The Running Man | event: Arnie Triple Bill; curated, approved 4 November 2026 | drivein.arnie
|
||||
2026-11-29 SUN 20:00 100 min The Last Starfighter | |
|
||||
2026-11-29 SUN 23:00 83 min The Blob | |
|
||||
2026-11-30 MON 20:00 93 min The Dark Crystal | |
|
||||
2026-11-30 MON 23:00 115 min Let Me In | |
|
||||
2026-12-01 TUE 20:00 105 min Ghostbusters | |
|
||||
2026-12-01 TUE 23:00 79 min Robin Redbreast | |
|
||||
2026-12-02 WED 20:00 125 min Her | |
|
||||
2026-12-02 WED 23:00 88 min Penda's Fen | |
|
||||
2026-12-03 THU 20:00 106 min Quantum of Solace | |
|
||||
2026-12-03 THU 23:00 120 min Scream 2 | |
|
||||
2026-12-04 FRI 20:00 121 min Spider-Man | |
|
||||
2026-12-04 FRI 22:01 127 min Spider-Man 2 | |
|
||||
2026-12-05 SAT 00:30 107 min Withnail & I | |
|
||||
2026-12-05 SAT 19:00 86 min My Neighbor Totoro | event: Studio Ghibli Saturday; curated, approved 4 November 2026 |
|
||||
2026-12-05 SAT 20:26 103 min Kiki's Delivery Service | event: Studio Ghibli Saturday; curated, approved 4 November 2026 |
|
||||
2026-12-05 SAT 22:10 124 min Spirited Away | event: Studio Ghibli Saturday; curated, approved 4 November 2026 |
|
||||
2026-12-06 SUN 20:00 136 min A Clockwork Orange | |
|
||||
2026-12-06 SUN 23:00 123 min Santa Sangre | |
|
||||
2026-12-07 MON 20:00 88 min Taken 2 | |
|
||||
2026-12-07 MON 23:00 74 min The Night Stalker | |
|
||||
2026-12-08 TUE 20:00 94 min Can't Buy Me Love | |
|
||||
2026-12-08 TUE 23:00 93 min Hostel | |
|
||||
2026-12-09 WED 20:00 90 min Rush Hour 2 | |
|
||||
2026-12-09 WED 23:00 93 min My Bloody Valentine | |
|
||||
2026-12-10 THU 20:00 108 min Ghostbusters II | |
|
||||
2026-12-10 THU 23:00 100 min The Innocents | |
|
||||
2026-12-11 FRI 20:00 109 min Lethal Weapon | |
|
||||
2026-12-11 FRI 21:49 114 min Lethal Weapon 2 | |
|
||||
2026-12-12 SAT 00:00 124 min Red Dragon | |
|
||||
2026-12-12 SAT 19:00 93 min First Blood | event: Rambo Marathon; curated, approved 4 November 2026 |
|
||||
2026-12-12 SAT 20:33 95 min Rambo: First Blood Part II | event: Rambo Marathon; curated, approved 4 November 2026 |
|
||||
2026-12-12 SAT 22:09 101 min Rambo III | event: Rambo Marathon; curated, approved 4 November 2026 |
|
||||
2026-12-13 SUN 20:00 170 min Heat | |
|
||||
2026-12-13 SUN 23:15 88 min Kliwon Friday Night | |
|
||||
2026-12-14 MON 20:00 91 min Commando | |
|
||||
2026-12-14 MON 23:00 86 min Angst | |
|
||||
2026-12-15 TUE 20:00 111 min Escape Plan | |
|
||||
2026-12-15 TUE 23:00 131 min It | |
|
||||
2026-12-16 WED 20:00 99 min Rocky III | |
|
||||
2026-12-16 WED 23:00 85 min Critters | |
|
||||
2026-12-17 THU 20:00 97 min Moon | |
|
||||
2026-12-17 THU 23:00 113 min Angel Heart | |
|
||||
2026-12-18 FRI 20:00 93 min Trainspotting | |
|
||||
2026-12-18 FRI 21:33 117 min T2 Trainspotting | |
|
||||
2026-12-19 SAT 00:00 90 min Final Destination 2 | |
|
||||
2026-12-19 SAT 19:00 130 min It's a Wonderful Life | event: Christmas at the Drive-In; curated, approved 4 November 2026 | drivein.christmas
|
||||
2026-12-19 SAT 21:10 102 min Home Alone | event: Christmas at the Drive-In; curated, approved 4 November 2026 | drivein.christmas
|
||||
2026-12-19 SAT 22:53 96 min Elf | event: Christmas at the Drive-In; curated, approved 4 November 2026 | drivein.christmas
|
||||
2026-12-20 SUN 20:00 169 min Interstellar | |
|
||||
2026-12-20 SUN 23:15 115 min Smile | |
|
||||
2026-12-21 MON 20:00 137 min Terminator 2: Judgment Day | |
|
||||
2026-12-21 MON 23:00 91 min A Nightmare on Elm Street | |
|
||||
2026-12-22 TUE 20:00 126 min Iron Man | |
|
||||
2026-12-22 TUE 23:00 134 min Lost Highway | |
|
||||
2026-12-23 WED 20:00 164 min The Hobbit: The Battle of the Five Armies | |
|
||||
2026-12-23 WED 23:00 100 min I Know What You Did Last Summer | |
|
||||
2026-12-24 THU 20:00 116 min Time Bandits | |
|
||||
2026-12-24 THU 23:00 90 min The Return of the Living Dead | |
|
||||
2026-12-25 FRI 20:00 113 min 28 Days Later | |
|
||||
2026-12-25 FRI 21:53 100 min 28 Weeks Later | |
|
||||
2026-12-26 SAT 00:00 124 min El Topo | |
|
||||
2026-12-26 SAT 19:00 99 min Shaun of the Dead | |
|
||||
2026-12-26 SAT 20:39 120 min Hot Fuzz | |
|
||||
2026-12-26 SAT 22:40 109 min The World's End | |
|
||||
2026-12-27 SUN 20:00 91 min The Witches | |
|
||||
2026-12-27 SUN 23:00 86 min The Autopsy of Jane Doe | |
|
||||
|
||||
notes: late excluded=1; late excluded=1; event=hammer booked; late excluded=1; event=universal booked; late excluded=1; late excluded=1; late excluded=1; event=amicus booked; late excluded=1; late excluded=1; event=jhorror booked; late excluded=1; event=shaw booked; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; event=troma booked; late excluded=1; late excluded=1; event=arnie booked; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; event=ghibli booked; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; event=rambo booked; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; event=christmas booked; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1; late excluded=1
|
||||
Reference in New Issue
Block a user