diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 83296e2..b6c106d 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 = 173 - versionName = "0.108.13-arcade" + versionCode = 174 + versionName = "0.108.14-event-ideas" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } 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 82fa603..7334f08 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -134,7 +134,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl o.optString("name") to o.optJSONArray("members")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty() } else emptyList() val studios = EventStudios.associateWith { catalogue.filmsByStudio(it.first) } val read = catalogue.ideaFilms(lines.keys).map(::enrich) - val held = seasonalFilms().map { it.first.itemId } + ApprovedDebuts.map { it.film.itemId } + val held = SeasonHeldFilms val eventFilms = DriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet() + held val seeds = ideaSeeds(collections, studios, read, eventFilms) var films = catalogue.ideaFilms(seeds.flatMap { it.members }).associateBy { it.id }.mapValues { enrich(it.value) } diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt index 5ad1b59..e02a984 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt @@ -11,3 +11,57 @@ internal const val PREMIERE_BOOKING_ACTIVE = false internal const val PREMIERE_WINDOW_MS = 28 * 24 * 60 * 60_000L /** A theatrical premiere's debut: its own label in What's On, apart from a series' FIRST ON ORBIT. */ internal const val PREMIERE_WHY = "orbit premiere: debut" + +/** + * The headline events Orbit has scheduled for the season (Advent's doors, The Naughty List's nights and the approved premieres, + * which arrive with the Advent release), as (what, when, hours): what a Drive-In event idea mustn't compete with on this line. + */ +private val SeasonHeadlines: List> = listOf( + Triple("Advent's Door 1", java.time.LocalDateTime.of(2026, 12, 1, 18, 0), 2), + Triple("Advent's Door 2", java.time.LocalDateTime.of(2026, 12, 2, 18, 0), 2), + Triple("Advent's Door 3", java.time.LocalDateTime.of(2026, 12, 3, 18, 0), 2), + Triple("Advent's Door 4", java.time.LocalDateTime.of(2026, 12, 4, 18, 0), 2), + Triple("Advent's Door 5", java.time.LocalDateTime.of(2026, 12, 5, 18, 0), 2), + Triple("Advent's Door 6", java.time.LocalDateTime.of(2026, 12, 6, 18, 0), 2), + Triple("Advent's Door 7", java.time.LocalDateTime.of(2026, 12, 7, 18, 0), 2), + Triple("Advent's Door 8", java.time.LocalDateTime.of(2026, 12, 8, 18, 0), 2), + Triple("Advent's Door 9", java.time.LocalDateTime.of(2026, 12, 9, 18, 0), 2), + Triple("Advent's Door 10", java.time.LocalDateTime.of(2026, 12, 10, 18, 0), 2), + Triple("Advent's Door 11", java.time.LocalDateTime.of(2026, 12, 11, 18, 0), 2), + Triple("Advent's Door 12", java.time.LocalDateTime.of(2026, 12, 12, 18, 0), 2), + Triple("Advent's Door 13", java.time.LocalDateTime.of(2026, 12, 13, 18, 0), 2), + Triple("Advent's Door 14", java.time.LocalDateTime.of(2026, 12, 14, 18, 0), 2), + Triple("Advent's Door 15", java.time.LocalDateTime.of(2026, 12, 15, 18, 0), 2), + Triple("Advent's Door 16", java.time.LocalDateTime.of(2026, 12, 16, 18, 0), 2), + Triple("Advent's Door 17", java.time.LocalDateTime.of(2026, 12, 17, 18, 0), 2), + Triple("Advent's Door 18", java.time.LocalDateTime.of(2026, 12, 18, 18, 0), 2), + Triple("Advent's Door 19", java.time.LocalDateTime.of(2026, 12, 19, 17, 0), 2), + Triple("Advent's Door 20", java.time.LocalDateTime.of(2026, 12, 20, 18, 0), 2), + Triple("Advent's Door 21", java.time.LocalDateTime.of(2026, 12, 21, 18, 0), 2), + Triple("Advent's Door 22", java.time.LocalDateTime.of(2026, 12, 22, 18, 0), 2), + Triple("Advent's Door 23", java.time.LocalDateTime.of(2026, 12, 23, 18, 0), 2), + Triple("Advent's Door 24", java.time.LocalDateTime.of(2026, 12, 24, 18, 0), 2), + Triple("Advent's Door 25", java.time.LocalDateTime.of(2026, 12, 25, 17, 0), 2), + Triple("The Naughty List's Gremlins", java.time.LocalDateTime.of(2026, 12, 3, 23, 0), 2), + Triple("The Naughty List's Krampus", java.time.LocalDateTime.of(2026, 12, 10, 23, 0), 2), + Triple("The Naughty List's Better Watch Out", java.time.LocalDateTime.of(2026, 12, 17, 23, 0), 2), + Triple("The Naughty List's Saint", java.time.LocalDateTime.of(2026, 12, 21, 23, 0), 2), + Triple("The Naughty List's Anna and the Apocalypse", java.time.LocalDateTime.of(2026, 12, 22, 23, 0), 2), + Triple("The Naughty List's Black Christmas", java.time.LocalDateTime.of(2026, 12, 23, 23, 0), 2), + Triple("the premiere of Avatar: Fire and Ash", java.time.LocalDateTime.of(2026, 11, 27, 20, 0), 3), + Triple("the premiere of Spider-Man: Brand New Day", java.time.LocalDateTime.of(2026, 12, 11, 20, 0), 3)) + +/** Films held for the season's fixed events on this line: an event idea never uses them. */ +internal val SeasonHeldFilms: Set = setOf("5688746", "5706930", "5642871", "6048904", "7416998", "5847706", "5848529", "5684856", "6399934", "5848040", "6062933", "5848691", "5689513", "6259980", "6056877", "6035893", "6933347", "6948638", "5848022", "5689633", "6067366", "6048026", "7485376", "5688583", "5847993", "5669960", "6387682", "6398602", "7317412", "6045853", "6053560", "7789551", "8006401") + +/** + * Editorial competition (Paul, 5 November 2026): whether a slot from [startMs] to [endMs] shares its airtime with another headline + * event anywhere on Orbit (a Drive-In event, an Advent door, a Naughty List night, a premiere). Returns what it competes with, or null. + */ +internal fun competingEvent(startMs: Long, endMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), except: String? = null): String? { + fun ms(t: java.time.LocalDateTime) = t.atZone(zone).toInstant().toEpochMilli() + val hour = 60 * 60_000L + val events = DriveInEvents.map { e -> Triple("the Drive-In's ${e.name}", ms(e.evening.atTime(e.hour, e.minute)), ms(e.evening.atTime(e.hour, e.minute)) + e.films.size * 2 * hour) } + + SeasonHeadlines.filter { except == null || !it.first.contains(except) }.map { (what, at, hours) -> Triple(what, ms(at), ms(at) + hours * hour) } + return events.firstOrNull { (_, s, e) -> s < endMs && startMs < e }?.first +} 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 e393713..5be21ab 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.108.13\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.108.14\"") 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/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index 58985f6..b9305a8 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -229,8 +229,11 @@ 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): Map { - val films = events.flatMap { event -> event.films.map { it to event.person } }.distinctBy { it.first.itemId } + fun readEventFilms(events: List): Map = readFilmChecks(events.flatMap { event -> event.films.map { it to event.person } }) + + /** Exact films, each with the person its cast must include (or null), read and checked as [readEventFilms] does. */ + fun readFilmChecks(listed: List>): Map { + val films = listed.distinctBy { it.first.itemId } val ids = films.map { it.first.itemId }.filter(EmbyClient::validId) if (ids.isEmpty()) return emptyMap() val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Ids=${ids.joinToString(",")}&Fields=MediaSources,ProviderIds,ProductionYear,People" + 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 03c2efe..9275a83 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.108.13") + next.setRequestProperty("User-Agent", "OrbitTV/0.108.14") 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()) {