Add first stage of library import (0.18.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m45s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 50s

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) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 18:19:17 +00:00
co-authored by Claude Opus 5.5
parent e2ec217428
commit 59e753b45a
10 changed files with 368 additions and 5 deletions
+17
View File
@@ -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.
+2 -2
View File
@@ -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 }
@@ -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<String>, val studios: List<String>, val providerIds: Map<String, String>, val premiere: String, val runtimeMs: Long?)
internal class CataloguePage(val records: List<CatalogueRecord>, 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<CatalogueRecord>, 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<String> = (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)
}
@@ -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)
}
}
@@ -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<CatalogueRecord>, 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 }
}
}
}
@@ -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)
@@ -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<List<MetadataCoverage>?>(null) }
var reviewingCoverage by remember { mutableStateOf(false) }
val catalogue = remember { CatalogueStore.get(appContext) }
var catalogueSummary by remember { mutableStateOf<CatalogueSummary?>(null) }
var catalogueRun by remember { mutableStateOf<String?>(null) }
var reviewingCatalogue by remember { mutableStateOf(false) }
var afterDarkChannel by remember { mutableStateOf<SampleChannel?>(null) }
var toonamiChannel by remember { mutableStateOf<SampleChannel?>(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)) {
@@ -149,6 +149,8 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
fun checkMetadataCoverage(checkActive: () -> Unit = {}): List<MetadataCoverage> =
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<AfterDarkMovie> {
val found = discoverAfterDarkMovies(transport, userId, ::currentToken, genre)
currentToken()
@@ -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()) {
@@ -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<String, CatalogueRecord>()
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<CatalogueRecord>, 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<EmbyRequest> = 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<EmbyRequest>(); 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<EmbyRequest>(); 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<EmbyRequest>(); 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) { }
}
}