diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 480a53a..154c02f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,6 +13,8 @@ android { versionName = "0.111.0-advent-premieres" } buildFeatures { compose = true } + // Android logging and the like do nothing in unit tests, so whole flows (a channel read from a fake server) can run. + testOptions { unitTests.isReturnDefaultValues = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } } dependencies { 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 199ac2d..e368cc5 100644 --- a/android/app/src/main/java/tv/orbit/classic/AdventOnOrbit.kt +++ b/android/app/src/main/java/tv/orbit/classic/AdventOnOrbit.kt @@ -20,7 +20,10 @@ private const val PREMIERE = 301 private const val BRITX = 309 private fun dec(day: Int, hour: Int = 18) = LocalDateTime.of(2026, Month.DECEMBER, day, hour, 0) -internal val AdventDoors: List = listOf( +/** Advent's doors are Paul's, by item on his server (portability, 6 November 2026): no other server has them yet. */ +internal val AdventDoors: List get() = if (Workspaces.originalOpen) PaulsAdventDoors else emptyList() + +private val PaulsAdventDoors: List = listOf( AdventDoor(1, WONDER, dec(1), EventFilm("The Polar Express", 2004, "tt0338348", "5688746")), AdventDoor(2, VHS, dec(2), EventFilm("Scrooged", 1988, "tt0096061", "5706930")), AdventDoor(3, BRITX, dec(3), EventFilm("Get Santa", 2014, "tt1935940", "5642871")), @@ -87,7 +90,10 @@ 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( +/** The Naughty List is Paul's, by item on his server: no other server has it yet. */ +internal val NaughtyList: List get() = if (Workspaces.originalOpen) PaulsNaughtyList else emptyList() + +private val PaulsNaughtyList: 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")), diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt index d894a14..42675bb 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt @@ -12,10 +12,9 @@ internal data class CatalogueSummary(val films: Long, val series: Long, val next val records get() = films + series } -/** Library metadata for one signed-in account, in app-private storage excluded from backup. Holds no login. */ -internal class CatalogueStore private constructor(context: Context) : CatalogueSink, - SQLiteOpenHelper(context, File(context.noBackupFilesDir, "catalogue.db").path, null, 2) { - private val file = File(context.noBackupFilesDir, "catalogue.db") +/** Library metadata for one signed-in account on one server ([ServerWorkspace]), in app-private storage excluded from backup. Holds no login. */ +internal class CatalogueStore(context: Context, private val file: File) : CatalogueSink, + SQLiteOpenHelper(context, file.path, null, 2) { override fun onCreate(db: SQLiteDatabase) { db.execSQL("CREATE TABLE items(id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, year INTEGER, rating TEXT NOT NULL, genres TEXT NOT NULL, studios TEXT NOT NULL, provider_ids TEXT NOT NULL, premiere TEXT NOT NULL, runtime_ms INTEGER, original_title TEXT NOT NULL, tags TEXT NOT NULL)") db.execSQL("CREATE INDEX items_type_title ON items(type, title)") @@ -150,10 +149,21 @@ internal class CatalogueStore private constructor(context: Context) : CatalogueS close() if (file.exists() && !SQLiteDatabase.deleteDatabase(file)) throw IllegalStateException("The catalogue could not be removed.") } + /** + * The library items each IMDb ID belongs to: every copy, so duplicates can be weighed rather than one picked blindly. + * Read only. An ID that isn't in the catalogue has no entry. + */ + @Synchronized fun idsByImdb(imdbs: Collection): Map> = imdbs.map { it.lowercase() }.distinct() + .filter { it.matches(Regex("tt\\d{5,10}")) }.associateWith { imdb -> + readableDatabase.rawQuery("SELECT id, provider_ids FROM items WHERE type = 'Movie' AND provider_ids LIKE ?", arrayOf("%\"$imdb\"%")).use { c -> + buildList { while (c.moveToNext()) if (imdbOf(c.getString(1)) == imdb) add(c.getString(0)) } + } + }.filterValues { it.isNotEmpty() } companion object { - @Volatile private var instance: CatalogueStore? = null - fun get(context: Context) = instance ?: synchronized(this) { - instance ?: CatalogueStore(context.applicationContext).also { instance = it } - } + /** The open server's catalogue. */ + fun get(context: Context): CatalogueStore = Workspaces.current(context).catalogue + /** The IMDb ID in a stored provider_ids record, lower case, or null. */ + fun imdbOf(providerIds: String): String? = runCatching { JSONObject(providerIds) }.getOrNull()?.let { o -> + o.keys().asSequence().firstOrNull { it.equals("Imdb", true) }?.let { o.optString(it).trim().lowercase() } }?.takeIf { it.isNotEmpty() } } } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index abf8c89..b53d5d4 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -69,13 +69,18 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se val catalogueScaleHours: Double? = null, /** With [catalogueScaleHours]: the least share of its tier's weight a short catalogue keeps. */ val catalogueScaleFloor: Double = MIN_CATALOGUE_SCALE, - /** Library items taken off this channel by editorial decision, by their Emby id, whichever way they reached its pool. */ - val withdrawn: Set = emptySet(), + /** + * Library items taken off this channel by Paul's editorial decisions, by their Emby id on the original server, whichever + * way they reached its pool. Ids belong to one server, so they apply only there: see [withdrawn]. + */ + val withdrawnOnOriginal: Set = emptySet(), /** * Multi-part stories booked whole, in order and back to back ([storiesOf], [extendWeighted]); weighted blocks only. Paradox * only (Living Network Stage A, approved 4 November 2026). */ val storyGrouping: Boolean = false) { + /** The items taken off this channel on the server signed in to: [withdrawnOnOriginal] there, none anywhere else. */ + val withdrawn: Set get() = if (Workspaces.originalOpen) withdrawnOnOriginal else emptySet() /** A show's weight here, by its tier (a show the brief doesn't name counts as supporting), scaled by [catalogueScaleHours]. */ fun weightOf(show: PoolSeries): Double { val tier = tierWeights?.get(seriesKey(show.title)) ?: TIER_SUPPORTING @@ -176,7 +181,7 @@ private val AfterDarkFilms = listOf("The Thing (1982)", "The Evil Dead (1983)", internal val SmartChannels = listOf( ChannelProgramming(AFTER_DARK_NUMBER, "After Dark", AfterDarkFilms, seriesCount = AfterDarkFilms.size, filmSeries = listOf(LAST_DRIVE_IN) + AfterDarkAnthologies, filmRestMs = 7 * 24 * 60 * 60_000L, adaptiveFilmRest = true, - withdrawn = AfterDarkWithdrawn, + withdrawnOnOriginal = AfterDarkWithdrawn, dayParts = AfterDarkEvents + AfterDarkStrand + listOf(DayPart(21, 5), DayPart(5, 21, listOf("The 'Burbs", "'Til Death Do Us Part (2017)", "Tremors (1990)", "The Lost Boys (1987)", "Fright Night (1985)", "Poltergeist (1982)", "Scream (1996)", "Escape from New York", "They Live", "Repo Man", "Donnie Darko", "From Dusk Till Dawn", "Creepshow", "Near Dark", "Halloween (1978)", "The Fog (1980)", "Blue Velvet", "The Wicker Man (1973)", "An American Werewolf in London", "Prince of Darkness") + AfterDarkDaytimeCrossover, group = "day")), @@ -187,7 +192,7 @@ internal val SmartChannels = listOf( SuggestionRule("day", "Daytime", "Movie", listOf("Thriller", "Mystery"), 1975..2005, 6.5f, setOf("NC-17", "X"), 30, NotCultCinema + "Science Fiction", 70, 7.9f), SuggestionRule("late", "Late night", "Movie", listOf("Horror"), 1970..2010, 6f, emptySet(), 90, NotCultCinema, 70))), ChannelProgramming(VORTEX_NUMBER, "Vortex", VortexShows, seriesCount = 40, finishTogether = true, exactTitles = true, englishAudio = false, tierWeights = VortexTiers, catalogueScaleHours = CATALOGUE_SCALE_HOURS, - dayParts = VortexDayParts, withdrawn = setOf(BEERUS_OTHER_COPY), storyGrouping = true, + dayParts = VortexDayParts, withdrawnOnOriginal = setOf(BEERUS_OTHER_COPY), storyGrouping = true, suggestionRules = listOf( SuggestionRule("movie", "Movie Night", "Movie", listOf("Animation"), 1985..2025, 5f, setOf("NC-17", "X"), 30, setOf("Documentary"), 30, titleContains = "Dragon Ball"), SuggestionRule("movie", "Movie Night", "Movie", listOf("Animation", "Action"), 1985..2015, 7f, setOf("NC-17", "X"), 30, setOf("Documentary", "Family"), 60), 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 2e0ec4b..f04f021 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -17,7 +17,12 @@ internal class SchedulerReport(val programmes: Int, val episodes: Int, val shows fun logLine(number: Int) = "channel=$number programmes=$programmes episodes=$episodes shows=${shows.size} requests=$requests fetchMs=$fetchMs planMs=$planMs bytes=$bytes cache=$fromCache added=$added deferred=$deferred fallbacks=$fallbacks clashes=$clashes" } -internal class ChannelScheduler(private val store: ScheduleStore, private val clock: () -> Long = System::currentTimeMillis) { +internal class ChannelScheduler(private val store: ScheduleStore, + /** Every library item with each IMDb ID, from this server's catalogue (null until it's complete): how the curated events are found. */ + private val copies: (Collection) -> Map>? = { emptyMap() }, + /** The original server, whose season plans (Advent, the Naughty List, the premieres) are Paul's own: no other server has them. */ + private val original: Boolean = true, + private val clock: () -> Long = System::currentTimeMillis) { /** Disk only and quick, so the guide and player agree from the first frame after a restart. */ fun load(userId: String, loop: SampleChannel): ScheduledChannel? { if (store.loadProgress(userId, loop.number)?.enabled != true) return null @@ -139,9 +144,11 @@ 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 = if (original) seasonalFilms().map { it.first.itemId } + ApprovedDebuts.map { it.film.itemId } else emptyList() ApprovedDriveInEvents.refresh(historyFile) - val eventFilms = allDriveInEvents().flatMap { e -> e.films.map { it.itemId } }.toSet() + held + // Until the curated events can be found, no idea is proposed: one could take an evening that belongs to an event. + if (!CuratedDriveInEvents.refresh(copies)) { Log.i("OrbitSchedule", "event ideas waiting=catalogue"); return } + val eventFilms = allDriveInEvents().flatMap { e -> e.films.map { it.itemId } }.toSet() + CuratedDriveInEvents.copies.values.flatten() + held val seeds = ideaSeeds(collections, studios, read, eventFilms) var films = catalogue.ideaFilms(seeds.flatMap { it.members }).associateBy { it.id }.mapValues { enrich(it.value) } // Evidence for the candidates that have none yet, read only and bounded: a theme can't be judged without its ratings. @@ -169,7 +176,7 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl EventIdea(h.optString("signature").substringBeforeLast(':'), h.optString("theme"), h.optString("source"), runCatching { IdeaFormat.valueOf(h.optString("format")) }.getOrDefault(IdeaFormat.FridayDouble), h.optString("evening").takeIf { it.isNotEmpty() && it != "null" }?.let { LocalDateTime.parse(it) }, ids.mapNotNull { films[it] }, emptyList(), 0.0) } - val ideas = weeklyEventIdeas(verifiedSeeds, films, unavailable, { format, taken -> ideaEvening(format, today.plusDays(1), drivePlanEnd, taken, format.films * 2 * 60 * 60_000L, zone) }, + val ideas = weeklyEventIdeas(verifiedSeeds, films, unavailable, { format, taken -> ideaEvening(format, today.plusDays(1), drivePlanEnd, taken, format.films * 2 * 60 * 60_000L, zone, season = original) }, EventIdeaHistory.dismissed(historyFile), limit = (EVENT_IDEAS_PER_WEEK - shownThisWeek).coerceAtLeast(0), current = current) EventIdeaHistory.record(historyFile, ideas, now) val thisWeek = EventIdeaHistory.load(historyFile).filter(::live) @@ -191,10 +198,15 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl val avoid = clashesWith(SmartChannels.mapNotNull { store.loadPlan(userId, it.number) }) // The curated events and the ideas you've approved (Drive-In event ideas, phase 2), booked alike. ApprovedDriveInEvents.refresh(store.eventIdeaHistoryFile()) + // Bookings are never rewritten, so the Drive-In waits rather than book an event's evening as ordinary while its films can't be looked up. + if (!CuratedDriveInEvents.refresh(copies)) { Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER waiting=catalogue"); return existing } val events = allDriveInEvents().filter { !it.evening.isBefore(java.time.Instant.ofEpochMilli(now).atZone(java.time.ZoneId.systemDefault()).toLocalDate().minusDays(1)) } + CuratedDriveInEvents.missing.takeIf { it.isNotEmpty() }?.let { Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER eventsNotInLibrary=${it.size}") } val eventFilms = withoutFailed(eventFilms(userId, session, events, now), failed).associateBy { it.id } + // Each curated film's copy to book: the first this box can play. A film with none leaves its whole event out. + val booking = chooseEventCopies(events, CuratedDriveInEvents.copies, eventFilms.keys) 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 = seasonalHeld()) ?: return existing + events = booking, 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 @@ -242,11 +254,13 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl */ private fun eventFilms(userId: String, session: EmbySampleSession?, events: List, now: Long): List { if (events.isEmpty()) return emptyList() - val signature = "events:" + events.joinToString("|") { event -> event.key + "=" + event.films.joinToString(",") { it.itemId } } + // Every copy of a curated film is read, so a duplicate this box can't play doesn't sink the event. + fun copiesOf(event: DriveInEvent, film: EventFilm) = eventFilmCopies(event, film, CuratedDriveInEvents.copies) + val signature = eventSignature(events, CuratedDriveInEvents.copies) val saved = store.loadPool(userId, DRIVE_IN_NUMBER)?.takeIf { it.signature == signature } if (session == null || (saved != null && now - saved.fetchedAtMs < 24 * 60 * 60_000L)) return saved?.series.orEmpty() return try { - val checks = session.readEventFilms(events) + val checks = session.readFilmChecks(events.flatMap { event -> event.films.flatMap { f -> copiesOf(event, f).map { EventFilm(f.title, f.year, f.imdb, it) to event.person } } }) Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER eventFilms=${checks.size} " + checks.values.groupingBy { it.reason }.eachCount().entries.joinToString(" ") { "${it.key.replace(' ', '_')}=${it.value}" }) checks.values.mapNotNull { it.film }.also { store.savePool(userId, DRIVE_IN_NUMBER, ScheduleStore.SavedPool(it, now, signature)) } } catch (failure: EmbyCheckException) { diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt index c483f0d..815b5b5 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorPremiere.kt @@ -117,9 +117,12 @@ internal class ApprovedDebut(val call: PremiereCall, val film: EventFilm, val at fun windowEndMs(zone: ZoneId) = startMs(zone) + windowMs } -internal val ApprovedDebuts: List = listOf( - ApprovedDebut(PremiereCalls.single { it.itemId == "7789551" }, EventFilm("Avatar: Fire and Ash", 2025, "tt1757678", "7789551"), java.time.LocalDateTime.of(2026, 11, 27, 20, 0)), - ApprovedDebut(PremiereCalls.single { it.itemId == "8006401" }, EventFilm("Spider-Man: Brand New Day", 2026, "tt22084616", "8006401"), java.time.LocalDateTime.of(2026, 12, 11, 20, 0))) +/** Paul's approved debuts, by item on his server: none on any other. */ +internal val ApprovedDebuts: List get() = if (Workspaces.originalOpen) PaulsApprovedDebuts else emptyList() + +private val PaulsApprovedDebuts: List = listOf( + ApprovedDebut(PaulsPremiereCalls.single { it.itemId == "7789551" }, EventFilm("Avatar: Fire and Ash", 2025, "tt1757678", "7789551"), java.time.LocalDateTime.of(2026, 11, 27, 20, 0)), + ApprovedDebut(PaulsPremiereCalls.single { it.itemId == "8006401" }, EventFilm("Spider-Man: Brand New Day", 2026, "tt22084616", "8006401"), java.time.LocalDateTime.of(2026, 12, 11, 20, 0))) /** Where the approved premieres' verified films are kept, beside the channels' own pools. Not a channel. */ internal const val PREMIERE_POOL = 9301 @@ -158,12 +161,14 @@ internal fun premiereHeld(channel: Int, zone: ZoneId = ZoneId.systemDefault()): * Whether an evening 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 or another approved premiere. Returns what it competes with, or null. */ -internal fun competingEvent(startMs: Long, endMs: Long, zone: ZoneId = ZoneId.systemDefault(), except: String? = null): String? { +internal fun competingEvent(startMs: Long, endMs: Long, zone: ZoneId = ZoneId.systemDefault(), except: String? = null, + /** The season's headlines are Paul's plans for his own server; another server has only its Drive-In events. */ + season: Boolean = true): String? { fun ms(t: java.time.LocalDateTime) = t.atZone(zone).toInstant().toEpochMilli() val hour = 60 * 60_000L val events = allDriveInEvents().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) } + - AdventDoors.map { d -> Triple("Advent's Door ${d.door}", ms(d.at), ms(d.at) + 2 * hour) } + - NaughtyList.map { n -> Triple("The Naughty List's ${n.film.title}", ms(n.at), ms(n.at) + 2 * hour) } + - ApprovedDebuts.filter { it.film.itemId != except }.map { d -> Triple("the premiere of ${d.film.title}", d.startMs(zone), d.startMs(zone) + 3 * hour) } + AdventDoors.filter { season }.map { d -> Triple("Advent's Door ${d.door}", ms(d.at), ms(d.at) + 2 * hour) } + + NaughtyList.filter { season }.map { n -> Triple("The Naughty List's ${n.film.title}", ms(n.at), ms(n.at) + 2 * hour) } + + ApprovedDebuts.filter { season && it.film.itemId != except }.map { d -> Triple("the premiere of ${d.film.title}", d.startMs(zone), d.startMs(zone) + 3 * hour) } return events.firstOrNull { (_, s, e) -> s < endMs && startMs < e }?.first } diff --git a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt index f50ef0e..d8f1144 100644 --- a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt +++ b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt @@ -270,7 +270,7 @@ internal fun rememberFilmPicture(session: EmbySampleSession, itemId: String?): B value = null if (itemId != null) value = withContext(Dispatchers.IO) { runCatching { - loadArtwork(ArtworkCache(File(context.cacheDir, "artwork")), itemId, null, session::fetchArtwork)?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } + loadArtwork(ArtworkCache(Workspaces.current(context).artworkDir), itemId, null, session::fetchArtwork)?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } }.getOrNull() } } diff --git a/android/app/src/main/java/tv/orbit/classic/DriveInEvents.kt b/android/app/src/main/java/tv/orbit/classic/DriveInEvents.kt index fb18148..f2696cf 100644 --- a/android/app/src/main/java/tv/orbit/classic/DriveInEvents.kt +++ b/android/app/src/main/java/tv/orbit/classic/DriveInEvents.kt @@ -10,7 +10,9 @@ import java.time.LocalDate * 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 EventFilm(val title: String, val year: Int, val imdb: String, + /** The library item on the server signed in to, or "" in a template that hasn't been found in a library yet. */ + val itemId: String = "") internal class DriveInEvent(val key: String, val name: String, val evening: LocalDate, val hour: Int, val minute: Int, val films: List, /** An approved takeover identity, or "" for the Drive-In's own. */ @@ -30,28 +32,33 @@ internal val DriveInTakeovers: Map = mapOf( private fun night(date: String) = LocalDate.parse(date) +/** + * The curated events as Orbit's own templates (portability, Paul, 6 November 2026): each film by title, year and IMDb ID, + * checked against Paul's library when these were approved, never by one server's item. [CuratedDriveInEvents] finds them in + * the library signed in to. What anyone approves or books stays with their own server, apart from these. + */ internal val DriveInEvents: List = 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"), + EventFilm("The Curse of Frankenstein", 1957, "tt0050280"), EventFilm("Dracula", 1958, "tt0051554")), "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"), + EventFilm("Dracula", 1931, "tt0021814"), EventFilm("Frankenstein", 1931, "tt0021884"), EventFilm("The Wolf Man", 1941, "tt0034398")), "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"), + EventFilm("Tales from the Crypt", 1972, "tt0069341"), EventFilm("The House That Dripped Blood", 1971, "tt0065854")), "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"), + EventFilm("Ring", 1998, "tt0178868"), EventFilm("Ju-on: The Grudge", 2002, "tt0364385")), "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"))), + EventFilm("The 36th Chamber of Shaolin", 1978, "tt0078243"), EventFilm("Five Fingers of Death", 1972, "tt0070800"))), 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"), + EventFilm("The Toxic Avenger", 1984, "tt0090190"), EventFilm("Class of Nuke 'Em High", 1986, "tt0090849")), "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")), + EventFilm("Commando", 1985, "tt0088944"), EventFilm("Total Recall", 1990, "tt0100802"), EventFilm("The Running Man", 1987, "tt0093894")), "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"))), + EventFilm("My Neighbor Totoro", 1988, "tt0096283"), EventFilm("Kiki's Delivery Service", 1989, "tt0097814"), EventFilm("Spirited Away", 2001, "tt0245429"))), 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"))), + EventFilm("First Blood", 1982, "tt0083944"), EventFilm("Rambo: First Blood Part II", 1985, "tt0089880"), EventFilm("Rambo III", 1988, "tt0095956"))), 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")), + EventFilm("It's a Wonderful Life", 1946, "tt0038650"), EventFilm("Home Alone", 1990, "tt0099785"), EventFilm("Elf", 2003, "tt0319343")), "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). */ @@ -74,3 +81,68 @@ internal fun checkEventFilm(film: EventFilm, row: JSONObject?, person: String?, val lookup = resolveFilm(FilmIdentity(candidate.name, film.year, film.imdb), listOf(candidate), canDecode) return EventFilmCheck(lookup.film, if (lookup.film == null) "unplayable" else "verified") } + +/** The curated events' keys: an approved idea is always a different key. */ +internal val CuratedKeys: Set get() = DriveInEvents.map { it.key }.toSet() + +/** An event with its films found in a library. */ +internal fun DriveInEvent.withFilms(found: List) = DriveInEvent(key, name, evening, hour, minute, found, presentation, person) + +/** + * The curated events found in the library signed in to: each film by its IMDb ID, from the catalogue. An event with any film + * the library doesn't have isn't there at all: no partial bill, and nothing unrelated stands in. Where a film has several + * copies, every copy is kept in [copies] so booking can pick one this box plays; the event lists the first by item. + */ +internal object CuratedDriveInEvents { + @Volatile var list: List = emptyList() + @Volatile var copies: Map> = emptyMap() + @Volatile var missing: Map> = emptyMap() + /** + * Finds the events again through [lookup], which gives null while the catalogue isn't complete. Returns false, keeping what + * was found before, when the library can't be read yet: an evening is never booked as ordinary because a look-up failed. + */ + fun refresh(lookup: (Collection) -> Map>?): Boolean { + val found = runCatching { lookup(DriveInEvents.flatMap { e -> e.films.map { it.imdb } }) }.getOrNull() ?: return false + val resolved = resolveDriveInEvents(DriveInEvents, found) + list = resolved.events; copies = found; missing = resolved.missing + return true + } +} + +internal class ResolvedEvents(val events: List, + /** Each event left out, by key, with the IMDb IDs of the films the library doesn't have. */ + val missing: Map>) + +/** [templates] found through [copies] (IMDb ID to every library item with it): see [CuratedDriveInEvents]. */ +internal fun resolveDriveInEvents(templates: List, copies: Map>): ResolvedEvents { + val missing = mutableMapOf>() + val events = templates.mapNotNull { event -> + val absent = event.films.map { it.imdb.lowercase() }.filter { copies[it].isNullOrEmpty() } + if (absent.isNotEmpty()) { missing[event.key] = absent; return@mapNotNull null } + event.withFilms(event.films.map { f -> EventFilm(f.title, f.year, f.imdb, copies.getValue(f.imdb.lowercase()).filter(EmbyClient::validId).minWith(ItemOrder)) }) + } + return ResolvedEvents(events, missing) +} + +/** Library items in a steady order: numbers by value, then anything else by text. */ +private val ItemOrder = compareBy({ it.toLongOrNull() == null }, { it.toLongOrNull() ?: 0L }, { it }) + +/** + * Which copy of each event film to book, from the copies [verified] on this box: the first by item. An event with any film + * that has no verified copy is left out by the booking ([planDriveIn] needs every film). + */ +internal fun chooseEventCopies(events: List, copies: Map>, verified: Set): List = events.map { event -> + if (event.key !in CuratedKeys) return@map event + event.withFilms(event.films.map { f -> + val playable = copies[f.imdb.lowercase()].orEmpty().filter { it in verified }.sortedWith(ItemOrder) + EventFilm(f.title, f.year, f.imdb, playable.firstOrNull() ?: f.itemId) + }) +} + +/** Every copy an event film could be booked from: all of a curated film's copies, or an approved idea's own item. */ +internal fun eventFilmCopies(event: DriveInEvent, film: EventFilm, copies: Map>): List = + if (event.key in CuratedKeys) copies[film.imdb.lowercase()].orEmpty().ifEmpty { listOf(film.itemId) } else listOf(film.itemId) + +/** What the saved event films were read for: when it changes, they're read again. A film's copies are joined by "/". */ +internal fun eventSignature(events: List, copies: Map>): String = + "events:" + events.joinToString("|") { event -> event.key + "=" + event.films.joinToString(",") { eventFilmCopies(event, it, copies).sorted().joinToString("/") } } 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 229ff1c..2763a7f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -15,7 +15,9 @@ internal class EmbyReply(val status: Int, val body: String) internal fun interface EmbyTransport { fun execute(request: EmbyRequest): EmbyReply } /** Network boundary: fixed HTTPS origin, standard TLS checks, no redirects or response logging. */ -internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUID().toString()) : EmbyTransport { +internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUID().toString(), + /** The one server this transport ever talks to, fixed when it's made, so a session never follows a change of server. */ + internal val origin: String = EmbyServer.origin) : EmbyTransport { companion object { /** The server in use: see [EmbyServer]. */ val SERVER get() = EmbyServer.origin @@ -36,7 +38,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI /** One picture from the provider, or nothing. Same fixed origin and no redirects, and failures are never shown or logged. */ fun fetchImage(path: String, token: String): ByteArray? { require(path.startsWith("/") && !path.startsWith("//")) - val connection = URL("$SERVER/emby$path").openConnection() as HttpsURLConnection + val connection = URL("$origin/emby$path").openConnection() as HttpsURLConnection try { connection.instanceFollowRedirects = false connection.connectTimeout = 10_000 @@ -60,7 +62,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI override fun execute(request: EmbyRequest): EmbyReply { // Paths come from the protocol below, never from a provider response or a text field. require(request.path.startsWith("/") && !request.path.startsWith("//")) - val connection = URL("$SERVER/emby${request.path}").openConnection() as HttpsURLConnection + val connection = URL("$origin/emby${request.path}").openConnection() as HttpsURLConnection try { connection.requestMethod = request.method connection.instanceFollowRedirects = false diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index d4251ab..4384ac9 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -61,7 +61,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, mainWhatsOn: Boolean = false, mainCatchUp: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit, onHome: () -> Unit = {}, onDriveIn: () -> Unit = {}) { val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext - val savedStore = remember { RememberedConnectionStore.get(appContext) } + // Everything below belongs to the server signed in to ([ServerWorkspace]): another server's sign-in, catalogue and + // schedules are never open at the same time, and work begun for one keeps writing only to its own. + val workspace = Workspaces.current(appContext) + val savedStore = remember(workspace) { workspace.connection } var setupSaved by remember { mutableStateOf(false) } var pendingSaved by remember { mutableStateOf(null) } var restoring by remember { mutableStateOf(true) } @@ -80,7 +83,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var session by remember { mutableStateOf(null) } var darkMovies by remember { mutableStateOf>(emptyList()) } var approvedDark by remember { mutableStateOf>(emptySet()) } - val catalogue = remember { CatalogueStore.get(appContext) } + val catalogue = remember(workspace) { workspace.catalogue } var lastNumber by remember { mutableStateOf(null) } var afterDarkChannel by remember { mutableStateOf(null) } var toonamiChannel by remember { mutableStateOf(null) } @@ -101,8 +104,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var driveInVisits by remember { mutableIntStateOf((System.currentTimeMillis() / 60_000 % DRIVE_IN_SCENES).toInt()) } var guideCategory by remember { mutableStateOf(ChannelCategory.All) } val liveHandoff = remember { LiveHandoff() } - val scheduleStore = remember { java.io.File(appContext.noBackupFilesDir, "schedule").let { migrateChannelNumbers(it); ScheduleStore(it) } } - val scheduler = remember { ChannelScheduler(scheduleStore) } + val scheduleStore = remember(workspace) { workspace.schedules } + val scheduler = remember(workspace) { ChannelScheduler(scheduleStore, copies = { if (catalogue.complete()) catalogue.idsByImdb(it) else null }, original = workspace.original) } var scheduledChannels by remember { mutableStateOf>(emptyMap()) } var suggesting by remember { mutableStateOf(null) } var suggestions by remember { mutableStateOf>(emptyList()) } @@ -135,16 +138,22 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea selectedNumber = available.firstOrNull { it.number == saved.selectedNumber }?.number ?: available.firstOrNull()?.number ?: 101 onChannelAvailable(available.isNotEmpty()) } - fun closeSession(forget: Boolean = false) { + /** + * Ends the session. [signOut] also forgets the sign-in token, keeping this server's channels, schedules and catalogue for + * when you sign back in. [remove] takes all of this server's data off the box, and only this server's. + */ + fun closeSession(signOut: Boolean = false, remove: Boolean = false) { setupSaved = false savedStore.invalidate() - if (forget) { + if (remove) { pendingSaved = null - scheduleStore.clear() - try { savedStore.clear() } - catch (_: Exception) { message = "The saved connection could not be removed. Clear Orbit TV app data in Android Settings before leaving this device." } - try { catalogue.clear() } - catch (_: Exception) { message = "The library data could not be removed. Clear Orbit TV app data in Android Settings before leaving this device." } + if (!Workspaces.remove(workspace)) message = "Some of this server's data could not be removed. Clear Orbit TV app data in Android Settings before leaving this device." + if (Workspaces.known(appContext).isEmpty()) AfterDarkLock.clear(appContext) + } else if (signOut) { + pendingSaved = pendingSaved?.let { saved -> RememberedState(RememberedIdentity(saved.identity.userId, null, saved.identity.deviceId), saved.sample, saved.matches, + saved.approved, saved.episodes, saved.orbitOne, saved.toonami, saved.selectedNumber, saved.afterDark, saved.darkMovies, saved.approvedDark, saved.extra, saved.origin) } + try { savedStore.forgetToken() } + catch (_: Exception) { message = "The saved sign-in could not be removed. Clear Orbit TV app data in Android Settings before leaving this device." } } driveInPlan = null; tickets = emptyMap(); reminder = null; reminders = emptyList(); programmeReminder = null seriesMatches = null; episodeSamples = null; approvedSeries = emptySet() @@ -184,9 +193,9 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea if (saved?.identity?.token != null && session == null) { val slotTicket = sessionSlot.ticket() val resumed = withContext(Dispatchers.IO) { - // The server this sign-in was saved with. + // The server this sign-in was saved with: always this workspace's own. EmbyServer.origin = saved.origin - val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId)).resume(saved) + val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId, saved.origin)).resume(saved) if (!sessionSlot.attach(active, slotTicket)) { active.detach(); throw CancellationException() } active } @@ -237,7 +246,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea delay(100) try { val saved = RememberedState(active.rememberedIdentity(), active.sample, seriesMatches.orEmpty(), - approvedSeries, episodeSamples, channel, toonamiChannel, selectedNumber, afterDarkChannel, darkMovies, approvedDark, extraChannels) + approvedSeries, episodeSamples, channel, toonamiChannel, selectedNumber, afterDarkChannel, darkMovies, approvedDark, extraChannels, active.origin) val ticket = saveTicket if (withContext(Dispatchers.IO) { savedStore.save(saved, ticket) }) { pendingSaved = saved; setupSaved = true @@ -580,7 +589,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val active = session ?: return null return directorReports(scheduleStore, catalogue, active.accountId)?.get(mode) } - SettingsScreen(settingsSection, { settingsSection = it }, modifier, busy, message, HttpsEmbyTransport.SERVER, sample?.total, + SettingsScreen(settingsSection, { settingsSection = it }, modifier, busy, message, workspace.origin, sample?.total, arrivals = { detail, first -> NewArrivalsDetail(::loadArrivalsReport, first, detail) }, director = { detail, first -> DirectorChair(::loadDirectorReport, first, detail, place = { choice, option -> session?.let { active -> scope.launch(Dispatchers.IO) { runCatching { @@ -608,7 +617,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea decision == "deferred" -> "${idea.theme} deferred: it can come back in four weeks." else -> "${idea.theme} dismissed: it won't be proposed again unless its films change." } } }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) }, - onSignOut = { job?.cancel(); closeSession(forget = true); AfterDarkLock.clear(appContext) }, windingDown = windingDown, onWindDown = { on -> + onSignOut = { job?.cancel(); closeSession(signOut = true) }, onRemoveServer = { job?.cancel(); closeSession(remove = true) }, windingDown = windingDown, onWindDown = { on -> val tots = scheduledChannels[TOTS_NUMBER] val active = session if (tots != null && active != null) scope.launch(Dispatchers.IO) { @@ -686,21 +695,23 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea nextAttemptAt = System.currentTimeMillis() + 15_000 job = scope.launch { try { + var switched = false val connected = withContext(Dispatchers.IO) { // It must answer as an Emby server before the password goes anywhere. - val previous = EmbyServer.origin - EmbyServer.origin = server - try { EmbyClient().checkServer() } catch (e: Exception) { EmbyServer.origin = previous; throw e } - val opened = EmbyClient().connect(loginName, secret) + EmbyClient(HttpsEmbyTransport(origin = server)).checkServer() + // Another server opens its own workspace; this one's data stays exactly where it is. + if (server != Workspaces.current(appContext).origin) { Workspaces.open(appContext, server); switched = true } + val opened = EmbyClient(HttpsEmbyTransport(origin = server)).connect(loginName, secret) if (!sessionSlot.attach(opened, ticket)) { opened.close(); throw CancellationException() } opened } - val saved = pendingSaved + val saved = if (switched) withContext(Dispatchers.IO) { runCatching { Workspaces.current(appContext).connection.load() }.getOrNull() }.also { pendingSaved = it } + else pendingSaved if (saved != null && connected.matchesAccount(saved.identity)) { connected.restoreRemembered(saved); applyChoices(saved) } session = connected; sample = connected.sample - saveTicket = savedStore.ticket() + saveTicket = Workspaces.current(appContext).connection.ticket() } catch (cancelled: CancellationException) { throw cancelled } catch (failure: EmbyCheckException) { message = failure.message } @@ -712,8 +723,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea if (restoring) Text("Restoring your saved connection…", color = Yellow, fontSize = 14.sp) if (pendingSaved?.identity?.token != null) OrbitButton("Try the saved connection again", Modifier.fillMaxWidth(), enabled = !restoring && !busy, onClick = { restoreAttempt++ }) - if (pendingSaved != null) - OrbitButton("Forget the saved connection", Modifier.fillMaxWidth(), enabled = !restoring && !busy, onClick = { closeSession(forget = true) }) + if (pendingSaved?.identity?.token != null) + OrbitButton("Forget the saved sign-in", Modifier.fillMaxWidth(), enabled = !restoring && !busy, onClick = { closeSession(signOut = true) }) Text("Your password is never saved. This box only keeps a sign-in token, in encrypted storage.", color = Muted, fontSize = 12.sp) } } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index 01cc882..7c9d494 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -100,7 +100,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl var audioClockResets by remember { mutableIntStateOf(0) } var audioChosen by remember { mutableStateOf(false) } var trackProblem by remember { mutableStateOf(null) } - val notes = remember { java.io.File(context.noBackupFilesDir, "schedule") } + val notes = remember { Workspaces.current(context).scheduleDir } val openedAt = remember { SystemClock.elapsedRealtime() } var numberEntry by remember { mutableStateOf(null) } var numberProblem by remember { mutableStateOf(null) } 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 b15d34a..3007d4d 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -14,12 +14,13 @@ internal class EmbyPlaybackPlan(val item: SampleItem, val url: String, val sourc val playSessionId: String, val subtitles: List, val durationMs: Long? = item.durationMs) internal object PlaybackUrls { - fun isProvider(uri: URI): Boolean { - val origin = URI(EmbyServer.origin) + fun isProvider(uri: URI, server: String = EmbyServer.origin): Boolean { + val origin = URI(server) val port = if (origin.port == -1) 443 else origin.port return uri.scheme.equals("https", true) && uri.host.equals(origin.host, true) && (if (uri.port == -1) 443 else uri.port) == port } - fun resolve(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/", token: String = ""): String { + fun resolve(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/", token: String = "", server: String = EmbyServer.origin): String { + fun isProvider(uri: URI) = isProvider(uri, server) try { val uri = URI(base).resolve(value) if (!uri.scheme.equals("https", true) || uri.host.isNullOrBlank() || uri.userInfo != null || uri.fragment != null) @@ -56,6 +57,8 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v @Volatile private var active: EmbyPlaybackPlan? = null @Volatile private var lastPositionMs = 0L val accountId get() = userId + /** The server this session belongs to, for good: its media addresses and token go nowhere else. */ + val origin: String get() = (transport as? HttpsEmbyTransport)?.origin ?: EmbyServer.origin fun rememberedIdentity() = RememberedIdentity(userId, currentToken(), (transport as? HttpsEmbyTransport)?.deviceId ?: "fixture-device") fun matchesAccount(identity: RememberedIdentity) = userId == identity.userId @@ -75,10 +78,10 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v checkedEpisodes = emptyList(); candidateEpisodes = emptyMap(); channelEpisodes = emptyMap(); darkCandidates = emptyList(); darkLineup = emptyMap() } private fun currentToken() = synchronized(this) { token ?: throw EmbyCheckException("This sample session has ended. Sign in again.") } - fun mediaUrl(value: String, base: String = HttpsEmbyTransport.SERVER + "/emby/") = PlaybackUrls.resolve(value, base, currentToken()) + fun mediaUrl(value: String, base: String = "$origin/emby/") = PlaybackUrls.resolve(value, base, currentToken(), origin) fun mediaHeaders(url: String): Map { val auth = currentToken() - return if (PlaybackUrls.isProvider(URI(url))) mapOf("X-Emby-Token" to auth) else emptyMap() + return if (PlaybackUrls.isProvider(URI(url), origin)) mapOf("X-Emby-Token" to auth) else emptyMap() } /** Tuning shouldn't fail on one slow answer, so a timeout or dropped connection gets one more try. */ fun prepareForTuning(item: SampleItem, sourceId: String, pause: () -> Unit = {}): EmbyPlaybackPlan = try { @@ -117,7 +120,7 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v val playId = (if (info.isNull("PlaySessionId")) "" else info.optString("PlaySessionId")).ifBlank { java.util.UUID.randomUUID().toString() } if (!EmbyClient.validId(playId)) throw EmbyCheckException("The provider returned an unusable playback identity.") val container = requireNotNull(supportedDirectContainer(source.optString("Container"))) - val url = "${HttpsEmbyTransport.SERVER}/emby/Videos/${item.id}/stream.$container?Static=true&MediaSourceId=${encode(sourceId)}&PlaySessionId=${encode(playId)}" + val url = "$origin/emby/Videos/${item.id}/stream.$container?Static=true&MediaSourceId=${encode(sourceId)}&PlaySessionId=${encode(playId)}" val streams = source.optJSONArray("MediaStreams") val subtitles = (0 until minOf(streams?.length() ?: 0, 32)).mapNotNull { index -> val stream = streams?.optJSONObject(index) ?: return@mapNotNull null diff --git a/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt b/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt index eb58499..a119f78 100644 --- a/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt +++ b/android/app/src/main/java/tv/orbit/classic/EventIdeas.kt @@ -221,12 +221,14 @@ internal fun weeklyEventIdeas(seeds: List, films: Map, filmsMs: Long, zone: ZoneId = ZoneId.systemDefault()): LocalDateTime? { +internal fun ideaEvening(format: IdeaFormat, from: LocalDate, planEndMs: Long, taken: Set, filmsMs: Long, zone: ZoneId = ZoneId.systemDefault(), + /** Whether the original server's season plans (Advent and the rest) are on this server too: see [competingEvent]. */ + season: Boolean = true): LocalDateTime? { var day = from repeat(42) { if (day.dayOfWeek in format.days && allDriveInEvents().none { it.evening == day }) { val at = day.atTime(format.hour, 0); val ms = at.atZone(zone).toInstant().toEpochMilli() - if (ms > planEndMs && at !in taken && competingEvent(ms, ms + filmsMs, zone) == null) return at + if (ms > planEndMs && at !in taken && competingEvent(ms, ms + filmsMs, zone, season = season) == null) return at } day = day.plusDays(1) } @@ -247,8 +249,8 @@ internal object ApprovedDriveInEvents { fun refresh(historyFile: File) { list = EventIdeaHistory.approvedEvents(historyFile) } } -/** The curated events and the ideas you've approved: every Drive-In event. */ -internal fun allDriveInEvents(): List = DriveInEvents + ApprovedDriveInEvents.list +/** The curated events this library has ([CuratedDriveInEvents]) and the ideas you've approved: every Drive-In event. */ +internal fun allDriveInEvents(): List = CuratedDriveInEvents.list + ApprovedDriveInEvents.list /** The proposal history: every idea shown, when, and what you decided. Kept for explainable learning. */ internal object EventIdeaHistory { diff --git a/android/app/src/main/java/tv/orbit/classic/PlaybackTestActivity.kt b/android/app/src/main/java/tv/orbit/classic/PlaybackTestActivity.kt index d108e8b..b24b62c 100644 --- a/android/app/src/main/java/tv/orbit/classic/PlaybackTestActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/PlaybackTestActivity.kt @@ -90,7 +90,7 @@ private fun PlaybackTest(itemId: String, done: () -> Unit) { withContext(Dispatchers.IO) { val saved = RememberedConnectionStore.get(context).load() ?: throw EmbyCheckException("No saved connection.") EmbyServer.origin = saved.origin - val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId)).resume(saved) + val active = EmbyClient(HttpsEmbyTransport(saved.identity.deviceId, saved.origin)).resume(saved) session = active val item = SampleItem("Playback test", "Episode", null, "", itemId) active.rememberScheduledItems(listOf(item)) diff --git a/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt b/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt index 889f920..676d554 100644 --- a/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt +++ b/android/app/src/main/java/tv/orbit/classic/PremiereSelection.kt @@ -26,7 +26,10 @@ internal class PremiereCall(val itemId: String, val title: String, val premiere: /** The day of the call: it counts towards that week's three. */ val decidedOn: LocalDate = LocalDate.of(2026, 11, 5)) -internal val PremiereCalls = listOf( +/** Paul's calls, by item on the original server: on any other server there are none, and its own Chair decides afresh. */ +internal val PremiereCalls: List get() = if (Workspaces.originalOpen) PaulsPremiereCalls else emptyList() + +internal val PaulsPremiereCalls = listOf( PremiereCall("7789551", "Avatar: Fire and Ash", true, 303, true, "approved by Paul on 5 November 2026: Premiere debut, then Orbit Sci-Fi"), PremiereCall("8006401", "Spider-Man: Brand New Day", true, 302, true, "approved by Paul on 5 November 2026: Premiere debut, then Orbit Action"), PremiereCall("7974596", "Mayday", false, 302, false, diff --git a/android/app/src/main/java/tv/orbit/classic/ProgrammeArtwork.kt b/android/app/src/main/java/tv/orbit/classic/ProgrammeArtwork.kt index e0dcc5d..acd6678 100644 --- a/android/app/src/main/java/tv/orbit/classic/ProgrammeArtwork.kt +++ b/android/app/src/main/java/tv/orbit/classic/ProgrammeArtwork.kt @@ -63,7 +63,7 @@ internal fun ProgrammeArtwork(session: EmbySampleSession, channel: SampleChannel val seriesId = remember(channel, programme.startMs) { (channel as? ScheduledChannel)?.seriesIdAt(programme.startMs) } val picture by produceState(null, itemId, seriesId) { value = withContext(Dispatchers.IO) { - val cache = ArtworkCache(File(context.cacheDir, "artwork")) + val cache = ArtworkCache(Workspaces.current(context).artworkDir) runCatching { loadArtwork(cache, itemId, seriesId, session::fetchArtwork)?.let { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() } }.getOrNull() diff --git a/android/app/src/main/java/tv/orbit/classic/RememberedConnectionStore.kt b/android/app/src/main/java/tv/orbit/classic/RememberedConnectionStore.kt index 4f4d190..a7740af 100644 --- a/android/app/src/main/java/tv/orbit/classic/RememberedConnectionStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/RememberedConnectionStore.kt @@ -29,16 +29,17 @@ internal object ConnectionCipher { } } -/** App-private, backup-excluded, authenticated ciphertext only. All operations serialize. */ -internal class RememberedConnectionStore private constructor(context: Context) { +/** + * App-private, backup-excluded, authenticated ciphertext only. All operations serialize. One per server ([ServerWorkspace]), + * each with its own key, and a record only ever opens for the server it was saved with. + */ +internal class RememberedConnectionStore(path: File, private val alias: String, private val origin: String) { companion object { - private var instance: RememberedConnectionStore? = null - @Synchronized fun get(context: Context): RememberedConnectionStore = instance - ?: RememberedConnectionStore(context.applicationContext).also { instance = it } + /** The open server's store. */ + fun get(context: Context): RememberedConnectionStore = Workspaces.current(context).connection } @Synchronized fun invalidate() { generation++ } - private val file = AtomicFile(File(context.noBackupFilesDir, "orbit-connection.v1")) - private val alias = "orbit-connection-key-v1" + private val file = AtomicFile(path) private var generation = 0L @Synchronized fun ticket() = generation private fun key(create: Boolean): SecretKey { @@ -56,10 +57,11 @@ internal class RememberedConnectionStore private constructor(context: Context) { return file.openRead().use { input -> val bytes = input.readBytesLimited(RememberedStateCodec.MAX_BYTES + 29) RememberedStateCodec.decode(ConnectionCipher.open(key(false), bytes)) - } + }.also { require(it.origin == origin) { "A saved sign-in belongs to another server." } } } @Synchronized fun save(state: RememberedState, ticket: Long): Boolean { if (ticket != generation) return false + require(state.origin == origin) { "A sign-in can only be saved with its own server." } val plain = RememberedStateCodec.encode(state) // Validate the same bounded schema before committing it. RememberedStateCodec.decode(plain) @@ -69,6 +71,19 @@ internal class RememberedConnectionStore private constructor(context: Context) { catch (failure: Exception) { file.failWrite(output); throw failure } return true } + /** Signs out but keeps the channel choices that go with this server, for when you sign back in. */ + @Synchronized fun forgetToken(): Boolean { + val saved = load() ?: return true + generation++ + val plain = RememberedStateCodec.encode(RememberedState(RememberedIdentity(saved.identity.userId, null, saved.identity.deviceId), saved.sample, + saved.matches, saved.approved, saved.episodes, saved.orbitOne, saved.toonami, saved.selectedNumber, saved.afterDark, saved.darkMovies, + saved.approvedDark, saved.extra, saved.origin)) + val encrypted = ConnectionCipher.seal(key(true), plain) + val output = file.startWrite() + try { output.write(encrypted); file.finishWrite(output) } + catch (failure: Exception) { file.failWrite(output); throw failure } + return true + } @Synchronized fun clear() { generation++ file.delete() diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 64825cb..ce6b8d2 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -71,7 +71,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC if (calmMotion) androidx.compose.animation.core.snap() else androidx.compose.animation.core.tween(1_000), label = "squeeze") val phase = if (identsHere) identPhase(current.startMs, current.endMs, now, identMs, if (squeezeOn) SQUEEZE_MS + IDENT_LOAD_AHEAD_MS else IDENT_LOAD_AHEAD_MS) else IdentPhase.None // Each changeover in the playback notes, so a missing ident can be explained afterwards. - val notes = remember { java.io.File(settingsContext.noBackupFilesDir, "schedule") } + val notes = remember { Workspaces.current(settingsContext).scheduleDir } DisposableEffect(Unit) { FullScreen.active = true; onDispose { FullScreen.active = false } } val introPlayback = rememberIntro(intro) { PlaybackNotes.add(notes, "channel=${channel.number} $it") } LaunchedEffect(channel, current.startMs) { diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index ca7d5dd..0dbf7fc 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -259,15 +259,16 @@ private fun ViewingDetail(totsOn: Boolean, windingDown: Boolean, onWindDown: (Bo } @Composable -private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, firstSwitch: FocusRequester, modifier: Modifier) { +private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, onRemoveServer: () -> Unit, firstSwitch: FocusRequester, modifier: Modifier) { val context = LocalContext.current val version = remember { runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull().orEmpty() } var confirming by remember { mutableStateOf(false) } + var removing by remember { mutableStateOf(false) } Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingLine("Connected", server + (titles?.let { ", $it titles available" }.orEmpty())) {} Text("Your password is never saved. This box only keeps a sign-in token, in encrypted storage.", color = Muted, fontSize = 13.sp) if (confirming) { - SettingLine("Sign out of this box?", "This also removes your channel schedules, review choices and tickets from this box. Your Emby library isn't touched.") { + SettingLine("Sign out of this box?", "Your channels, schedules, review choices and tickets for this server stay on this box for when you sign back in. Your Emby library isn't touched.") { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { val keep = remember { FocusRequester() } LaunchedEffect(Unit) { keep.requestFocus() } @@ -275,7 +276,17 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, OrbitButton("Sign out", onClick = onSignOut) } } - } else SettingLine("Sign out", "Disconnect this box from Emby.") { OrbitButton("Sign out", Modifier.focusRequester(firstSwitch), onClick = { confirming = true }) } + } else SettingLine("Sign out", "Disconnect this box from Emby. Signing in to another server keeps this one's channels and schedules too.") { OrbitButton("Sign out", Modifier.focusRequester(firstSwitch), onClick = { confirming = true }) } + if (removing) { + SettingLine("Remove this server from this box?", "Signs out and deletes this server's channels, schedules, bookings, review choices, tickets and library data from this box. Other servers and your Emby library aren't touched. This can't be undone.") { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val keep = remember { FocusRequester() } + LaunchedEffect(Unit) { keep.requestFocus() } + OrbitButton("Keep", Modifier.focusRequester(keep), onClick = { removing = false }) + OrbitButton("Remove", onClick = onRemoveServer) + } + } + } else SettingLine("Remove this server", "For leaving a box or starting again: deletes this server's data from this box only.") { OrbitButton("Remove", onClick = { removing = true }) } SettingLine("Welcome tour", "See the first-launch steps a new box goes through, from Ollie's welcome to You're on air. Nothing is changed by it.") { OrbitButton("Show it", onClick = { Onboarding.start(tour = true) }) } @@ -290,7 +301,7 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio arrivals: @Composable (Modifier, FocusRequester) -> Unit, director: @Composable (Modifier, FocusRequester) -> Unit, isOn: (Int) -> Boolean, onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, onSignOut: () -> Unit, - windingDown: Boolean, onWindDown: (Boolean) -> Unit) { + windingDown: Boolean, onWindDown: (Boolean) -> Unit, onRemoveServer: () -> Unit = {}) { val firstFocus = remember { FocusRequester() } val firstSwitch = remember { FocusRequester() } val channelList = rememberLazyListState() @@ -314,7 +325,7 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio SettingsSection.Arrivals -> arrivals(detail, firstSwitch) SettingsSection.Director -> director(detail, firstSwitch) SettingsSection.Profiles -> ProfilesDetail(firstSwitch, detail) - SettingsSection.Account -> AccountDetail(server, titles, onSignOut, firstSwitch, detail) + SettingsSection.Account -> AccountDetail(server, titles, onSignOut, onRemoveServer, firstSwitch, detail) } } Text("▲▼ Move ► Into this section OK Select Back Main menu", color = Muted, fontSize = 11.sp) diff --git a/android/app/src/main/java/tv/orbit/classic/Workspaces.kt b/android/app/src/main/java/tv/orbit/classic/Workspaces.kt new file mode 100644 index 0000000..054420b --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/Workspaces.kt @@ -0,0 +1,135 @@ +package tv.orbit.classic + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import java.io.File +import java.security.MessageDigest + +/** + * One Emby server's own data on this box (portability, Paul, 6 November 2026): its sign-in and channel choices, its catalogue, + * its schedules and bookings, its Director's Chair decisions and approved events, its playback notes and its artwork. Each + * server keeps its own, and only the server signed in to is open, so nothing from one server is shown, sent or booked on + * another. Moving to another server deletes nothing: the last server's data stays put for when you come back. Removing a + * server's data is its own choice in Settings. + * + * The data a box already has belongs to the original server (the only one Orbit could use before an address could be typed + * in), and it stays exactly where it always was: nothing is moved, copied or rewritten, so an older Orbit still finds all of + * it. Any other server's data lives under servers/, where an older Orbit never looks. + */ +internal class ServerWorkspace(val origin: String, val root: File, val artworkDir: File, val keyAlias: String) { + /** The server this box used before addresses could be typed in: its data is the box's existing data, where it was. */ + val original get() = origin == EmbyServer.DEFAULT + val connectionFile get() = File(root, "orbit-connection.v1") + val catalogueFile get() = File(root, "catalogue.db") + val scheduleDir get() = File(root, "schedule") + val connection: RememberedConnectionStore by lazy { RememberedConnectionStore(connectionFile, keyAlias, origin) } + private val catalogueOnce = lazy { CatalogueStore(appContext!!, catalogueFile) } + val catalogue: CatalogueStore by catalogueOnce + /** Closes the catalogue if it was ever opened. */ + fun close() { if (catalogueOnce.isInitialized()) runCatching { catalogue.close() } } + val schedules: ScheduleStore by lazy { scheduleDir.let { migrateChannelNumbers(it); ScheduleStore(it) } } + internal var appContext: Context? = null +} + +internal object Workspaces { + /** The original server's key alias, as it always was. Others get their own, so removing one never touches another. */ + private const val ORIGINAL_ALIAS = "orbit-connection-key-v1" + private const val ACTIVE_FILE = "active-server" + private const val ORIGIN_FILE = "server" + + var current by mutableStateOf(null) + private set + /** Whether the original server is the one open (or none is yet, as in tests): Paul's own decisions by item apply only there. */ + val originalOpen: Boolean get() = current?.original ?: true + + /** A short, stable folder name for [origin]. */ + fun keyOf(origin: String): String = MessageDigest.getInstance("SHA-256").digest(origin.toByteArray(Charsets.UTF_8)) + .take(8).joinToString("") { "%02x".format(it) } + + /** Where [origin]'s data lives under [noBackup] and [cacheDir]. Nothing is created here. */ + fun layout(noBackup: File, cacheDir: File, origin: String): ServerWorkspace = + if (origin == EmbyServer.DEFAULT) ServerWorkspace(origin, noBackup, File(cacheDir, "artwork"), ORIGINAL_ALIAS) + else keyOf(origin).let { key -> ServerWorkspace(origin, File(noBackup, "servers/$key"), File(cacheDir, "artwork-$key"), "$ORIGINAL_ALIAS-$key") } + + /** The server whose workspace was open last: the original server until another has been signed in to. */ + fun activeOrigin(noBackup: File): String { + val saved = runCatching { File(noBackup, ACTIVE_FILE).takeIf { it.isFile && it.length() <= 300 }?.readText()?.trim() }.getOrNull() ?: return EmbyServer.DEFAULT + return saved.takeIf { EmbyServer.normalise(it) == it } ?: EmbyServer.DEFAULT + } + + /** The open workspace, opening the last one used if none is yet. */ + @Synchronized fun current(context: Context): ServerWorkspace = current ?: open(context, activeOrigin(context.noBackupFilesDir)) + + @Synchronized fun open(context: Context, origin: String): ServerWorkspace { + val app = context.applicationContext + return openIn(app.noBackupFilesDir, app.cacheDir, origin) { it.appContext = app } + } + + /** + * Opens [origin]'s workspace and makes it the one in use, closing the last one's catalogue. Creates the folder of a new + * server (never the original's, which is the box's own) with a note of its address, and refuses a folder that belongs to + * another address. Opening the original server writes nothing at all, unless another server was the one in use. + */ + @Synchronized fun openIn(noBackup: File, cacheDir: File, origin: String, prepare: (ServerWorkspace) -> Unit = {}): ServerWorkspace { + require(EmbyServer.normalise(origin) == origin) + if (current?.origin == origin) return current!! + val next = layout(noBackup, cacheDir, origin).also(prepare) + if (!next.original) { + val note = File(next.root, ORIGIN_FILE) + if (note.exists()) check(note.readText().trim() == origin) { "A server folder belongs to another address." } + else { next.root.mkdirs(); replace(note, origin) } + } + if (activeOrigin(noBackup) != origin) replace(File(noBackup, ACTIVE_FILE), origin) + current?.close() + current = next + // What one server showed or approved never carries over to another. + PromoBoard.items = emptyList(); PromoBoard.shown.clear(); PromoBoard.preview = null + ApprovedDriveInEvents.list = emptyList() + CuratedDriveInEvents.list = emptyList(); CuratedDriveInEvents.copies = emptyMap(); CuratedDriveInEvents.missing = emptyMap() + EmbyServer.origin = origin + return next + } + + /** For tests: no workspace open. */ + @Synchronized internal fun reset() { current?.close(); current = null; EmbyServer.origin = EmbyServer.DEFAULT } + + /** A small file written whole or not at all. */ + private fun replace(target: File, text: String) { + val temporary = File(target.parentFile, target.name + ".tmp") + java.io.FileOutputStream(temporary).use { it.write(text.toByteArray(Charsets.UTF_8)); it.fd.sync() } + if (!temporary.renameTo(target)) { temporary.delete(); throw java.io.IOException("Couldn't save which server is in use.") } + } + + /** Whether [workspace] holds anything: a sign-in, a catalogue, or schedules. Read only. */ + fun hasData(workspace: ServerWorkspace) = workspace.connectionFile.exists() || workspace.catalogueFile.exists() || + workspace.scheduleDir.listFiles().orEmpty().any { it.name != "numbering-2" } + + /** Every server with data on this box, the original first if it has any. Read only. */ + fun known(noBackup: File, cacheDir: File): List { + val others = File(noBackup, "servers").listFiles().orEmpty().mapNotNull { dir -> File(dir, ORIGIN_FILE).takeIf { it.isFile }?.readText()?.trim() } + .filter { EmbyServer.normalise(it) == it && it != EmbyServer.DEFAULT }.sorted() + return (listOf(EmbyServer.DEFAULT) + others).filter { hasData(layout(noBackup, cacheDir, it)) } + } + fun known(context: Context) = known(context.noBackupFilesDir, context.cacheDir) + + /** + * Removes one server's data from this box, when you choose to: its sign-in, catalogue, schedules, decisions, notes and + * artwork. No other server's data is touched. Returns false if anything couldn't be removed. + */ + fun remove(workspace: ServerWorkspace): Boolean { + var ok = true + runCatching { workspace.connection.clear() }.onFailure { ok = false } + runCatching { workspace.catalogue.clear() }.onFailure { ok = false } + runCatching { workspace.schedules.clear() }.onFailure { ok = false } + workspace.artworkDir.deleteRecursively() + if (!workspace.original) { + listOf(File(workspace.root, "orbit-connection.v1.bak"), File(workspace.root, "catalogue.db-journal")).forEach { it.delete() } + // A server's own folder goes once it's empty of everything but its note; anything left is kept rather than guessed at. + if (workspace.scheduleDir.listFiles().orEmpty().all { it.name == "numbering-2" }) workspace.scheduleDir.deleteRecursively() + if (workspace.root.listFiles().orEmpty().all { it.name == ORIGIN_FILE }) workspace.root.deleteRecursively() + } + return ok + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt b/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt index 300d918..3a50758 100644 --- a/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/DirectorPremiereTest.kt @@ -2,7 +2,9 @@ package tv.orbit.classic import org.json.JSONArray import org.json.JSONObject +import org.junit.After import org.junit.Assert.* +import org.junit.Before import org.junit.Test import java.io.File import java.time.DayOfWeek @@ -12,6 +14,9 @@ import java.time.ZonedDateTime /** Premiere as a broadcast stage: two recommendations, a debut that only appends, a window, then the film's genre home. */ class DirectorPremiereTest { + // The curated Drive-In events as found in Paul's library, which these evenings compete with. + @Before fun paulsEvents() { CuratedDriveInEvents.refresh { PaulsEventCopies } } + @After fun noEvents() { CuratedDriveInEvents.list = emptyList(); CuratedDriveInEvents.copies = emptyMap() } private val london = ZoneId.of("Europe/London") private val minute = 60_000L private val day = 24 * 60 * minute diff --git a/android/app/src/test/java/tv/orbit/classic/DriveInEventsReplayTest.kt b/android/app/src/test/java/tv/orbit/classic/DriveInEventsReplayTest.kt index cd12cea..a7286b7 100644 --- a/android/app/src/test/java/tv/orbit/classic/DriveInEventsReplayTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/DriveInEventsReplayTest.kt @@ -52,7 +52,7 @@ class DriveInEventsReplayTest { } 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())) { + for ((name, events) in listOf("drivein-events" to PaulsDriveInEvents, "drivein-baseline" to emptyList())) { val notes = mutableListOf() var current: ChannelPlan? = committed val all = committed.entries.toMutableList() diff --git a/android/app/src/test/java/tv/orbit/classic/DriveInEventsTest.kt b/android/app/src/test/java/tv/orbit/classic/DriveInEventsTest.kt index c87c31f..3d9271e 100644 --- a/android/app/src/test/java/tv/orbit/classic/DriveInEventsTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/DriveInEventsTest.kt @@ -186,7 +186,8 @@ class DriveInEventsTest { 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) }) + // Templates name films by IMDb ID only: no server's item is built in. + assertTrue(DriveInEvents.flatMap { it.films }.all { it.imdb.matches(Regex("tt\\d{7,8}")) && it.itemId.isEmpty() }) // 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 }) } diff --git a/android/app/src/test/java/tv/orbit/classic/EventIdeasReplayTest.kt b/android/app/src/test/java/tv/orbit/classic/EventIdeasReplayTest.kt index 11c401a..2090a9f 100644 --- a/android/app/src/test/java/tv/orbit/classic/EventIdeasReplayTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/EventIdeasReplayTest.kt @@ -32,7 +32,7 @@ class EventIdeasReplayTest { val studios = EventStudios.associateWith { st -> films.values.filter { f -> f.studios.any { it.contains(st.first) } }.map { it.id } } val read = films.values.filter { it.id in evidence } val held = AdventDoors.map { it.film.itemId } + NaughtyList.map { it.film.itemId } + ApprovedDebuts.map { it.film.itemId } - val seeds = ideaSeeds(collections, studios, read, DriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet() + held) + val seeds = ideaSeeds(collections, studios, read, PaulsDriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet() + held) val busy = strings(input, "busy").toSet() val unavailable = { f: IdeaFilm -> if (f.id in busy) "booked" else if (f.id in held) "held" else null } val bills = seeds.mapNotNull { billFor(it, films, unavailable).first }.sortedByDescending { it.score } diff --git a/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt b/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt index 915199a..4215f0b 100644 --- a/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/EventIdeasTest.kt @@ -71,16 +71,22 @@ class EventIdeasTest { @Test fun seedsSkipCuratedListsAndThemesAlreadyHad() { val seeds = ideaSeeds(listOf("Godzilla Collection" to listOf("a", "b"), "IMDB Top 250 Movies" to listOf("a", "b"), "Rambo Collection" to listOf("5642223", "x")), - emptyMap(), emptyList(), DriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet()) + emptyMap(), emptyList(), PaulsDriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet()) assertEquals(listOf("franchise:Godzilla Collection"), seeds.map { it.key }) } @Test fun anEveningSuitsItsFormatAndCompetesWithNothing() { - val late = ideaEvening(IdeaFormat.LateShow, LocalDate.of(2026, 11, 16), 0L, emptySet(), 4 * 3_600_000L, london)!! - assertTrue(late.dayOfWeek in setOf(DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY)); assertEquals(23, late.hour) - assertNotEquals(LocalDate.of(2026, 11, 17), late.toLocalDate()) // Amicus has Tuesday 17 November - val sat = ideaEvening(IdeaFormat.SaturdayTriple, LocalDate.of(2026, 11, 14), 0L, emptySet(), 6 * 3_600_000L, london)!! - assertEquals(LocalDate.of(2026, 11, 21), sat.toLocalDate()) // Universal Monsters has the 14th + CuratedDriveInEvents.refresh { PaulsEventCopies } + try { + val late = ideaEvening(IdeaFormat.LateShow, LocalDate.of(2026, 11, 16), 0L, emptySet(), 4 * 3_600_000L, london)!! + assertTrue(late.dayOfWeek in setOf(DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY)); assertEquals(23, late.hour) + assertNotEquals(LocalDate.of(2026, 11, 17), late.toLocalDate()) // Amicus has Tuesday 17 November + val sat = ideaEvening(IdeaFormat.SaturdayTriple, LocalDate.of(2026, 11, 14), 0L, emptySet(), 6 * 3_600_000L, london)!! + assertEquals(LocalDate.of(2026, 11, 21), sat.toLocalDate()) // Universal Monsters has the 14th + // A library without Universal's films has no Universal night, so the 14th is free there. + CuratedDriveInEvents.refresh { PaulsEventCopies - "tt0034398" } + assertEquals(LocalDate.of(2026, 11, 14), ideaEvening(IdeaFormat.SaturdayTriple, LocalDate.of(2026, 11, 14), 0L, emptySet(), 6 * 3_600_000L, london)!!.toLocalDate()) + } finally { CuratedDriveInEvents.list = emptyList(); CuratedDriveInEvents.copies = emptyMap() } } @Test fun anIdeaWithdrawnOnlyForARuleChangeComesBackWhenProposedAgain() { diff --git a/android/app/src/test/java/tv/orbit/classic/FriendsLibraryTest.kt b/android/app/src/test/java/tv/orbit/classic/FriendsLibraryTest.kt new file mode 100644 index 0000000..7c234be --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/FriendsLibraryTest.kt @@ -0,0 +1,162 @@ +package tv.orbit.classic + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.net.URLDecoder +import java.time.LocalDateTime +import java.time.ZoneId + +/** + * A new person's server, end to end (portability, Paul, 6 November 2026): a small library that isn't Paul's, with films and + * shows missing, duplicate copies and copies this box can't play. Sign-in, the catalogue, setting channels up and the + * Drive-In's curated events all run against it through the same code the box uses, with a fake server standing in for Emby. + */ +class FriendsLibraryTest { + @get:Rule val folder = TemporaryFolder() + private val zone = ZoneId.of("Europe/London") + + @After fun reset() { + Workspaces.reset() + CuratedDriveInEvents.list = emptyList(); CuratedDriveInEvents.copies = emptyMap(); CuratedDriveInEvents.missing = emptyMap() + } + + private class Film(val id: String, val name: String, val year: Int, val imdb: String?, val container: String = "mkv", val cast: List = emptyList()) + private class Show(val id: String, val name: String, val year: Int, val episodes: List) + + /** Sam's library: nothing like Paul's in size, and not the same items. "iso" is a container this box can't play directly. */ + private val films = listOf( + Film("m-thing1", "The Thing", 1982, "tt0084787", container = "iso"), Film("m-thing2", "The Thing", 1982, "tt0084787"), + Film("m-theylive", "They Live", 1988, "tt0096256"), Film("m-video", "Videodrome", 1983, "tt0086541"), Film("m-repo", "Repo Man", 1984, "tt0087995"), + // Hammer: the Curse is here, Dracula (1958) isn't. + Film("m-curse", "The Curse of Frankenstein", 1957, "tt0050280"), + // Universal: every film, Dracula twice, one copy unplayable. + Film("m-drac-a", "Dracula", 1931, "tt0021814", container = "iso"), Film("m-drac-b", "Dracula", 1931, "tt0021814"), + Film("m-frank", "Frankenstein", 1931, "tt0021884"), Film("m-wolf", "The Wolf Man", 1941, "tt0034398"), + // Arnie: all three, but the library doesn't list him in The Running Man's cast. + Film("m-commando", "Commando", 1985, "tt0088944", cast = listOf("Arnold Schwarzenegger")), Film("m-recall", "Total Recall", 1990, "tt0100802", cast = listOf("Arnold Schwarzenegger")), + Film("m-running", "The Running Man", 1987, "tt0093894"), + Film("m-bluey", "Bluey: The Sign", 2024, null)) + private val shows = listOf( + Show("s-xf1", "The X-Files", 1993, listOf("mkv")), Show("s-xf2", "The X-Files", 1993, List(6) { "mkv" }), + Show("s-buffy", "Buffy the Vampire Slayer", 1997, List(4) { "mkv" }), Show("s-fringe", "Fringe", 2008, listOf("mkv", "iso", "mkv")), + Show("s-tp", "Twin Peaks", 1990, listOf("iso", "iso")), Show("s-bluey", "Bluey", 2018, List(5) { "mp4" })) + + private fun source(id: String, container: String, ms: Long) = JSONObject().put("Id", "src-$id").put("Container", container).put("RunTimeTicks", ms * 10_000) + private fun filmRow(f: Film) = JSONObject().put("Id", f.id).put("Type", "Movie").put("Name", f.name).put("ProductionYear", f.year).put("RunTimeTicks", 6_000_000L * 10_000) + .put("ProviderIds", JSONObject().apply { f.imdb?.let { put("Imdb", it) } }).put("MediaSources", JSONArray().put(source(f.id, f.container, 6_000_000))) + .put("People", JSONArray(f.cast.map { JSONObject().put("Name", it).put("Type", "Actor") })).put("Genres", JSONArray().put("Horror")).put("OfficialRating", "15") + .put("PremiereDate", "${f.year}-06-01T00:00:00.0000000Z") + private fun showRow(s: Show) = JSONObject().put("Id", s.id).put("Type", "Series").put("Name", s.name).put("ProductionYear", s.year).put("OfficialRating", "TV-14") + .put("ProviderIds", JSONObject()).put("Genres", JSONArray().put("Drama")).put("PremiereDate", "${s.year}-09-01T00:00:00.0000000Z") + private fun episodeRows(s: Show) = s.episodes.mapIndexed { i, container -> JSONObject().put("Id", "${s.id}-e${i + 1}").put("Type", "Episode").put("Name", "Episode ${i + 1}") + .put("ParentIndexNumber", 1).put("IndexNumber", i + 1).put("MediaSources", JSONArray().put(source("${s.id}-e${i + 1}", container, 2_700_000))) } + + private fun page(rows: List, start: Int = 0, limit: Int = rows.size) = + EmbyReply(200, JSONObject().put("Items", JSONArray(rows.drop(start).take(limit))).put("TotalRecordCount", rows.size).toString()) + private fun matches(name: String, term: String) = name.lowercase().filter { it.isLetterOrDigit() || it == ' ' }.contains(term.lowercase().filter { it.isLetterOrDigit() || it == ' ' }) + + val requests = mutableListOf() + /** Enough of Emby's API for everything the box asks of it here; anything else is a failure the test would see. */ + private val server = EmbyTransport { request -> + requests += request.path + val query = request.path.substringAfter('?', "").split('&').filter { it.isNotEmpty() }.associate { URLDecoder.decode(it.substringBefore('='), "UTF-8") to URLDecoder.decode(it.substringAfter('=', ""), "UTF-8") } + val path = request.path.substringBefore('?') + val start = query["StartIndex"]?.toInt() ?: 0; val limit = query["Limit"]?.toInt() ?: 1_000 + when { + path == "/System/Info/Public" -> EmbyReply(200, JSONObject().put("Id", "sams-server").put("Version", "4.8.10").put("ServerName", "Sam's Emby").toString()) + path == "/Users/AuthenticateByName" -> EmbyReply(200, JSONObject().put("AccessToken", "sams-token").put("User", JSONObject().put("Id", "sam")).toString()) + request.token != "sams-token" -> EmbyReply(401, "") + path == "/Users/sam/Items" -> page(films.map(::filmRow), 0, 20) + path.startsWith("/Shows/") -> page(episodeRows(shows.single { it.id == path.removePrefix("/Shows/").substringBefore('/') }), start, limit) + path == "/Items" && query["Ids"] != null -> query.getValue("Ids").split(',').let { ids -> page(films.filter { it.id in ids }.map(::filmRow) + shows.filter { it.id in ids }.map(::showRow)) } + path == "/Items" && query["SearchTerm"] != null -> { + val term = query.getValue("SearchTerm"); val years = query["Years"]?.split(',')?.map { it.toInt() } + val types = query["IncludeItemTypes"].orEmpty().split(',') + page((if ("Movie" in types) films.filter { matches(it.name, term) && (years == null || it.year in years) }.map(::filmRow) else emptyList()) + + (if ("Series" in types) shows.filter { matches(it.name, term) }.map(::showRow) else emptyList()), start, limit) + } + path == "/Items" && query["IncludeItemTypes"] == "Movie,Series" -> page(films.map(::filmRow) + shows.map(::showRow), start, limit) + else -> EmbyReply(404, "") + } + } + + /** The catalogue, held in memory as the box holds it in its database, with the same IMDb lookup. */ + private class MemoryCatalogue : CatalogueSink { + val records = mutableListOf(); var next = 0L; var finished = false + override fun position(userId: String) = next + override fun write(userId: String, records: List, nextStart: Long, total: Long?, finished: Boolean) { this.records += records; next = nextStart; this.finished = finished } + fun idsByImdb(imdbs: Collection) = imdbs.map { it.lowercase() }.associateWith { imdb -> + records.filter { it.type == "Movie" && it.providerIds.entries.any { (k, v) -> k.equals("Imdb", true) && v.lowercase() == imdb } }.map { it.id } }.filterValues { it.isNotEmpty() } + } + + @Test fun aNewServerSignsInBuildsItsOwnChannelsAndOnlyTheEventsItCanShow() { + val noBackup = folder.newFolder("no_backup"); val cache = folder.newFolder("cache") + // The box's own (Paul's) data, which must come through untouched. + java.io.File(noBackup, "schedule").mkdirs(); java.io.File(noBackup, "schedule/schedule-300.json").writeText("paul's drive-in") + val origin = "https://emby.sam.example" + assertEquals("Sam's Emby", EmbyClient(server).checkServer()) + val workspace = Workspaces.openIn(noBackup, cache, origin) + val session = EmbyClient(server).connect("sam", "fixture-password") + assertEquals("sam", session.accountId) + + // The catalogue, from Sam's library. + val catalogue = MemoryCatalogue() + session.importCatalogue(catalogue, CATALOGUE_PAGES_FULL_RUN) + assertTrue(catalogue.finished); assertEquals(films.size + shows.size, catalogue.records.size) + + // Channels: Paradox (series) and After Dark (films), read and planned as switching them on does. + val scheduler = ChannelScheduler(workspace.schedules, copies = catalogue::idsByImdb, original = workspace.original, + clock = { LocalDateTime.of(2026, 11, 12, 12, 0).atZone(zone).toInstant().toEpochMilli() }) + val paradox = SmartChannels.single { it.name == "Paradox" } + scheduler.setEnabled("sam", paradox.number, true) + val paradoxLoop = scheduler.seedLoop(session, "sam", paradox) + val paradoxReport = scheduler.topUp(session, "sam", paradox, paradoxLoop) + val paradoxPool = workspace.schedules.loadPool("sam", paradox.number)!!.series + // The X-Files from its fuller copy; Twin Peaks has nothing this box plays; Angel and the rest aren't in the library. + assertEquals(setOf("s-xf2", "s-buffy", "s-fringe"), paradoxPool.map { it.id }.toSet()) + assertEquals(2, paradoxPool.single { it.id == "s-fringe" }.episodes.size) + assertTrue(paradoxReport.programmes > 0) + assertTrue(workspace.schedules.loadPlan("sam", paradox.number)!!.entries.all { it.seriesId in setOf("s-xf2", "s-buffy", "s-fringe") }) + + val afterDark = SmartChannels.single { it.number == AFTER_DARK_NUMBER } + assertTrue(afterDark.withdrawn.isEmpty()) // Paul's removals are his server's alone + scheduler.setEnabled("sam", afterDark.number, true) + scheduler.seedLoop(session, "sam", afterDark) + val darkPool = workspace.schedules.loadPool("sam", afterDark.number)?.series.orEmpty() + // The Thing from the copy that plays; nothing stands in for the films Sam doesn't have. + assertTrue(darkPool.map { it.id }.toSet().let { "m-thing2" in it && "m-thing1" !in it && it.all { id -> films.any { f -> f.id == id } } }) + + // The Drive-In's curated events: only those whose every film is in the library, read with every copy. + CuratedDriveInEvents.refresh(catalogue::idsByImdb) + assertEquals(setOf("universal", "arnie"), CuratedDriveInEvents.list.map { it.key }.toSet()) + assertEquals(listOf("tt0051554"), CuratedDriveInEvents.missing["hammer"]) + assertEquals(listOf("m-drac-a", "m-drac-b"), CuratedDriveInEvents.copies["tt0021814"]) + val listed = CuratedDriveInEvents.list.flatMap { e -> e.films.flatMap { f -> eventFilmCopies(e, f, CuratedDriveInEvents.copies).map { EventFilm(f.title, f.year, f.imdb, it) to e.person } } } + val checks = session.readFilmChecks(listed) + assertEquals("unplayable", checks.getValue("m-drac-a").reason); assertEquals("verified", checks.getValue("m-drac-b").reason) + assertEquals("person not in cast", checks.getValue("m-running").reason) + val verified = checks.values.mapNotNull { it.film }.associateBy { it.id } + val booking = chooseEventCopies(CuratedDriveInEvents.list, CuratedDriveInEvents.copies, verified.keys) + assertEquals("m-drac-b", booking.single { it.key == "universal" }.films.first().itemId) + + // Booked as the box books: Universal on its Saturday from the copies that play; Arnie and Hammer not at all, not even in part. + val pool = (1..40).map { n -> PoolSeries("p$n", "Film $n", listOf(PoolEpisode("p$n", "s$n", EpisodeMark(1, 1), "Film $n", 100 * 60_000L))) } + var plan: ChannelPlan? = null; var now = LocalDateTime.of(2026, 11, 12, 12, 0).atZone(zone).toInstant().toEpochMilli() + repeat(4) { plan = planDriveIn(plan, pool, pool, pool, now, zone, events = booking, eventFilms = verified); now += 24 * 60 * 60_000L } + val events = plan!!.entries.filter { it.why.startsWith(DRIVE_IN_EVENT_WHY) } + assertEquals(listOf("m-drac-b", "m-frank", "m-wolf"), events.map { it.seriesId }) + assertTrue(plan!!.entries.none { it.seriesId in setOf("m-curse", "m-commando", "m-recall", "m-running", "m-drac-a") }) + + // Everything Sam's server produced is in Sam's folder; the box's own data is as it was. + assertEquals("paul's drive-in", java.io.File(noBackup, "schedule/schedule-300.json").readText()) + assertTrue(java.io.File(workspace.scheduleDir, "schedule-${paradox.number}.json").exists()) + assertFalse(java.io.File(noBackup, "schedule/schedule-${paradox.number}.json").exists()) + // And Sam's token only ever went to Sam's server's requests, never in an address. + assertTrue(requests.none { it.contains("sams-token") }) + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/PaulsLibrary.kt b/android/app/src/test/java/tv/orbit/classic/PaulsLibrary.kt new file mode 100644 index 0000000..b4b3534 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/PaulsLibrary.kt @@ -0,0 +1,41 @@ +package tv.orbit.classic + +/** + * Paul's library's copies of the curated Drive-In films, IMDb ID to item, read from a read-only copy of the box's catalogue + * (5 November 2026): one copy each, every title and year matching. What the portable events must find on his server. + */ +internal val PaulsEventCopies: Map> = mapOf( + "tt0050280" to listOf("5706532"), // The Curse of Frankenstein (1957) + "tt0051554" to listOf("5705114"), // Dracula (1958) + "tt0021814" to listOf("5661535"), // Dracula (1931) + "tt0021884" to listOf("5658574"), // Frankenstein (1931) + "tt0034398" to listOf("5848283"), // The Wolf Man (1941) + "tt0069341" to listOf("6047716"), // Tales from the Crypt (1972) + "tt0065854" to listOf("6036576"), // The House That Dripped Blood (1971) + "tt0178868" to listOf("7956704"), // Ring (1998) + "tt0364385" to listOf("5662234"), // Ju-on: The Grudge (2002) + "tt0078243" to listOf("6332423"), // The 36th Chamber of Shaolin (1978) + "tt0070800" to listOf("6336439"), // Five Fingers of Death (1972) + "tt0090190" to listOf("5672180"), // The Toxic Avenger (1984) + "tt0090849" to listOf("5706569"), // Class of Nuke 'Em High (1986) + "tt0088944" to listOf("6048687"), // Commando (1985) + "tt0100802" to listOf("5848135"), // Total Recall (1990) + "tt0093894" to listOf("5671824"), // The Running Man (1987) + "tt0096283" to listOf("5844712"), // My Neighbor Totoro (1988) + "tt0097814" to listOf("7883574"), // Kiki's Delivery Service (1989) + "tt0245429" to listOf("7950220"), // Spirited Away (2001) + "tt0083944" to listOf("5642223"), // First Blood (1982) + "tt0089880" to listOf("5848906"), // Rambo: First Blood Part II (1985) + "tt0095956" to listOf("5848907"), // Rambo III (1988) + "tt0038650" to listOf("6175183"), // It's a Wonderful Life (1946) + "tt0099785" to listOf("7416999"), // Home Alone (1990) + "tt0319343" to listOf("6952195") // Elf (2003) +) + +/** The event films' signature the box saved before events became portable (its schedule folder, 6 November 2026 snapshot). */ +internal const val PaulsSavedEventSignature = "events:hammer=5706532,5705114|universal=5661535,5658574,5848283|amicus=6047716,6036576|" + + "jhorror=7956704,5662234|shaw=6332423,6336439|troma=5672180,5706569|arnie=6048687,5848135,5671824|ghibli=5844712,7883574,7950220|" + + "rambo=5642223,5848906,5848907|christmas=6175183,7416999,6952195" + +/** The curated events as found on Paul's server. */ +internal val PaulsDriveInEvents: List get() = resolveDriveInEvents(DriveInEvents, PaulsEventCopies).events diff --git a/android/app/src/test/java/tv/orbit/classic/PortabilityTest.kt b/android/app/src/test/java/tv/orbit/classic/PortabilityTest.kt new file mode 100644 index 0000000..4b4ddba --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/PortabilityTest.kt @@ -0,0 +1,173 @@ +package tv.orbit.classic + +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.net.URI +import java.security.MessageDigest + +/** + * Portability and data safety (Paul, 6 November 2026): curated events found by IMDb ID in any library, each server's data in + * its own workspace, the box's existing data left exactly where it is, and Paul's own decisions kept to his own server. + */ +class PortabilityTest { + @get:Rule val folder = TemporaryFolder() + private val other = "https://emby.friend.example:8920" + + @After fun reset() { + Workspaces.reset() + CuratedDriveInEvents.list = emptyList(); CuratedDriveInEvents.copies = emptyMap(); CuratedDriveInEvents.missing = emptyMap() + } + + private fun digest(dir: File): Map = dir.walkTopDown().filter { it.isFile }.associate { file -> + file.relativeTo(dir).path to MessageDigest.getInstance("SHA-256").digest(file.readBytes()).joinToString("") { "%02x".format(it) } + } + + /** A box as it is today: the original server's sign-in, catalogue and schedules at the top of no_backup. */ + private fun todaysBox(): Pair { + val noBackup = folder.newFolder("no_backup"); val cache = folder.newFolder("cache") + File(noBackup, "orbit-connection.v1").writeBytes(byteArrayOf(1, 2, 3)) + File(noBackup, "catalogue.db").writeText("catalogue") + File(noBackup, "schedule").mkdirs(); File(noBackup, "schedule/schedule-101.json").writeText("{\"user\":\"paul\"}"); File(noBackup, "schedule/numbering-2").writeText("") + File(cache, "artwork").mkdirs(); File(cache, "artwork/x.img").writeText("art") + return noBackup to cache + } + + @Test fun curatedEventsFindExactlyPaulsItemsAndKeepTheSavedSignature() { + val found = resolveDriveInEvents(DriveInEvents, PaulsEventCopies) + assertEquals(10, found.events.size); assertTrue(found.missing.isEmpty()) + // The same items as before the change, film for film, so nothing on his box reads, books or plans differently. + assertEquals(PaulsSavedEventSignature, eventSignature(found.events, PaulsEventCopies)) + assertTrue(found.events.flatMap { it.films }.all { listOf(it.itemId) == PaulsEventCopies[it.imdb] }) + } + + @Test fun aMissingFilmLeavesItsWholeEventOutAndNothingStandsIn() { + val withoutDracula = PaulsEventCopies - "tt0051554" + val found = resolveDriveInEvents(DriveInEvents, withoutDracula) + assertNull(found.events.firstOrNull { it.key == "hammer" }) + assertEquals(listOf("tt0051554"), found.missing["hammer"]) + assertEquals(9, found.events.size) + // Its other film isn't offered alone, and no other film takes Dracula's place. + assertTrue(found.events.none { e -> e.films.any { it.imdb == "tt0050280" } }) + assertTrue(resolveDriveInEvents(DriveInEvents, emptyMap()).events.isEmpty()) + } + + @Test fun aLibraryThatCantBeReadYetKeepsWhatWasFoundAndTheDriveInWaits() { + assertTrue(CuratedDriveInEvents.refresh { PaulsEventCopies }) + assertFalse(CuratedDriveInEvents.refresh { null }) // catalogue still importing + assertFalse(CuratedDriveInEvents.refresh { throw IllegalStateException() }) // catalogue unreadable + assertEquals(10, CuratedDriveInEvents.list.size) + val store = ScheduleStore(folder.newFolder("schedule")) + val films = (1..40).map { n -> PoolSeries("$n", "Film $n", listOf(PoolEpisode("$n", "s$n", EpisodeMark(1, 1), "Film $n", 100 * 60_000L))) } + (DriveInMarqueeChannels + DriveInLateChannels + DriveInBillChannels).distinct().forEach { store.savePool("paul", it, ScheduleStore.SavedPool(films, 0L)) } + val now = { java.time.LocalDateTime.of(2026, 11, 12, 12, 0).atZone(java.time.ZoneId.of("Europe/London")).toInstant().toEpochMilli() } + assertNull(ChannelScheduler(store, copies = { null }, clock = now).topUpDriveIn("paul")) + assertNull(store.loadPlan("paul", DRIVE_IN_NUMBER)) + // Once the library can be read, it books. + assertNotNull(ChannelScheduler(store, copies = { emptyMap() }, clock = now).topUpDriveIn("paul")) + } + + @Test fun duplicateCopiesAreAllReadAndThePlayableOneBooks() { + val copies = PaulsEventCopies + ("tt0021814" to listOf("900", "5661535")) + val universal = resolveDriveInEvents(DriveInEvents, copies).events.single { it.key == "universal" } + assertTrue(eventSignature(listOf(universal), copies).contains("5661535/900")) + // Only the second copy plays on this box: that one books. + val chosen = chooseEventCopies(listOf(universal), copies, setOf("5661535", "5658574", "5848283")).single() + assertEquals("5661535", chosen.films.first().itemId) + // Both play: the first by item, every time. + assertEquals("900", chooseEventCopies(listOf(universal), copies, setOf("900", "5661535", "5658574", "5848283")).single().films.first().itemId) + // Neither plays: the film keeps a copy that isn't verified, so the booking leaves the whole event out. + val none = chooseEventCopies(listOf(universal), copies, setOf("5658574", "5848283")).single() + assertFalse(none.films.first().itemId in setOf("5658574", "5848283")) + } + + @Test fun theOriginalServerIsTodaysDataWhereItIsAndOpeningItWritesNothing() { + val (noBackup, cache) = todaysBox() + val before = digest(noBackup); val cacheBefore = digest(cache) + assertEquals(EmbyServer.DEFAULT, Workspaces.activeOrigin(noBackup)) + val original = Workspaces.openIn(noBackup, cache, Workspaces.activeOrigin(noBackup)) + assertTrue(original.original) + assertEquals(File(noBackup, "orbit-connection.v1"), original.connectionFile) + assertEquals(File(noBackup, "catalogue.db"), original.catalogueFile) + assertEquals(File(noBackup, "schedule"), original.scheduleDir) + assertEquals(File(cache, "artwork"), original.artworkDir) + assertEquals(before, digest(noBackup)); assertEquals(cacheBefore, digest(cache)) + assertEquals(setOf("orbit-connection.v1", "catalogue.db", "schedule"), noBackup.list()!!.toSet()) + assertTrue(Workspaces.originalOpen) + } + + @Test fun anotherServerGetsItsOwnFolderAndTodaysDataIsNeverTouched() { + val (noBackup, cache) = todaysBox() + val before = digest(noBackup) + Workspaces.openIn(noBackup, cache, EmbyServer.DEFAULT) + val friend = Workspaces.openIn(noBackup, cache, other) + assertFalse(friend.original); assertFalse(Workspaces.originalOpen) + assertEquals(File(noBackup, "servers/${Workspaces.keyOf(other)}"), friend.root) + assertNotEquals(File(cache, "artwork"), friend.artworkDir) + assertNotEquals("orbit-connection-key-v1", friend.keyAlias) + assertEquals(other, Workspaces.activeOrigin(noBackup)); assertEquals(other, EmbyServer.origin) + // Only the new folder's note and which server is in use were written; everything that was there is byte for byte the same. + val after = digest(noBackup) + assertEquals(before, after.filterKeys { it in before }) + assertEquals(setOf("servers/${Workspaces.keyOf(other)}/server", "active-server"), after.keys - before.keys) + // Back to the original server: still the same data, and an older Orbit still finds all of it where it always was. + assertTrue(Workspaces.openIn(noBackup, cache, EmbyServer.DEFAULT).original) + assertEquals(before, digest(noBackup).filterKeys { it in before }) + assertEquals(EmbyServer.DEFAULT, Workspaces.activeOrigin(noBackup)) + assertEquals(listOf(EmbyServer.DEFAULT), Workspaces.known(noBackup, cache)) // the new server holds nothing yet + } + + @Test fun aFolderNeverOpensForAnotherAddressAndAGarbledChoiceFallsBackToTheOriginal() { + val (noBackup, cache) = todaysBox() + val root = File(noBackup, "servers/${Workspaces.keyOf(other)}").apply { mkdirs() } + File(root, "server").writeText("https://someone.else.example") + assertThrows(IllegalStateException::class.java) { Workspaces.openIn(noBackup, cache, other) } + File(noBackup, "active-server").writeText("http://not-https.example") + assertEquals(EmbyServer.DEFAULT, Workspaces.activeOrigin(noBackup)) + assertThrows(IllegalArgumentException::class.java) { Workspaces.openIn(noBackup, cache, "http://plain.example") } + } + + @Test fun switchingServersForgetsWhatTheLastOneShowed() { + val (noBackup, cache) = todaysBox() + Workspaces.openIn(noBackup, cache, EmbyServer.DEFAULT) + CuratedDriveInEvents.refresh { PaulsEventCopies } + ApprovedDriveInEvents.list = PaulsDriveInEvents.take(1) + PromoBoard.shown[101] = 1L + Workspaces.openIn(noBackup, cache, other) + assertTrue(allDriveInEvents().isEmpty()); assertTrue(PromoBoard.items.isEmpty()); assertTrue(PromoBoard.shown.isEmpty()) + } + + @Test fun paulsOwnDecisionsByItemApplyOnlyOnHisServer() { + val (noBackup, cache) = todaysBox() + Workspaces.openIn(noBackup, cache, EmbyServer.DEFAULT) + val afterDark = SmartChannels.single { it.number == AFTER_DARK_NUMBER } + assertTrue(afterDark.withdrawn.isNotEmpty()); assertTrue(PremiereCalls.isNotEmpty()) + val advent = java.time.LocalDateTime.of(2026, 12, 1, 18, 30).atZone(java.time.ZoneId.of("Europe/London")).toInstant().toEpochMilli() + assertEquals("Advent's Door 1", competingEvent(advent, advent + 60_000, java.time.ZoneId.of("Europe/London"))) + assertEquals(25, AdventDoors.size); assertTrue(NaughtyList.isNotEmpty()); assertEquals(2, ApprovedDebuts.size) + Workspaces.openIn(noBackup, cache, other) + assertTrue(afterDark.withdrawn.isEmpty()); assertTrue(PremiereCalls.isEmpty()) + // Advent, the Naughty List and the approved debuts name Paul's items: another server has none of them. + assertTrue(AdventDoors.isEmpty()); assertTrue(NaughtyList.isEmpty()); assertTrue(ApprovedDebuts.isEmpty()); assertTrue(seasonalFilms().isEmpty()) + assertNull(competingEvent(advent, advent + 60_000, java.time.ZoneId.of("Europe/London"), season = false)) + // Its pool signature changes with it, so a server never reuses a pool read under the other's rules. + assertFalse(afterDark.signature.contains("#withdrawn")) + } + + @Test fun aSessionStaysWithItsOwnServerAfterTheServerInUseChanges() { + val requests = mutableListOf() + val transport = HttpsEmbyTransport("fixture-device", "https://emby.home.example") + val session = EmbySampleSession(transport, "fixture-token", "fixture-user", EmbySample(emptyList(), 0)) + EmbyServer.origin = other + assertEquals("https://emby.home.example", session.origin) + val url = session.mediaUrl("/emby/Videos/1/stream.mkv?api_key=fixture-token") + assertTrue(url.startsWith("https://emby.home.example/emby/Videos/1/stream.mkv")); assertFalse(url.contains("fixture-token")) + assertEquals(mapOf("X-Emby-Token" to "fixture-token"), session.mediaHeaders(url)) + // The server now in use is someone else's: this session's token never goes there. + assertTrue(session.mediaHeaders("$other/emby/Videos/1/stream.mkv").isEmpty()) + assertTrue(requests.isEmpty()) + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/PremiereReplayTest.kt b/android/app/src/test/java/tv/orbit/classic/PremiereReplayTest.kt index e8a4f49..5386253 100644 --- a/android/app/src/test/java/tv/orbit/classic/PremiereReplayTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/PremiereReplayTest.kt @@ -85,6 +85,7 @@ class PremiereReplayTest { @Test fun releaseChecks() { assumeTrue(dir != null && File(dir, "premiere-films.json").exists() && File(dir, "advent-films.json").exists()) + CuratedDriveInEvents.refresh { PaulsEventCopies } val with = run(true); val without = run(false) val out = StringBuilder() for (d in ApprovedDebuts) { diff --git a/android/app/src/test/java/tv/orbit/classic/WorkspaceSnapshotTest.kt b/android/app/src/test/java/tv/orbit/classic/WorkspaceSnapshotTest.kt new file mode 100644 index 0000000..16829f5 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/WorkspaceSnapshotTest.kt @@ -0,0 +1,37 @@ +package tv.orbit.classic + +import org.junit.After +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import java.security.MessageDigest + +/** + * The workspace change against a copy of the box's real data (never the box itself): + * ORBIT_SNAPSHOT_DIR= ./gradlew :app:testDebugUnitTest --tests '*WorkspaceSnapshotTest*' + * Opening the original server, then another, then the original again must leave every existing file byte for byte as it was, + * and the saved Drive-In event signature must be what the portable events produce. + */ +class WorkspaceSnapshotTest { + @After fun reset() = Workspaces.reset() + private fun digest(dir: File) = dir.walkTopDown().filter { it.isFile }.associate { f -> + f.relativeTo(dir).path to MessageDigest.getInstance("SHA-256").digest(f.readBytes()).joinToString("") { "%02x".format(it) } } + + @Test fun theBoxsDataIsUntouchedByOpeningServers() { + val dir = System.getenv("ORBIT_SNAPSHOT_DIR")?.let(::File) + assumeTrue(dir != null && dir.isDirectory) + val noBackup = dir!!; val cache = File(noBackup.parentFile, "cache-for-test").apply { mkdirs() } + val before = digest(noBackup) + val original = Workspaces.openIn(noBackup, cache, Workspaces.activeOrigin(noBackup)) + assertTrue(original.original); assertEquals(File(noBackup, "schedule"), original.scheduleDir) + assertEquals(before, digest(noBackup)) + Workspaces.openIn(noBackup, cache, "https://emby.friend.example") + Workspaces.openIn(noBackup, cache, EmbyServer.DEFAULT) + val after = digest(noBackup) + assertEquals(before, after.filterKeys { it in before }) + println("snapshot files=${before.size} unchanged=${before.count { (k, v) -> after[k] == v }} added=${(after.keys - before.keys).sorted()}") + val saved = File(noBackup, "schedule/episodes-300.json").readText().let { Regex("\"signature\":\"(events:[^\"]*)\"").find(it)!!.groupValues[1] } + assertEquals(saved, eventSignature(PaulsDriveInEvents, PaulsEventCopies)) + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c69314..f750a30 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -400,6 +400,17 @@ Paul will watch Orbit for a few weeks first, so the channels, the Director's Cha Not built yet; each needs its own proposal and approval. +## Portability and data safety (Paul, 6 November 2026) + +Before themes or more features: Orbit has to work for someone else's server without touching Paul's. + +- **Server address (0.108.40):** typed in at sign-in, HTTPS only, checked as an Emby server before the password is sent, saved with the sign-in. +- **Each server its own workspace (0.108.41):** sign-in, catalogue, schedules and bookings, the Director's Chair's decisions and approved events, playback notes and artwork. Only the server signed in to is open; a session stays bound to its own server's address. The box's existing data belongs to the original server and stays exactly where it is (nothing moved, copied or rewritten), so an older Orbit still finds all of it. Moving to another server deletes nothing; Sign out keeps a server's data; Remove this server is its own confirmed choice. +- **Portable events:** the curated Drive-In events are templates by title, year and verified IMDb ID, found in each library through its catalogue. An event with any film missing isn't offered at all, and nothing unrelated stands in. Every copy of a film is checked and the first this box plays is booked. +- **Paul's own decisions stay his:** premiere calls, After Dark removals, the Beerus copy and the season plans (Advent, the Naughty List, premieres) are by item on his server and apply only there. +- **Advent:** its doors and the Naughty List still name Paul's items; when Advent is installed, they need the same template treatment for any other server. +- **Home servers on plain HTTP (later, its own change):** LAN-only. HTTPS stays the default; plain HTTP only for a private address on the home network (10.x, 172.16-31.x, 192.168.x, or a .local name), never a public one, with a clear warning and the token kept to that address. Needs the cleartext rule narrowed to those addresses and the media source's private-address refusal relaxed for that server alone. No unrestricted public HTTP. + ## The Orbit cast across Orbit (Paul, 5 November 2026) Ollie, Blaze, Zip, Drift, Luna and Bumble, always drawn to the model sheet (design/arcade/orbit-cast.md). Ideas to pick from: cameos in the other games (Snake, Brick Break, Pairs cards); Tots idents and short bumpers; a sleep timer goodnight