diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d8f2d4a..0230aca 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 159 - versionName = "0.109.1-advent" + versionCode = 160 + versionName = "0.110.0-naughty-list" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/AdventOnOrbit.kt b/android/app/src/main/java/tv/orbit/classic/AdventOnOrbit.kt index 9880298..199ac2d 100644 --- a/android/app/src/main/java/tv/orbit/classic/AdventOnOrbit.kt +++ b/android/app/src/main/java/tv/orbit/classic/AdventOnOrbit.kt @@ -77,3 +77,39 @@ internal fun adventHeld(zone: ZoneId = ZoneId.systemDefault()): (PoolSeries, Lon return { film, atMs -> doors.any { (door, doorMs) -> (film.id == door.film.itemId || seriesKey(film.title) == seriesKey(door.film.title)) && kotlin.math.abs(atMs - doorMs) < ADVENT_HOLD_MS } } } + +/** + * After Dark: The Naughty List (approved by Paul on 4 November 2026; docs/after-dark-naughty-list-proposal.md): adult Christmas + * horror on weeknights at 23:00, kept apart from the family Advent. Built exactly as Advent's doors are: fixed slots from + * films verified on the box, each held out of every rotation either side of its night. + */ +internal class NaughtyNight(val at: LocalDateTime, val film: EventFilm) + +private fun night(day: Int) = LocalDateTime.of(2026, Month.DECEMBER, day, 23, 0) + +internal val NaughtyList: List = listOf( + NaughtyNight(night(3), EventFilm("Gremlins", 1984, "tt0087363", "5669960")), + NaughtyNight(night(10), EventFilm("Krampus", 2015, "tt3850590", "6387682")), + NaughtyNight(night(17), EventFilm("Better Watch Out", 2016, "tt4443658", "6398602")), + NaughtyNight(night(21), EventFilm("Saint", 2010, "tt1167675", "7317412")), + NaughtyNight(night(22), EventFilm("Anna and the Apocalypse", 2017, "tt6433880", "6045853")), + NaughtyNight(night(23), EventFilm("Black Christmas", 1974, "tt0071222", "6053560"))) + +internal const val NAUGHTY_WHY = "naughty list: " +internal const val NAUGHTY_KEY = "afterdark.naughty" + +internal fun naughtySlots(channel: Int, films: Map, zone: ZoneId = ZoneId.systemDefault()): List = + if (channel != AFTER_DARK_NUMBER) emptyList() else NaughtyList.mapNotNull { night -> + val film = films[night.film.itemId] ?: return@mapNotNull null + FixedSlot(night.at.atZone(zone).toInstant().toEpochMilli(), film, "$NAUGHTY_WHY${night.film.title}; fixed, approved 4 November 2026", NAUGHTY_KEY) + } + +/** Every December fixed film, Advent's and the Naughty List's, as (film, its time). */ +internal fun seasonalFilms(): List> = AdventDoors.map { it.film to it.at } + NaughtyList.map { it.film to it.at } + +/** [adventHeld] for every seasonal fixed film: Advent's doors and the Naughty List's nights. */ +internal fun seasonalHeld(zone: ZoneId = ZoneId.systemDefault()): (PoolSeries, Long) -> Boolean { + val fixed = seasonalFilms().map { (film, at) -> film to at.atZone(zone).toInstant().toEpochMilli() } + return { film, atMs -> fixed.any { (fixedFilm, fixedMs) -> + (film.id == fixedFilm.itemId || seriesKey(film.title) == seriesKey(fixedFilm.title)) && kotlin.math.abs(atMs - fixedMs) < ADVENT_HOLD_MS } } +} diff --git a/android/app/src/main/java/tv/orbit/classic/BlockIdents.kt b/android/app/src/main/java/tv/orbit/classic/BlockIdents.kt index 1b6c535..ba04734 100644 --- a/android/app/src/main/java/tv/orbit/classic/BlockIdents.kt +++ b/android/app/src/main/java/tv/orbit/classic/BlockIdents.kt @@ -55,6 +55,8 @@ internal val BlockIdents: Map = mapOf( // Advent on Orbit (approved 4 November 2026): one shared door for Doors 1 to 24, numbered and filled from the booking, and Door 25's own. ADVENT_DOOR_KEY to BlockIdent(R.raw.block_advent_door, R.raw.block_advent_door_sting, BlockOverlay.AdventDoor), ADVENT_DOOR_25_KEY to BlockIdent(R.raw.block_advent_door25, R.raw.block_advent_door25_sting, BlockOverlay.AdventDoor25), + // After Dark: The Naughty List (approved 4 November 2026): the whole title is in the clip. + NAUGHTY_KEY to BlockIdent(R.raw.block_afterdark_naughty, R.raw.block_afterdark_naughty_sting), ) + RingXNights.keys.associateWith { BlockIdent(R.raw.block_ringx_nights, R.raw.block_ringx_nights_sting, BlockOverlay.RingXNight) } /** Orbit's own names for Ring X's weekly nights (approved 3 November 2026), and each night's rope colour. */ 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 39e576b..c20b129 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -80,25 +80,24 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl 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") }, alsoHeld = adventHeld()) ?: return existing + events = events, eventFilms = eventFilms, note = { Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER $it") }, alsoHeld = seasonalHeld()) ?: 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 Advent doors' verified films, read at most once a day, and only from mid-November, when the first door comes inside + * The Advent doors' and the Naughty List's verified films, read at most once a day, and only from mid-November, when the first door comes inside * a week's booking. A read that fails keeps the last verified list; with no verified film, a door simply isn't booked. */ private fun adventFilms(userId: String, session: EmbySampleSession, now: Long): List { - val first = AdventDoors.minOf { it.at.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() } - val last = AdventDoors.maxOf { it.at.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() } - if (now < first - SCHEDULE_AHEAD_MS - 2 * 24 * 60 * 60_000L || now > last) return emptyList() - val signature = "advent:" + AdventDoors.joinToString(",") { it.film.itemId } + val times = seasonalFilms().map { it.second.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() } + if (now < times.min() - SCHEDULE_AHEAD_MS - 2 * 24 * 60 * 60_000L || now > times.max()) return emptyList() + val signature = "advent:" + seasonalFilms().joinToString(",") { it.first.itemId } val saved = store.loadPool(userId, ADVENT_POOL)?.takeIf { it.signature == signature } if (saved != null && now - saved.fetchedAtMs < 24 * 60 * 60_000L) return saved.series return try { - val checks = session.readFilmChecks(AdventDoors.map { it.film to null }) + val checks = session.readFilmChecks(seasonalFilms().map { it.first to null }) Log.i("OrbitSchedule", "advent films=${checks.size} " + checks.values.groupingBy { it.reason }.eachCount().entries.joinToString(" ") { "${it.key.replace(' ', '_')}=${it.value}" }) withoutFailed(checks.values.mapNotNull { it.film }, store.loadFailed(userId)).also { store.savePool(userId, ADVENT_POOL, ScheduleStore.SavedPool(it, now, signature)) } } catch (failure: EmbyCheckException) { @@ -200,13 +199,14 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl .also { othersMs = android.os.SystemClock.elapsedRealtime() - began } } // Advent on Orbit: this channel's doors as fixed slots, from films verified on the box; every film channel holds them back. - val fixed = if (programming.filmRestMs != null && AdventDoors.any { it.channel == number }) adventSlots(number, adventFilms(userId, session, now).associateBy { it.id }) else emptyList() + val fixed = if (programming.filmRestMs != null && (AdventDoors.any { it.channel == number } || number == AFTER_DARK_NUMBER)) + adventFilms(userId, session, now).associateBy { it.id }.let { films -> adventSlots(number, films) + naughtySlots(number, films) } else emptyList() val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts, avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes, mixed = programming::mixes, weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping, - fixed = fixed, held = adventHeld()) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.") - if (plan !== existing) plan.entries.filter { it.why.startsWith(ADVENT_WHY) && existing?.entries?.none { e -> e.startMs == it.startMs } != false } - .forEach { Log.i("OrbitSchedule", "channel=$number advent door=${it.why.removePrefix(ADVENT_WHY).substringBefore(';')} booked lateMin=${(it.startMs - (fixed.firstOrNull { f -> f.film.id == it.seriesId }?.startMs ?: it.startMs)) / 60_000}") } + fixed = fixed, held = seasonalHeld()) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.") + if (plan !== existing) plan.entries.filter { (it.why.startsWith(ADVENT_WHY) || it.why.startsWith(NAUGHTY_WHY)) && existing?.entries?.none { e -> e.startMs == it.startMs } != false } + .forEach { Log.i("OrbitSchedule", "channel=$number ${if (it.why.startsWith(ADVENT_WHY)) "advent door=" + it.why.removePrefix(ADVENT_WHY).substringBefore(';') else "naughty"} booked lateMin=${(it.startMs - (fixed.firstOrNull { f -> f.film.id == it.seriesId }?.startMs ?: it.startMs)) / 60_000}") } if (plan !== existing) store.savePlan(userId, number, plan) // Stage C, report only: every work any plan books, so "not yet shown on Orbit" has evidence. if (plan !== existing) runCatching { diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index a76b9db..e696e2c 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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.109.1\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.110.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 2fa4a2d..2c50423 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -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.109.1") + next.setRequestProperty("User-Agent", "OrbitTV/0.110.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()) { diff --git a/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt b/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt index 3ad381a..4e0508d 100644 --- a/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt +++ b/android/app/src/main/java/tv/orbit/classic/WhatsOnPage.kt @@ -45,6 +45,7 @@ internal fun whatsOnRule(channel: Int, entry: ScheduleEntry, zone: ZoneId): Trip "premiere: first airing on Orbit" in why -> Triple("FIRST ON ORBIT", "A first airing on Orbit", 100) // An Advent door, only ever from its confirmed booking; its number is the broadcast's own date. why.startsWith(ADVENT_WHY) -> adventDoorNumber(entry.startMs, zone)?.let { Triple("ADVENT ON ORBIT ยท DOOR $it", "Advent on Orbit", 90) } + why.startsWith(NAUGHTY_WHY) -> Triple("THE NAUGHTY LIST", "After Dark's Christmas nightmares", 85) entry.presentation.isNotEmpty() && why.startsWith("event") -> Triple(if (channel == 401) "SPECIAL EVENT" else "WEEKLY EVENT", "A booked channel event", 80) why.contains("story: part 1 of") -> Triple("COMPLETE STORY", "A whole multi-part story, back to back", 60) entry.media.item.type == "Movie" && channel in 300..399 && hour in 19..22 -> Triple("TONIGHT'S FILM", "An evening film on a film channel", 40) diff --git a/android/app/src/main/res/raw/block_afterdark_naughty.mp4 b/android/app/src/main/res/raw/block_afterdark_naughty.mp4 new file mode 100644 index 0000000..39ddb9d Binary files /dev/null and b/android/app/src/main/res/raw/block_afterdark_naughty.mp4 differ diff --git a/android/app/src/main/res/raw/block_afterdark_naughty_sting.m4a b/android/app/src/main/res/raw/block_afterdark_naughty_sting.m4a new file mode 100644 index 0000000..1cd5ae8 Binary files /dev/null and b/android/app/src/main/res/raw/block_afterdark_naughty_sting.m4a differ diff --git a/android/app/src/test/java/tv/orbit/classic/AdventOnOrbitTest.kt b/android/app/src/test/java/tv/orbit/classic/AdventOnOrbitTest.kt index 2ecdbad..994c2e3 100644 --- a/android/app/src/test/java/tv/orbit/classic/AdventOnOrbitTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/AdventOnOrbitTest.kt @@ -101,4 +101,20 @@ class AdventOnOrbitTest { val empty = whatsOnPage(mapOf(612 to unbooked), null, at(12, 1, 13), london) assertTrue((listOfNotNull(empty.pick) + empty.tonight + empty.comingUp).none { it.tag.startsWith("ADVENT") }) } + + @Test fun theNaughtyListIsAfterDarksOwnAndApartFromAdvent() { + assertEquals(6, NaughtyList.size) + assertTrue(NaughtyList.all { it.at.hour == 23 && it.at.month == java.time.Month.DECEMBER && it.at.dayOfWeek !in setOf(java.time.DayOfWeek.FRIDAY, java.time.DayOfWeek.SATURDAY, java.time.DayOfWeek.SUNDAY) }) + assertTrue(NaughtyList.none { n -> AdventDoors.any { it.film.itemId == n.film.itemId } }) + val krampus = film("Krampus", 97, "6387682") + assertEquals(1, naughtySlots(AFTER_DARK_NUMBER, mapOf(krampus.id to krampus), london).size) + assertTrue(naughtySlots(612, mapOf(krampus.id to krampus), london).isEmpty()) + assertEquals(NAUGHTY_KEY, naughtySlots(AFTER_DARK_NUMBER, mapOf(krampus.id to krampus), london).single().presentation) + // Gremlins (3 December) is held on VHS and everywhere else around its night, and Klaus still for Advent. + val held = seasonalHeld(london) + assertTrue(held(film("Gremlins", 106, "5669960"), at(11, 25, 12))) + assertFalse(held(film("Gremlins", 106, "5669960"), at(11, 10, 12))) + assertTrue(held(klaus, at(12, 1, 12))) + assertTrue(BlockIdents.containsKey(NAUGHTY_KEY) && BlockIdents.containsKey(ADVENT_DOOR_KEY) && BlockIdents.containsKey(ADVENT_DOOR_25_KEY)) + } } diff --git a/android/app/src/test/java/tv/orbit/classic/AdventReplayTest.kt b/android/app/src/test/java/tv/orbit/classic/AdventReplayTest.kt index fa37bd1..10e3c28 100644 --- a/android/app/src/test/java/tv/orbit/classic/AdventReplayTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/AdventReplayTest.kt @@ -48,7 +48,7 @@ class AdventReplayTest { val films = (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 held = if (advent) adventHeld(zone) else { _: PoolSeries, _: Long -> false } + val held = if (advent) seasonalHeld(zone) else { _: PoolSeries, _: Long -> false } val channels = SmartChannels.sortedBy { it.number }.filter { File(dir, "schedule-${it.number}.json").exists() && File(dir, "episodes-${it.number}.json").exists() } val plans = channels.associate { it.number to plan(File(dir, "schedule-${it.number}.json"))!! }.toMutableMap() val pools = channels.associate { it.number to pool(File(dir, "episodes-${it.number}.json")) } @@ -59,7 +59,7 @@ class AdventReplayTest { for (programming in channels) { val number = programming.number val others = clashesWith(plans.filterKeys { it != number }.values.toList()) - val fixed = if (advent && programming.filmRestMs != null) adventSlots(number, films, zone) else emptyList() + val fixed = if (advent && programming.filmRestMs != null) adventSlots(number, films, zone) + naughtySlots(number, films, zone) else emptyList() val next = topUpPlan(plans[number], ScheduleCursor(), pools[number], now, programming.filmRestMs, programming.finishTogether, programming.dayParts, zone, others, shuffle = programming.shuffleEpisodes, mixed = programming::mixes, weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping, fixed = fixed, held = held) ?: continue @@ -82,10 +82,17 @@ class AdventReplayTest { out.appendLine("Door %2d %-3d %s %s".format(door.door, door.channel, door.film.title, if (booked == null) "NOT BOOKED" else "booked %s %02d:%02d (%+d min) %d min, key %s".format(at!!.toLocalDate(), at.hour, at.minute, (booked.startMs - doorStarts[door.door]!!) / 60_000, booked.media.durationMs / 60_000, booked.presentation))) } + for (night in NaughtyList) { + val booked = with[AFTER_DARK_NUMBER].orEmpty().firstOrNull { it.why.startsWith(NAUGHTY_WHY) && it.seriesId == night.film.itemId } + val at = booked?.let { Instant.ofEpochMilli(it.startMs).atZone(zone) } + val due = night.at.atZone(zone).toInstant().toEpochMilli() + out.appendLine("Naughty %s %s".format(night.film.title, if (booked == null) "NOT BOOKED" else "booked %s %02d:%02d (%+d min) %d min, key %s".format(at!!.toLocalDate(), at.hour, at.minute, (booked.startMs - due) / 60_000, booked.media.durationMs / 60_000, booked.presentation))) + } // Checks: no overlaps anywhere; committed bookings kept; no door film in any rotation within its hold; nothing changed on channels without doors before the holds. val overlaps = with.mapValues { (_, e) -> e.sortedBy { it.startMs }.zipWithNext().count { (a, b) -> a.endMs > b.startMs } }.filterValues { it > 0 } - val holdBreaks = with.flatMap { (number, entries) -> entries.filter { e -> !e.why.startsWith(ADVENT_WHY) && AdventDoors.any { d -> - (e.seriesId == d.film.itemId || seriesKey(e.media.item.title) == seriesKey(d.film.title)) && kotlin.math.abs(e.startMs - doorStarts[d.door]!!) < ADVENT_HOLD_MS } }.map { "$number ${it.media.item.title}" } } + val fixedTimes = seasonalFilms().map { (film, at) -> film to at.atZone(zone).toInstant().toEpochMilli() } + val holdBreaks = with.flatMap { (number, entries) -> entries.filter { e -> !e.why.startsWith(ADVENT_WHY) && !e.why.startsWith(NAUGHTY_WHY) && fixedTimes.any { (film, ms) -> + (e.seriesId == film.itemId || seriesKey(e.media.item.title) == seriesKey(film.title)) && kotlin.math.abs(e.startMs - ms) < ADVENT_HOLD_MS } }.map { "$number ${it.media.item.title}" } } .filter { line -> AdventDoors.none { d -> line.endsWith(d.film.title) } || true } val committed = SmartChannels.mapNotNull { p -> plan(File(dir, "schedule-${p.number}.json"))?.let { p.number to it } } .count { (number, plan) -> with[number]?.take(plan.entries.size) != plan.entries } @@ -109,6 +116,14 @@ class AdventReplayTest { out.appendLine(" %02d:%02d %3d min %s | %s".format(t.hour, t.minute, e.media.durationMs / 60_000, e.media.item.title, e.why.take(140))) } } + for (night in NaughtyList.filter { n -> with[AFTER_DARK_NUMBER].orEmpty().none { it.why.startsWith(NAUGHTY_WHY) && it.seriesId == n.film.itemId } }) { + val at = night.at.atZone(zone).toInstant().toEpochMilli() + out.appendLine("around ${night.film.title}:") + with[AFTER_DARK_NUMBER].orEmpty().filter { it.endMs > at - 6 * 60 * 60_000L && it.startMs < at + 60 * 60_000L }.forEach { e -> + val t = Instant.ofEpochMilli(e.startMs).atZone(zone) + out.appendLine(" %02d:%02d %3d min %s | %s".format(t.hour, t.minute, e.media.durationMs / 60_000, e.media.item.title, e.why.take(150))) + } + } File(dir, "advent-doors.txt").writeText(out.toString()) println(out) } diff --git a/docs/VERIFICATION-0.110.0.md b/docs/VERIFICATION-0.110.0.md new file mode 100644 index 0000000..710acbd --- /dev/null +++ b/docs/VERIFICATION-0.110.0.md @@ -0,0 +1,49 @@ +# 0.110.0: After Dark: The Naughty List, built and replayed, NOT installed + +This is built on 0.109.1 (Advent), as its own release. Paul approved the six films and dates, the ident and the tagline on 4 November 2026. **Not installed; nothing booked.** + +## What it adds + +**Six fixed slots on After Dark (305)** at 23:00, all weeknights clear of After Dark's Friday to Sunday events (`NaughtyList` in `AdventOnOrbit.kt`): + +| Date | Film | +|---|---| +| Thu 3 Dec | Gremlins | +| Thu 10 Dec | Krampus | +| Thu 17 Dec | Better Watch Out | +| Mon 21 Dec | Saint | +| Tue 22 Dec | Anna and the Apocalypse | +| Wed 23 Dec | Black Christmas (1974) | + +**The same mechanism as the Advent doors:** +- Each film is pinned by item and IMDb ID and verified on the box in the same daily read, which now covers 31 films. +- A film that fails isn't booked, and the slot only appends. +- `seasonalHeld` holds both lists' films out of every film channel's rotation, and the Drive-In's, for 14 days either side. That holds Gremlins on VHS and the Drive-In, and Black Christmas on Orbit Horror. +- None of these films is in Advent, and none of Advent's is here. + +**Presentation:** +- Key `afterdark.naughty`, with the approved preview's clip as `res/raw/block_afterdark_naughty.mp4` plus its sting. AFTER DARK, THE NAUGHTY LIST and "Not everyone makes the nice list." are in the clip. +- What's On shows **THE NAUGHTY LIST** for a booked night only. + +## Checks + +- 488 unit tests run, 0 failures. One new test covers: the six as approved, After Dark's alone, kept apart from Advent, the holds, and the idents present. Lint is unchanged. +- **Replay on the box's own files with Advent and The Naughty List together** (`docs/replays/advent-and-naughty-0.110.0.txt`): + +| Night | Starts | Late by | +|---|---|---| +| Gremlins | 23:11 | 11 min | +| Krampus | 23:01 | 1 min | +| Better Watch Out | 23:04 | 4 min | +| Saint | 23:01 | 1 min | +| Anna and the Apocalypse | 23:00 | on time | +| Black Christmas | 23:07 | 7 min | + + - **All six nights are booked.** + - **All 25 Advent doors are still booked.** With Gremlins also held on VHS, VHS's pool is smaller, so **Door 17 (Santa Claus: The Movie) starts at 18:28, 28 minutes late**. Doors 12 and 22 on Premiere are +16 and +14, as in 0.109.1. Every other door is within 4 minutes. + - No overlaps. + - 0 committed plans changed. + - 0 held films in any rotation. +- **Rollback:** reinstall 0.109.1, which has Advent only. A night already booked stays booked as an ordinary film. + +APK: `outputs/revision-0.110.0`, signed. **Not installed.** diff --git a/docs/replays/advent-and-naughty-0.110.0.txt b/docs/replays/advent-and-naughty-0.110.0.txt new file mode 100644 index 0000000..71b78c7 --- /dev/null +++ b/docs/replays/advent-and-naughty-0.110.0.txt @@ -0,0 +1,45 @@ +Door 1 612 The Polar Express booked 2026-12-01 18:01 (+1 min) 99 min, key advent.door +Door 2 306 Scrooged booked 2026-12-02 18:04 (+4 min) 101 min, key advent.door +Door 3 309 Get Santa booked 2026-12-03 18:01 (+1 min) 102 min, key advent.door +Door 4 301 Fred Claus booked 2026-12-04 18:03 (+3 min) 115 min, key advent.door +Door 5 306 Home Alone 2: Lost in New York booked 2026-12-05 18:02 (+2 min) 120 min, key advent.door +Door 6 612 Klaus booked 2026-12-06 18:01 (+1 min) 98 min, key advent.door +Door 7 301 The Christmas Chronicles booked 2026-12-07 18:00 (+0 min) 103 min, key advent.door +Door 8 306 The Santa Clause booked 2026-12-08 18:01 (+1 min) 97 min, key advent.door +Door 9 309 A Boy Called Christmas booked 2026-12-09 18:03 (+3 min) 106 min, key advent.door +Door 10 612 The Nightmare Before Christmas booked 2026-12-10 18:03 (+3 min) 76 min, key advent.door +Door 11 306 Jingle All the Way booked 2026-12-11 18:01 (+1 min) 93 min, key advent.door +Door 12 301 How the Grinch Stole Christmas booked 2026-12-12 18:16 (+16 min) 104 min, key advent.door +Door 13 612 Arthur Christmas booked 2026-12-13 18:01 (+1 min) 97 min, key advent.door +Door 14 306 A Christmas Story booked 2026-12-14 18:03 (+3 min) 93 min, key advent.door +Door 15 309 Nativity! booked 2026-12-15 18:01 (+1 min) 105 min, key advent.door +Door 16 612 A Christmas Carol booked 2026-12-16 18:03 (+3 min) 95 min, key advent.door +Door 17 306 Santa Claus: The Movie booked 2026-12-17 18:28 (+28 min) 108 min, key advent.door +Door 18 301 Red One booked 2026-12-18 18:04 (+4 min) 124 min, key advent.door +Door 19 306 National Lampoon's Christmas Vacation booked 2026-12-19 17:01 (+1 min) 97 min, key advent.door +Door 20 612 The Grinch booked 2026-12-20 18:01 (+1 min) 85 min, key advent.door +Door 21 309 Last Christmas booked 2026-12-21 18:02 (+2 min) 102 min, key advent.door +Door 22 301 White Christmas booked 2026-12-22 18:14 (+14 min) 120 min, key advent.door +Door 23 309 Scrooge booked 2026-12-23 18:00 (+0 min) 86 min, key advent.door +Door 24 301 Miracle on 34th Street booked 2026-12-24 18:03 (+3 min) 96 min, key advent.door +Door 25 612 The Muppet Christmas Carol booked 2026-12-25 17:02 (+2 min) 86 min, key advent.door25 +Naughty Gremlins booked 2026-12-03 23:11 (+11 min) 106 min, key afterdark.naughty +Naughty Krampus booked 2026-12-10 23:01 (+1 min) 97 min, key afterdark.naughty +Naughty Better Watch Out booked 2026-12-17 23:04 (+4 min) 89 min, key afterdark.naughty +Naughty Saint booked 2026-12-21 23:01 (+1 min) 87 min, key afterdark.naughty +Naughty Anna and the Apocalypse booked 2026-12-22 23:00 (+0 min) 108 min, key afterdark.naughty +Naughty Black Christmas booked 2026-12-23 23:07 (+7 min) 97 min, key afterdark.naughty + +overlaps: {} +committed plans changed: 0 +door films in rotation within the hold: 0 [] +channels whose bookings differ from the run without Advent: [301, 304, 305, 306, 309, 602, 603, 612, 613] + 301 first differs at 2026-12-04T13:07:46.770: Apollo 13 -> Mission: Impossible - Fallout | whole pool; picked from 20 rested films, last shown 205h ago; chosen so the fixed slot at 18:00 starts on time + 304 first differs at 2026-12-05T08:45:00.524: The Ring -> The Others | no block, whole pool; picked from 21 rested films, last shown 264h ago; deferred 1 airing elsewhere + 305 first differs at 2026-12-03T20:57:35.110: Critters -> Donnie Darko | block day; picked from 7 rested films, last shown 126h ago; chosen so the fixed slot at 23:00 starts on time + 306 first differs at 2026-11-24T02:23:34.603: Gremlins -> Mac and Me | whole pool; fallback: no film past its rest, so the longest waiting + 309 first differs at 2026-12-03T13:38:09.271: Control -> Control | whole pool; fallback: no film past its rest, so the longest waiting; chosen so the fixed slot at 18:00 starts + 602 first differs at 2026-12-20T08:01:02.325: Static Shock / S4 E8 / Now You See Him... -> The Batman / S3 E3 / A Dark Knight to Remember | block 6-15; weighted: lowest tally for its tier (412), others resting or just played + 603 first differs at 2026-12-28T10:36:23.734: The Transformers / S3 E13 / Madman's Paradise -> Exosquad / S2 E16 / Miracle | block 6-18; weighted: lowest tally for its tier (528), others resting or just played + 612 first differs at 2026-11-24T11:54:53.225: Klaus -> Castle in the Sky | whole pool; fallback: no film past its rest, so the longest waiting + 613 first differs at 2026-11-22T01:08:03.394: The Polar Express -> How to Train Your Dragon | whole pool; fallback: no film past its rest, so the longest waiting