From 59e753b45aab810893caecf3999207214083a2d6 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sun, 1 Nov 2026 18:19:17 +0000 Subject: [PATCH] Add first stage of library import (0.18.0) On-box SQLite catalogue and a resumable import of films and series, 1,000 titles per press, with size and speed figures to plan later stages. It doesn't touch channels or schedules, and Disconnect deletes the data. Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.18.0.md | 17 +++ android/app/build.gradle.kts | 4 +- .../java/tv/orbit/classic/CatalogueImport.kt | 80 +++++++++++++ .../java/tv/orbit/classic/CatalogueScreen.kt | 46 ++++++++ .../java/tv/orbit/classic/CatalogueStore.kt | 69 +++++++++++ .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 43 ++++++- .../tv/orbit/classic/EmbyPlaybackProtocol.kt | 2 + .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../tv/orbit/classic/CatalogueImportTest.kt | 108 ++++++++++++++++++ 10 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 VERIFICATION-0.18.0.md create mode 100644 android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt create mode 100644 android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt create mode 100644 android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt diff --git a/VERIFICATION-0.18.0.md b/VERIFICATION-0.18.0.md new file mode 100644 index 0000000..a31f31d --- /dev/null +++ b/VERIFICATION-0.18.0.md @@ -0,0 +1,17 @@ +# Orbit TV 0.18.0 — library import, stage one + +Approved scope: the first stage of a catalogue that can hold Paul's library of over 100,000 films and about 20,000 shows. Includes 0.16 and 0.17. + +Flow: Menu → Connect Emby → Library import test → Import next 1,000 titles. One press reads up to five pages of 200 Movie and Series records, pausing 1.5 seconds between pages, oldest first so titles added during an import land after the current position. No episodes are read, and no playback checks are made. Each page is one SQLite transaction with its next position, so a stopped import resumes from the last whole page. Rate-limit, expired-login and permission replies stop the run and keep finished pages. Leaving the screen or the app cancels it. + +Stored for each title: ID, type, title, year, rating, up to 8 genres and studios, up to 8 external IDs, premiere date and runtime, all bounded. Records aren't connected to channels, schedules, saved setup or the playback allowlist. + +Storage: Android's built-in SQLite, in app-private noBackupFilesDir, with no new dependency. It holds no login. It isn't encrypted, unlike the saved login and setup, because SQLCipher would be a new dependency; revisit before anyone else uses Orbit. It belongs to one account: a different account's first write replaces it, and Disconnect deletes it, including a page mid-write. + +The screen shows position, total, films and series saved, how many have genres, external IDs, year and rating, database size and bytes per title, plus the last run's time. Those figures size the next stage. + +Validation: offline assembleDebug, testDebugUnitTest and lintDebug passed; 107 unit tests (96 existing, 11 new), zero failures or errors, no lint errors. New fixture tests cover the page bound and token placement, resume, finishing, rate-limit and login stops keeping whole pages, cancellation, unusable rows, a server ignoring the limit, malformed fields, a change of account, and imported titles staying unplayable. CatalogueStore's SQLite code has no JVM test here, because the project has no Android test runtime, so the database, screen and real provider need the Xiaomi test. No private account, catalogue, media or device data was read by tools. + +Not in this stage: continuous background import, full-library import, refresh, episodes, search or use of this data by channels. + +Rollback: reinstall outputs/revision-0.17.0, which ignores this database, or revert this commit. Disconnect or clearing app data removes the data. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cd4864d..83484e4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 17 - versionName = "0.17.0-metadata-check" + versionCode = 18 + versionName = "0.18.0-catalogue-import" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt new file mode 100644 index 0000000..af627dd --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueImport.kt @@ -0,0 +1,80 @@ +package tv.orbit.classic + +import org.json.JSONArray +import org.json.JSONObject + +internal data class CatalogueRecord(val id: String, val type: String, val title: String, val year: Int?, val rating: String, + val genres: List, val studios: List, val providerIds: Map, val premiere: String, val runtimeMs: Long?) +internal class CataloguePage(val records: List, val rawCount: Int, val total: Long?) +internal data class CatalogueRun(val pages: Int, val records: Int, val finished: Boolean) + +/** Each write is one transaction, so an interrupted import resumes from the last whole page. */ +internal interface CatalogueSink { + fun position(userId: String): Long + fun write(userId: String, records: List, nextStart: Long, total: Long?, finished: Boolean) +} + +internal const val CATALOGUE_PAGE_SIZE = 200 +internal const val CATALOGUE_PAGES_PER_RUN = 5 +internal const val CATALOGUE_PAUSE_MS = 1_500L + +private fun names(array: JSONArray?, limit: Int = 8): List = (0 until minOf(array?.length() ?: 0, 32)).mapNotNull { index -> + when (val value = array?.opt(index)) { + is String -> value + is JSONObject -> value.optString("Name") + else -> null + }?.let { EmbyClient.clean(it, 60).replace("|", "/") }?.takeIf { it.isNotBlank() } +}.distinct().take(limit) + +private fun catalogueRecord(row: JSONObject): CatalogueRecord? { + val id = row.optString("Id") + val type = row.optString("Type") + if (!EmbyClient.validId(id) || type !in listOf("Movie", "Series")) return null + val title = EmbyClient.clean(row.optString("Name"), 160).takeIf { it.isNotBlank() } ?: return null + val ids = row.optJSONObject("ProviderIds") + val external = ids?.keys()?.asSequence().orEmpty().take(8).mapNotNull { key -> + val value = (ids?.opt(key) as? String)?.let { EmbyClient.clean(it, 64) }.orEmpty() + if (value.isBlank()) null else EmbyClient.clean(key, 32) to value + }.toMap() + val runtime = (row.opt("RunTimeTicks") as? Number)?.toLong()?.div(10_000)?.takeIf { it in 1_000..SampleChannel.MAX_DURATION_MS } + return CatalogueRecord(id, type, title, (row.opt("ProductionYear") as? Number)?.toInt()?.takeIf { it in 1800..2200 }, + EmbyClient.clean((row.opt("OfficialRating") as? String).orEmpty(), 40), names(row.optJSONArray("Genres")), names(row.optJSONArray("Studios")), + external, EmbyClient.clean((row.opt("PremiereDate") as? String).orEmpty(), 40), runtime) +} + +/** Oldest first, so titles added during an import land after the current position. */ +internal fun fetchCataloguePage(transport: EmbyTransport, userId: String, token: () -> String, start: Long): CataloguePage { + require(EmbyClient.validId(userId) && start >= 0) + val reply = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Recursive=true&IncludeItemTypes=Movie,Series&StartIndex=$start&Limit=$CATALOGUE_PAGE_SIZE&SortBy=DateCreated&SortOrder=Ascending&Fields=Genres,ProviderIds,Studios&EnableImages=false&EnableUserData=false", token = token())) + EmbyClient.expectSuccess(reply.status, false) + val page = EmbyClient.parse(reply.body) + val rows = page.optJSONArray("Items") ?: throw EmbyCheckException("The provider did not return a catalogue page.") + val count = minOf(rows.length(), CATALOGUE_PAGE_SIZE) + val records = (0 until count).mapNotNull { rows.optJSONObject(it)?.let(::catalogueRecord) }.distinctBy { it.id } + val total = if (page.has("TotalRecordCount")) page.optLong("TotalRecordCount", -1).takeIf { it >= 0 } else null + return CataloguePage(records, count, total) +} + +/** One explicit run of at most five pages. A rate-limit or login failure stops it; finished pages stay saved. */ +internal fun importCatalogue(transport: EmbyTransport, userId: String, token: () -> String, sink: CatalogueSink, + maxPages: Int = CATALOGUE_PAGES_PER_RUN, pause: () -> Unit = {}, checkActive: () -> Unit = {}, onPage: () -> Unit = {}): CatalogueRun { + require(maxPages in 1..CATALOGUE_PAGES_PER_RUN) + var start = sink.position(userId) + var pages = 0 + var written = 0 + while (pages < maxPages) { + checkActive() + val page = fetchCataloguePage(transport, userId, token, start) + checkActive() + val next = start + page.rawCount + val finished = page.rawCount == 0 || page.total?.let { next >= it } == true + sink.write(userId, page.records, next, page.total, finished) + start = next + pages++ + written += page.records.size + onPage() + if (finished) return CatalogueRun(pages, written, true) + if (pages < maxPages) pause() + } + return CatalogueRun(pages, written, false) +} diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt new file mode 100644 index 0000000..88ec563 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueScreen.kt @@ -0,0 +1,46 @@ +package tv.orbit.classic + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import java.util.Locale + +private fun whole(value: Long) = String.format(Locale.UK, "%,d", value) + +@Composable +internal fun CatalogueScreen(summary: CatalogueSummary?, busy: Boolean, canRun: Boolean, message: String?, lastRun: String?, + modifier: Modifier, onRun: () -> Unit, onBack: () -> Unit) { + BackHandler(onBack = onBack) + val backFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { backFocus.requestFocus() } + Column(modifier.fillMaxWidth().background(Navy).padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Library import test", color = Yellow, fontSize = 26.sp) + Text("Saves film and series details on this box, 1,000 titles per press. Channels and schedules are not changed, and no episodes are read.", color = Color.White, fontSize = 15.sp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OrbitButton(if (busy) "Importing…" else if (summary?.finished == true) "Import finished" else "Import next 1,000 titles", enabled = canRun && summary?.finished != true, onClick = onRun) + OrbitButton("Back", Modifier.focusRequester(backFocus), onClick = onBack) + } + message?.let { Text(it, color = Yellow, fontSize = 15.sp, maxLines = 3) } + lastRun?.let { Text(it, color = Yellow, fontSize = 15.sp) } + summary?.let { s -> + Column(Modifier.fillMaxWidth().background(Panel).padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Position ${whole(s.next)}${s.total?.let { " of ${whole(it)}" }.orEmpty()}", color = Color.White, fontSize = 19.sp) + Text("Saved: ${whole(s.films)} films, ${whole(s.series)} series", color = Color.White, fontSize = 17.sp) + Text("With genres ${whole(s.withGenres)} • external IDs ${whole(s.withIds)} • year ${whole(s.withYear)} • rating ${whole(s.withRating)}", color = Muted, fontSize = 15.sp) + val perRecord = if (s.records > 0) " • about ${whole(s.bytes / s.records)} bytes per title" else "" + Text("Database ${whole(s.bytes / 1024)} KB$perRecord", color = Muted, fontSize = 15.sp) + } + } + Text("Leaving this screen stops the import safely. It carries on from the same place next time. Disconnect removes this data.", color = Muted, fontSize = 13.sp) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt new file mode 100644 index 0000000..c6be9ac --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt @@ -0,0 +1,69 @@ +package tv.orbit.classic + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import org.json.JSONObject +import java.io.File + +internal data class CatalogueSummary(val films: Long, val series: Long, val next: Long, val total: Long?, val finished: Boolean, + val withGenres: Long, val withIds: Long, val withYear: Long, val withRating: Long, val bytes: Long) { + 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, 1) { + private val file = File(context.noBackupFilesDir, "catalogue.db") + 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)") + db.execSQL("CREATE INDEX items_type_title ON items(type, title)") + db.execSQL("CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)") + } + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + private fun meta(db: SQLiteDatabase, key: String): String? = + db.rawQuery("SELECT value FROM meta WHERE key = ?", arrayOf(key)).use { if (it.moveToFirst()) it.getString(0) else null } + private fun setMeta(db: SQLiteDatabase, key: String, value: String) { + db.insertWithOnConflict("meta", null, ContentValues().apply { put("key", key); put("value", value) }, SQLiteDatabase.CONFLICT_REPLACE) + } + @Synchronized override fun position(userId: String): Long { + val db = readableDatabase + return if (meta(db, "user") == userId) meta(db, "next")?.toLongOrNull() ?: 0 else 0 + } + @Synchronized override fun write(userId: String, records: List, nextStart: Long, total: Long?, finished: Boolean) { + val db = writableDatabase + db.beginTransaction() + try { + if (meta(db, "user") != userId) { db.delete("items", null, null); db.delete("meta", null, null); setMeta(db, "user", userId) } + records.forEach { r -> + db.insertWithOnConflict("items", null, ContentValues().apply { + put("id", r.id); put("type", r.type); put("title", r.title); put("year", r.year); put("rating", r.rating) + put("genres", r.genres.joinToString("|")); put("studios", r.studios.joinToString("|")) + put("provider_ids", JSONObject(r.providerIds as Map<*, *>).toString()); put("premiere", r.premiere); put("runtime_ms", r.runtimeMs) + }, SQLiteDatabase.CONFLICT_REPLACE) + } + setMeta(db, "next", nextStart.toString()) + total?.let { setMeta(db, "total", it.toString()) } + setMeta(db, "finished", finished.toString()) + db.setTransactionSuccessful() + } finally { db.endTransaction() } + } + @Synchronized fun summary(): CatalogueSummary { + val db = readableDatabase + fun count(where: String) = db.rawQuery("SELECT COUNT(*) FROM items WHERE $where", null).use { it.moveToFirst(); it.getLong(0) } + return CatalogueSummary(count("type = 'Movie'"), count("type = 'Series'"), meta(db, "next")?.toLongOrNull() ?: 0, + meta(db, "total")?.toLongOrNull(), meta(db, "finished") == "true", count("genres != ''"), count("provider_ids != '{}'"), + count("year IS NOT NULL"), count("rating != ''"), file.length()) + } + @Synchronized fun clear() { + close() + if (file.exists() && !SQLiteDatabase.deleteDatabase(file)) throw IllegalStateException("The catalogue could not be removed.") + } + companion object { + @Volatile private var instance: CatalogueStore? = null + fun get(context: Context) = instance ?: synchronized(this) { + instance ?: CatalogueStore(context.applicationContext).also { instance = it } + } + } +} 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 1ac4f06..514f5ec 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -41,7 +41,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.17.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.18.0\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index d1a6965..a66e4e6 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -76,6 +76,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea // Deliberately outside the saved setup: the coverage check changes nothing on this device. var metadataCoverage by remember { mutableStateOf?>(null) } var reviewingCoverage by remember { mutableStateOf(false) } + val catalogue = remember { CatalogueStore.get(appContext) } + var catalogueSummary by remember { mutableStateOf(null) } + var catalogueRun by remember { mutableStateOf(null) } + var reviewingCatalogue by remember { mutableStateOf(false) } var afterDarkChannel by remember { mutableStateOf(null) } var toonamiChannel by remember { mutableStateOf(null) } var selectedNumber by remember { mutableIntStateOf(101) } @@ -112,10 +116,12 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea pendingSaved = null 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." } } seriesMatches = null; episodeSamples = null; reviewingSeries = false; approvedSeries = emptySet() darkMovies = emptyList(); approvedDark = emptySet(); reviewingDark = false; afterDarkChannel = null - metadataCoverage = null; reviewingCoverage = false + metadataCoverage = null; reviewingCoverage = false; reviewingCatalogue = false; catalogueSummary = null; catalogueRun = null playback = null; session = null; sample = null; channel = null; toonamiChannel = null; selectedNumber = 101; watchingChannel = false onChannelAvailable(false) sessionSlot.clear()?.let { active -> EmbyCleanup.run { active.close() } } @@ -220,6 +226,39 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea return } if (!visible) return + if (reviewingCatalogue) { + LaunchedEffect(Unit) { + try { catalogueSummary = withContext(Dispatchers.IO) { catalogue.summary() } } + catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { message = "The library data could not be read." } + } + CatalogueScreen(catalogueSummary, busy, !busy && session != null, message, catalogueRun, modifier, onRun = { + val active = session + if (!busy && active != null) { + busy = true; message = null; catalogueRun = null + job = scope.launch { + val started = System.currentTimeMillis() + try { + val result = withContext(Dispatchers.IO) { + val context = coroutineContext + active.importCatalogue(catalogue, pause = { Thread.sleep(CATALOGUE_PAUSE_MS) }, checkActive = { context.ensureActive() }, + onPage = { val latest = catalogue.summary(); scope.launch { catalogueSummary = latest } }) + } + catalogueRun = "Last run: ${result.records} titles in ${result.pages} pages, ${(System.currentTimeMillis() - started) / 1000} seconds." + } catch (cancelled: CancellationException) { throw cancelled } + catch (failure: EmbyCheckException) { message = failure.message + " Finished pages are saved." } + catch (_: Exception) { message = "The import stopped. Finished pages are saved, and it can carry on later." } + finally { + busy = false + // A page already being written when Disconnect was pressed must not survive it. + if (session == null) 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." } + else try { catalogueSummary = withContext(Dispatchers.IO) { catalogue.summary() } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { } + } + } + } + }, onBack = { job?.cancel(); busy = false; reviewingCatalogue = false }) + return + } if (reviewingCoverage) { MetadataCoverageScreen(metadataCoverage, busy, !busy && session != null && now >= nextAttemptAt, message, modifier, onRun = { val active = session @@ -463,6 +502,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea }) OrbitButton("Check metadata coverage", Modifier.fillMaxWidth(), enabled = !busy && session != null, onClick = { message = null; reviewingCoverage = true }) + OrbitButton("Library import test", Modifier.fillMaxWidth(), + enabled = !busy && session != null, onClick = { message = null; reviewingCatalogue = true }) Spacer(Modifier.height(8.dp)) if (result.items.isEmpty()) Text("No movies or episodes were returned for this account.", color = Color.White, fontSize = 17.sp) LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { 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 f96e50c..79dba28 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -149,6 +149,8 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v fun checkMetadataCoverage(checkActive: () -> Unit = {}): List = auditMetadataCoverage(transport, userId, ::currentToken, checkActive) + fun importCatalogue(sink: CatalogueSink, pause: () -> Unit = {}, checkActive: () -> Unit = {}, onPage: () -> Unit = {}): CatalogueRun = + importCatalogue(transport, userId, ::currentToken, sink, pause = pause, checkActive = checkActive, onPage = onPage) fun checkAfterDarkMovies(genre: String): List { val found = discoverAfterDarkMovies(transport, userId, ::currentToken, genre) currentToken() diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index a084db1..3d65c5b 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.17.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.18.0") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { diff --git a/android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt b/android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt new file mode 100644 index 0000000..9c4675c --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/CatalogueImportTest.kt @@ -0,0 +1,108 @@ +package tv.orbit.classic + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +class CatalogueImportTest { + private class MemorySink : CatalogueSink { + val records = linkedMapOf() + var next = 0L; var total: Long? = null; var finished = false; var writes = 0; var owner: String? = null + override fun position(userId: String) = if (owner == userId) next else 0 + override fun write(userId: String, records: List, nextStart: Long, total: Long?, finished: Boolean) { + if (owner != userId) { this.records.clear(); owner = userId } + records.forEach { this.records[it.id] = it } + next = nextStart; this.total = total; this.finished = finished; writes++ + } + } + private fun row(id: String, type: String = "Movie") = JSONObject().put("Id", id).put("Type", type).put("Name", "Fixture $id") + private fun startOf(request: EmbyRequest) = request.path.substringAfter("StartIndex=").substringBefore('&').toInt() + /** A fixture library of [size] rows, answering paging as a server would. */ + private fun library(size: Int, requests: MutableList = mutableListOf()) = EmbyTransport { r -> + requests += r + val start = startOf(r) + val rows = JSONArray() + for (i in start until minOf(start + CATALOGUE_PAGE_SIZE, size)) rows.put(row("id-$i", if (i % 5 == 0) "Series" else "Movie")) + EmbyReply(200, JSONObject().put("Items", rows).put("TotalRecordCount", size).toString()) + } + private fun run(transport: EmbyTransport, sink: CatalogueSink, user: String = "fixture-user", checkActive: () -> Unit = {}) = + importCatalogue(transport, user, { "fixture-token" }, sink, checkActive = checkActive) + + @Test fun oneRunReadsAtMostFiveBoundedPagesWithTheTokenOnlyInHeaders() { + val requests = mutableListOf(); val sink = MemorySink() + val result = run(library(120_000, requests), sink) + assertEquals(CatalogueRun(5, 1000, false), result) + assertEquals(listOf(0, 200, 400, 600, 800), requests.map(::startOf)) + requests.forEach { r -> + assertEquals("GET", r.method); assertEquals("fixture-token", r.token); assertFalse(r.path.contains("fixture-token")) + assertTrue(r.path.contains("IncludeItemTypes=Movie,Series&")); assertTrue(r.path.contains("Limit=200&")) + assertTrue(r.path.contains("SortBy=DateCreated&SortOrder=Ascending")); assertFalse(r.path.contains("Episode")) + } + assertEquals(1000, sink.records.size); assertEquals(1000L, sink.next); assertEquals(120_000L, sink.total); assertFalse(sink.finished) + } + @Test fun aLaterRunResumesWhereTheLastStopped() { + val requests = mutableListOf(); val sink = MemorySink(); val server = library(120_000, requests) + run(server, sink); requests.clear() + run(server, sink) + assertEquals(1000, startOf(requests.first())); assertEquals(2000, sink.records.size) + } + @Test fun finishesAtTheEndOfTheLibrary() { + val sink = MemorySink(); val result = run(library(350), sink) + assertEquals(CatalogueRun(2, 350, true), result); assertTrue(sink.finished); assertEquals(350L, sink.next) + } + @Test fun anEmptyPageFinishesEvenWithoutATotal() { + val sink = MemorySink() + val result = run(EmbyTransport { EmbyReply(200, JSONObject().put("Items", JSONArray()).toString()) }, sink) + assertTrue(result.finished); assertEquals(1, sink.writes); assertNull(sink.total) + } + @Test fun rateLimitAndLoginFailuresStopAndKeepWholePages() { + for (status in listOf(429, 401, 403)) { + val sink = MemorySink(); val good = library(120_000); var calls = 0 + try { run(EmbyTransport { r -> if (++calls == 3) EmbyReply(status, "") else good.execute(r) }, sink); fail("Status $status accepted") } + catch (failure: EmbyCheckException) { assertEquals(status, failure.httpStatus) } + assertEquals(3, calls); assertEquals(400L, sink.next); assertEquals(400, sink.records.size) + } + } + @Test fun cancellationBeforeAWriteDoesNotAdvanceThePosition() { + val sink = MemorySink(); var checks = 0 + try { run(library(120_000), sink) { if (++checks == 2) throw IllegalStateException("cancelled") }; fail("Cancellation ignored") } + catch (_: IllegalStateException) { } + assertEquals(0L, sink.next); assertEquals(0, sink.writes) + } + @Test fun positionAdvancesPastRowsItCannotUse() { + val rows = JSONArray().put(row("good")).put(row("good")).put(row("../bad")).put(row("season", "Season")).put(JSONObject().put("Id", "untitled").put("Type", "Movie")).put("text") + val page = fetchCataloguePage(EmbyTransport { EmbyReply(200, JSONObject().put("Items", rows).toString()) }, "fixture-user", { "fixture-token" }, 0) + assertEquals(listOf("good"), page.records.map { it.id }); assertEquals(6, page.rawCount) + } + @Test fun aServerIgnoringTheLimitCannotOverfillAPage() { + val rows = JSONArray(); repeat(900) { rows.put(row("id-$it")) } + val page = fetchCataloguePage(EmbyTransport { EmbyReply(200, JSONObject().put("Items", rows).toString()) }, "fixture-user", { "fixture-token" }, 0) + assertEquals(CATALOGUE_PAGE_SIZE, page.rawCount); assertEquals(CATALOGUE_PAGE_SIZE, page.records.size) + } + @Test fun readsAndBoundsMetadataAndToleratesMalformedFields() { + val full = row("full").put("Name", "T".repeat(400)).put("ProductionYear", 1999).put("OfficialRating", "18").put("PremiereDate", "1999-01-01T00:00:00Z") + .put("RunTimeTicks", 54_000_000_000L).put("Genres", JSONArray().put("Horror").put("Cult|Classic")) + .put("Studios", JSONArray().put(JSONObject().put("Name", "Fixture Studio"))).put("ProviderIds", JSONObject().put("Imdb", "tt0").put("Empty", "")) + val odd = row("odd").put("ProductionYear", "1999").put("OfficialRating", 7).put("Genres", "Horror").put("Studios", JSONObject()).put("ProviderIds", JSONArray()).put("RunTimeTicks", -5).put("PremiereDate", 9) + val page = fetchCataloguePage(EmbyTransport { EmbyReply(200, JSONObject().put("Items", JSONArray().put(full).put(odd)).toString()) }, "fixture-user", { "fixture-token" }, 0) + val first = page.records[0] + assertEquals(160, first.title.length); assertEquals(1999, first.year); assertEquals("18", first.rating); assertEquals(5_400_000L, first.runtimeMs) + assertEquals(listOf("Horror", "Cult/Classic"), first.genres); assertEquals(listOf("Fixture Studio"), first.studios); assertEquals(mapOf("Imdb" to "tt0"), first.providerIds) + val second = page.records[1] + assertNull(second.year); assertEquals("", second.rating); assertTrue(second.genres.isEmpty()); assertTrue(second.studios.isEmpty()); assertTrue(second.providerIds.isEmpty()); assertNull(second.runtimeMs); assertEquals("", second.premiere) + } + @Test fun anotherAccountStartsFromTheBeginningAndReplacesTheData() { + val requests = mutableListOf(); val sink = MemorySink(); val server = library(120_000, requests) + run(server, sink, "first-user"); requests.clear() + run(server, sink, "second-user") + assertEquals(0, startOf(requests.first())); assertEquals(1000, sink.records.size); assertEquals("second-user", sink.owner) + } + @Test fun importedTitlesDoNotBecomePlayable() { + val session = EmbySampleSession(library(1000), "fixture-token", "fixture-user", EmbySample(emptyList(), 0)) + val sink = MemorySink() + session.importCatalogue(sink) + try { session.prepare(SampleItem("Fixture id-1", "Movie", null, "", "id-1")); fail("Imported title became playable") } + catch (_: EmbyCheckException) { } + } +}