Add the pinned Conductarr TV client and disk cache
All checks were successful
review/code-claude Claude cross-family review: clean, no blocking findings

This commit is contained in:
justinzeus 2026-11-05 12:56:06 +01:00
parent de452448ba
commit adbfe5d0e7
87 changed files with 21924 additions and 0 deletions

View file

@ -0,0 +1,63 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.nio.file.Files
import org.junit.Assert.*
import org.junit.Test
/** Lifetime boundaries and storage behavior do not depend on wall-clock sleeps. */
class CacheTest {
@Test fun `per endpoint lifetime boundaries preserve expired LKG for failure fallback`() {
val entry = CacheEntry("body", "tag", 1_000, 1_000, expiresAtMillis = 5_000, group = "group")
val rows = listOf(
Triple(CachePolicy.DAILY, ReadTrigger.IF_STALE, 86_400_000L),
Triple(CachePolicy.DISCOVERY, ReadTrigger.IF_STALE, 1_800_000L),
Triple(CachePolicy.RATINGS, ReadTrigger.IF_STALE, 21_600_000L),
Triple(CachePolicy.ACTIVITY, ReadTrigger.POLL, 30_000L),
)
for ((policy, trigger, lifetime) in rows) {
assertFalse(policy.name, policy.shouldRefresh(trigger, entry, 1_000 + lifetime - 1))
assertTrue(policy.name, policy.shouldRefresh(trigger, entry, 1_000 + lifetime))
}
assertFalse(CachePolicy.FEED.shouldRefresh(ReadTrigger.IF_STALE, entry, 4_999))
assertTrue(CachePolicy.FEED.shouldRefresh(ReadTrigger.IF_STALE, entry, 5_000))
assertFalse(CachePolicy.LAUNCH.shouldRefresh(ReadTrigger.IF_STALE, entry, Long.MAX_VALUE))
assertTrue(CachePolicy.LAUNCH.shouldRefresh(ReadTrigger.LAUNCH, entry, 1_001))
assertTrue(CachePolicy.FOREGROUND.shouldRefresh(ReadTrigger.FOREGROUND, entry, 1_001))
assertTrue(CachePolicy.ON_OPEN.shouldRefresh(ReadTrigger.OPEN, entry, 1_001))
assertTrue(CachePolicy.SEASON.shouldRefresh(ReadTrigger.OPEN, entry, 1_001))
assertFalse(CachePolicy.DAILY.shouldRefresh(ReadTrigger.CACHE_ONLY, null, Long.MAX_VALUE))
}
@Test fun `disk and memory retain newest group entries while isolating other identities and media`() {
val directory = Files.createTempDirectory("conductarr-capacity-").toFile()
try {
for (store in listOf(MemoryCacheStore(), FileCacheStore(directory))) {
for (index in 0..300) {
store.write("person-a", "movies:$index", CacheEntry("$index", null, index.toLong(), index.toLong(), group = "movies"), 300)
}
store.write("person-a", "tv:1", CacheEntry("tv", null, 0, 0, group = "tv"), 300)
store.write("person-b", "movies:0", CacheEntry("other", null, 0, 0, group = "movies"), 300)
assertNull(store.read("person-a", "movies:0"))
assertEquals("300", store.read("person-a", "movies:300")!!.body)
assertEquals("tv", store.read("person-a", "tv:1")!!.body)
store.clear("person-a")
assertNull(store.read("person-a", "tv:1"))
assertEquals("other", store.read("person-b", "movies:0")!!.body)
}
} finally { directory.deleteRecursively() }
}
@Test fun `corrupt saved entries become a cache miss rather than blocking recovery`() {
val directory = Files.createTempDirectory("conductarr-corrupt-").toFile()
try {
val store = FileCacheStore(directory)
store.write("person", "profiles:1", CacheEntry("valid", null, 0, 0, group = "profiles"))
directory.walkTopDown().first { it.extension == "json" }.writeText("truncated{")
assertNull(FileCacheStore(directory).read("person", "profiles:1"))
val repaired = CacheEntry("new", null, 1, 1, group = "profiles")
store.write("person", "profiles:1", repaired)
assertEquals(repaired, FileCacheStore(directory).read("person", "profiles:1"))
} finally { directory.deleteRecursively() }
}
}

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.nio.file.Files
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
/** Exercise the production facade with a real local socket and a reopened disk cache. */
fun main() = runBlocking {
val directory = Files.createTempDirectory("conductarr-smoke-").toFile()
try {
MockWebServer().use { server ->
server.start()
val baseUrl = server.url("/").toString()
server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build())
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value
check(live.saved.value!!.profiles!!.single().name == "Justin")
}
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
check(saved.saved.fromDisk)
server.enqueue(MockResponse.Builder().code(304).build())
val revalidated = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Value
check(revalidated.saved.value == saved.saved.value)
server.enqueue(MockResponse.Builder().code(503).body("offline").build())
val failure = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure
check(failure.error == ClientError.Http(503))
check(failure.saved!!.value!!.defaultProfile == "justin")
println("SMOKE: live profile Justin -> disk reopen -> ETag 304 -> HTTP 503 retains Justin; requests=${server.requestCount}")
}
}
} finally { directory.deleteRecursively() }
}

View file

@ -0,0 +1,213 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.io.File
import java.nio.file.Files
import java.util.logging.Handler
import java.util.logging.LogRecord
import java.util.logging.Logger
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.model.*
internal const val TEST_TOKEN = "0123456789abcdef0123456789abcdef"
internal const val PROFILES_JSON = """{"profiles":[{"key":"justin","name":"Justin"}],"default_profile":"justin"}"""
/** Local network tests exercise identity, conditional reads and durable failure recovery. */
class ClientTest {
private lateinit var server: MockWebServer
private val clients = mutableListOf<ConductarrClient>()
private lateinit var directory: File
private var now = 1_700_000_000_000L
@Before fun setUp() {
server = MockWebServer()
server.start()
directory = Files.createTempDirectory("conductarr-test-").toFile()
}
@After fun tearDown() {
clients.forEach { it.close() }
server.close()
directory.deleteRecursively()
}
@Test fun `401 means sign in clears saved identity and never returns stale fallback`() = runBlocking {
val cache = FileCacheStore(directory)
val client = client(cache)
server.enqueue(response(PROFILES_JSON))
client.read(TvRoutes.profiles()).toList()
server.enqueue(response("unauthorized", 401))
val results = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList()
assertEquals("Justin", (results.first() as ReadResult.Value).saved.value!!.profiles!!.first().name)
val failure = results.last() as ReadResult.Failure
assertEquals(ClientError.NeedsSignIn, failure.error)
assertNull(failure.saved)
assertEquals(listOf(ReadResult.Failure<RecommendationProfiles>(ClientError.NeedsSignIn)), client.read(TvRoutes.profiles()).toList())
server.enqueue(response("unauthorized", 401))
val reopened = client(cache).read(TvRoutes.profiles()).toList()
assertEquals(1, reopened.size)
assertEquals(ClientError.NeedsSignIn, (reopened.single() as ReadResult.Failure).error)
assertEquals(3, server.requestCount)
}
@Test fun `ETag 304 reuses body across disk reopen without changing saved time`() = runBlocking {
val first = client(FileCacheStore(directory))
server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"profiles-v1\"").build())
val original = (first.read(TvRoutes.profiles()).toList().single() as ReadResult.Value).saved
val firstRequest = server.takeRequest()
assertEquals("MediaBrowser Token=\"$TEST_TOKEN\"", firstRequest.headers["Authorization"])
assertEquals("/api/v1/recommendations/profiles", firstRequest.url.encodedPath)
now += 60_000
val reopened = client(FileCacheStore(directory))
val painted = reopened.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).first() as ReadResult.Value
assertEquals(original.value, painted.saved.value)
assertTrue(painted.saved.fromDisk)
assertEquals(1, server.requestCount)
server.enqueue(response("", 304))
val results = reopened.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList()
assertEquals(2, results.size)
val validated = (results.last() as ReadResult.Value).saved
assertEquals(original.value, validated.value)
assertEquals(original.savedAtMillis, validated.savedAtMillis)
assertFalse(validated.fromDisk)
assertEquals("\"profiles-v1\"", server.takeRequest().headers["If-None-Match"])
}
@Test fun `HTTP and invalid JSON failures preserve last known good on disk`() = runBlocking {
val cache = FileCacheStore(directory)
server.enqueue(response(PROFILES_JSON))
client(cache).read(TvRoutes.profiles()).toList()
for ((body, status, expected) in listOf(
Triple("down", 503, ClientError.Http(503)),
Triple("{bad json", 200, ClientError.InvalidResponse),
)) {
server.enqueue(response(body, status))
val result = client(FileCacheStore(directory)).read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure
assertEquals(expected, result.error)
assertEquals("justin", result.saved!!.value!!.defaultProfile)
}
val saved = client(FileCacheStore(directory)).read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
assertEquals("Justin", saved.saved.value!!.profiles!!.single().name)
}
@Test fun `network failure retains LKG and maps to unreachable`() = runBlocking {
val client = client()
server.enqueue(response(PROFILES_JSON))
client.read(TvRoutes.profiles()).toList()
server.close()
val failure = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure
assertEquals(ClientError.Unreachable, failure.error)
assertEquals("justin", failure.saved!!.value!!.defaultProfile)
}
@Test fun `identity partitions saved data by token and base URL`() = runBlocking {
val cache = FileCacheStore(directory)
server.enqueue(response(PROFILES_JSON))
client(cache).read(TvRoutes.profiles()).toList()
val switched = client(cache, "fedcba9876543210fedcba9876543210")
assertTrue(switched.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).toList().isEmpty())
val otherServer = ConductarrClient(server.url("/other/").toString(), TEST_TOKEN, cache).also { clients.add(it) }
assertTrue(otherServer.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).toList().isEmpty())
assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(TEST_TOKEN) || it.path.contains(TEST_TOKEN) })
}
@Test fun `ratings 404 is cached as none for six hours but server failure is not cached`() = runBlocking {
val client = client()
val call = TvRoutes.enrichment(42, MediaType.MOVIE)
server.enqueue(response("missing", 404))
assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent)
now += 6 * 60 * 60 * 1000L - 1
assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent)
assertEquals(1, server.requestCount)
now++
server.enqueue(response("down", 503))
assertEquals(ClientError.Http(503), (client.read(call).toList().last() as ReadResult.Failure).error)
server.enqueue(response("missing", 404))
client.read(call).toList()
assertEquals(3, server.requestCount)
}
@Test fun `mutation is sent once without caller controlled actor or automatic replay`() = runBlocking {
val client = client()
val body = RequestIntentBody(mediaType = RequestIntentBody.MediaType.MOVIE, tmdbId = 42, requester = "mindel")
server.enqueue(response("uncertain", 503))
assertEquals(MutationResult.Failure(ClientError.Http(503)), client.mutate(TvRoutes.request(body)))
assertEquals(1, server.requestCount)
val request = server.takeRequest()
assertEquals("POST", request.method)
val sent = WireJson.parseToJsonElement(request.body!!.utf8()).toString()
assertTrue(sent.contains("\"requester\":\"mindel\""))
assertFalse(sent.contains("actor"))
assertFalse(sent.contains("seasons"))
assertTrue(client.read(TvRoutes.requests(42, MediaType.MOVIE), ReadTrigger.CACHE_ONLY).toList().isEmpty())
}
@Test fun `diagnostics and errors never log token or hostile response body`() = runBlocking {
val events = mutableListOf<ClientEvent>()
val logged = StringBuilder()
val handler = object : Handler() {
override fun publish(record: LogRecord) { logged.append(record.message).append(record.thrown?.toString().orEmpty()) }
override fun flush() = Unit
override fun close() = Unit
}
val logger = Logger.getLogger("")
logger.addHandler(handler)
try {
val client = ConductarrClient(server.url("/").toString(), TEST_TOKEN, MemoryCacheStore(), onEvent = { events.add(it) }).also { clients.add(it) }
server.enqueue(response("hostile response $TEST_TOKEN", 500))
val error = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Failure
assertEquals(ClientError.Http(500), error.error)
server.enqueue(response("$TEST_TOKEN", 401))
val unauthorized = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Failure
assertEquals(ClientError.NeedsSignIn, unauthorized.error)
assertEquals(listOf(ClientEvent.HTTP_FAILURE, ClientEvent.NEEDS_SIGN_IN), events)
assertFalse((logged.toString() + events + error + unauthorized).contains(TEST_TOKEN))
} finally { logger.removeHandler(handler) }
}
@Test fun `feed honours server expiry and encodes profile as one path segment`() = runBlocking {
val client = client()
val expiry = java.time.Instant.ofEpochMilli(now + 10_000).toString()
val feed = """{"profile":{"key":"justin","name":"Justin"},"projection":{"coverage":{"complete":true,"excluded":0,"matched":0,"total":0,"unresolved":0},"facts":null,"genre_id":0,"media_type":"all","service_id":0,"unresolved":null},"state":"current","outcome":"empty","materialization":"current","movies":null,"shows":null,"expires_at":"$expiry"}"""
server.enqueue(MockResponse.Builder().body(feed).addHeader("ETag", "\"feed-v1\"").build())
val call = TvRoutes.feed("justin/a b")
client.read(call).toList()
now += 9_999
assertEquals(RecommendationFeed.Outcome.EMPTY, (client.read(call).toList().single() as ReadResult.Value).saved.value!!.outcome)
assertEquals(1, server.requestCount)
val request = server.takeRequest()
assertEquals("/api/v1/recommendations/profiles/justin%2Fa%20b/feed", request.url.encodedPath)
now++
server.enqueue(response("", 304))
val result = client.read(call).toList().last() as ReadResult.Value
assertEquals(RecommendationFeed.Outcome.EMPTY, result.saved.value!!.outcome)
assertEquals("\"feed-v1\"", server.takeRequest().headers["If-None-Match"])
}
@Test fun `successful request returns typed receipt and title requests never use cache`() = runBlocking {
val receipt = """{"actor":{"name":"Justin","requester":"justin","role":"household"},"attempts":1,"coverage":null,"created_at":"2026-11-05T12:00:00Z","id":7,"media_type":"movie","membership":{"status":"added"},"on_behalf":true,"requester":"mindel","requester_username":"Mindel","seasons":null,"seerr":{"outcome":"requested","request_id":9},"source":"title","state":"settled","tmdb_id":42,"updated_at":"2026-11-05T12:00:00Z"}"""
val client = client()
server.enqueue(response(receipt))
val result = client.mutate(TvRoutes.request(RequestIntentBody(mediaType = RequestIntentBody.MediaType.MOVIE, tmdbId = 42, requester = "mindel"))) as MutationResult.Success
assertEquals(7L, result.value.id)
assertEquals("justin", result.value.actor.requester)
assertTrue(result.value.onBehalf)
server.enqueue(response("""{"requests":[$receipt]}"""))
val receipts = client.read(TvRoutes.requests(42, MediaType.MOVIE)).toList().single() as ReadResult.Value
assertEquals(7L, receipts.saved.value!!.requests!!.single().id)
assertTrue(client.read(TvRoutes.requests(42, MediaType.MOVIE), ReadTrigger.CACHE_ONLY).toList().isEmpty())
}
private fun client(cache: CacheStore = MemoryCacheStore(), token: String = TEST_TOKEN): ConductarrClient =
ConductarrClient(server.url("/").toString(), token, cache, nowMillis = { now }).also { clients.add(it) }
private fun response(body: String, status: Int = 200): MockResponse = MockResponse.Builder().code(status).body(body).build()
}

View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-2.0-only
@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
package org.justinvisser.wolfskin.conductarr
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.SerialKind
import kotlinx.serialization.descriptors.StructureKind
import kotlinx.serialization.json.*
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.model.ErrorDetail
/** Schema structure, constraints and route coverage are independent of runtime transport tests. */
class ContractTest {
private val document = resource("openapi.json")
private val schemas = document["components"]!!.jsonObject["schemas"]!!.jsonObject
private val serializers = modelSerializers.associateBy { it.descriptor.serialName.substringAfterLast('.') }
@Test fun `every maintained model matches pinned schema fields types requiredness and enums`() {
for ((name, serializer) in serializers) {
compare(serializer.descriptor, schemas.getValue(name).jsonObject, name)
}
}
@Test fun `all reviewed schema constraints fail on drift including formats limits and defaults`() {
val reviewed = resource("model-contracts.json")
assertEquals(serializers.keys, reviewed.keys)
for (name in reviewed.keys) assertEquals("Schema constraint drift: $name", reviewed[name], schemas[name])
}
@Test fun `all twenty TV operations exist with their typed request and response contracts`() {
val expected = setOf(
"GET /api/v1/operator", "GET /api/v1/recommendations/profiles",
"GET /api/v1/recommendations/profiles/{profile}/feed",
"GET /api/v1/recommendations/profiles/{profile}/dismissals",
"POST /api/v1/recommendations/profiles/{profile}/dismissals",
"DELETE /api/v1/recommendations/profiles/{profile}/dismissals/{type}/{tmdb}",
"GET /catalogue/options", "GET /catalogue/discover",
"GET /catalogue/titles/{tmdb}/{type}", "GET /catalogue/titles/{tmdb}/{type}/cast",
"GET /catalogue/titles/{tmdb}/{type}/related", "GET /catalogue/titles/{tmdb}/{type}/enrichment",
"GET /catalogue/tv/{tmdb}/seasons/{season}", "GET /catalogue/performers/{performer}",
"GET /catalogue/performers/{performer}/filmography",
"GET /api/v1/activity", "GET /api/v1/activity/titles/{tmdb}/{type}",
"GET /api/v1/requests/titles/{tmdb}/{type}", "POST /api/v1/requests",
"GET /curation/library-audiences",
)
assertEquals(expected, TvRoutes.contracts.map { "${it.method} ${it.path}" }.toSet())
assertEquals(expected.size, TvRoutes.contracts.size)
val paths = document["paths"]!!.jsonObject
for (route in TvRoutes.contracts) {
val operation = paths.getValue(route.path).jsonObject.getValue(route.method.lowercase()).jsonObject
val response = operation["responses"]!!.jsonObject["200"]!!.jsonObject
assertEquals(route.responseSchema, schemaReference(response))
assertTrue("Missing response model ${route.responseSchema}", serializers.containsKey(route.responseSchema))
if (route.requestSchema != null) {
assertEquals(route.requestSchema, schemaReference(operation["requestBody"]!!.jsonObject))
assertTrue(serializers.containsKey(route.requestSchema))
}
}
}
@Test fun `unconstrained error values preserve objects arrays scalars and null`() {
for (value in listOf("{\"nested\":[1,null,true]}", "[1,\"x\"]", "42", "true", "null")) {
val detail = WireJson.decodeFromString<ErrorDetail>("{\"value\":$value}")
assertEquals(Json.parseToJsonElement(value), detail.value ?: JsonNull)
}
}
private fun compare(descriptor: SerialDescriptor, schema: JsonObject, location: String) {
schema["\$ref"]?.let {
val name = it.jsonPrimitive.content.substringAfterLast('/')
assertEquals(location, name, descriptor.serialName.substringAfterLast('.').removeSuffix("?"))
assertTrue("Unregistered referenced model $name", serializers.containsKey(name))
return
}
if (schema["type"] == null) {
assertTrue(location, descriptor.serialName.startsWith("kotlinx.serialization.json.JsonElement"))
return
}
schema["enum"]?.let { values ->
assertEquals(location, SerialKind.ENUM, descriptor.kind)
assertEquals(location, values.jsonArray.map { it.jsonPrimitive.content }.toSet(),
(0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet())
return
}
val types = when (val type = schema["type"]!!) {
is JsonArray -> type.map { it.jsonPrimitive.content }
else -> listOf(type.jsonPrimitive.content)
}
when (types.first { it != "null" }) {
"string" -> assertEquals(location, PrimitiveKind.STRING, descriptor.kind)
"integer" -> assertEquals(location, PrimitiveKind.LONG, descriptor.kind)
"number" -> assertEquals(location, PrimitiveKind.DOUBLE, descriptor.kind)
"boolean" -> assertEquals(location, PrimitiveKind.BOOLEAN, descriptor.kind)
"array" -> {
assertEquals(location, StructureKind.LIST, descriptor.kind)
compare(descriptor.getElementDescriptor(0), schema["items"]!!.jsonObject, "$location[]")
}
"object" -> if (schema["additionalProperties"] is JsonObject) {
assertEquals(location, StructureKind.MAP, descriptor.kind)
assertEquals(location, PrimitiveKind.STRING, descriptor.getElementDescriptor(0).kind)
compare(descriptor.getElementDescriptor(1), schema["additionalProperties"]!!.jsonObject, "$location{}")
} else compareFields(descriptor, schema, location)
else -> error("Unhandled schema type at $location")
}
}
private fun compareFields(descriptor: SerialDescriptor, schema: JsonObject, location: String) {
assertEquals(location, StructureKind.CLASS, descriptor.kind)
val properties = schema["properties"]!!.jsonObject.filterValues { it.jsonObject["readOnly"] != JsonPrimitive(true) }
val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }?.toSet().orEmpty()
assertEquals(location, properties.keys, (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet())
for ((name, property) in properties) {
val index = descriptor.getElementIndex(name)
val field = descriptor.getElementDescriptor(index)
assertEquals("Requiredness drift: $location.$name", name !in required, descriptor.isElementOptional(index))
val type = property.jsonObject["type"]
if (type is JsonArray) assertTrue("Nullable collection drift: $location.$name", field.isNullable)
else if (name in required) assertFalse("Nullability drift: $location.$name", field.isNullable)
compare(field, property.jsonObject, "$location.$name")
}
}
private fun schemaReference(container: JsonObject): String = container.getValue("content").jsonObject
.getValue("application/json").jsonObject.getValue("schema").jsonObject.getValue("\$ref").jsonPrimitive.content.substringAfterLast('/')
private fun resource(name: String): JsonObject = javaClass.classLoader.getResourceAsStream(name)!!.use {
Json.parseToJsonElement(it.bufferedReader().readText()).jsonObject
}
}

View file

@ -0,0 +1,77 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import kotlinx.serialization.KSerializer
import org.justinvisser.wolfskin.conductarr.model.*
/** Independently enumerated fallback models checked against the vendored schemas. */
internal val modelSerializers: List<KSerializer<*>> = listOf(
ActingCredit.serializer(),
ActivityFeed.serializer(),
Actor.serializer(),
Candidate.serializer(),
CastMember.serializer(),
Detail.serializer(),
Discovery.serializer(),
Enrichment.serializer(),
Episode.serializer(),
EpisodeFileChip.serializer(),
EpisodeRow.serializer(),
EpisodeView.serializer(),
ErrorDetail.serializer(),
ErrorModel.serializer(),
EvidenceSource.serializer(),
FileDetail.serializer(),
FilmographyPage.serializer(),
FilmographyTitle.serializer(),
Genre.serializer(),
HealthDetail.serializer(),
HeldTitle.serializer(),
ItemDetail.serializer(),
LibraryAudience.serializer(),
LibraryAudiencesBody.serializer(),
LibraryObservation.serializer(),
OpenLink.serializer(),
Operator.serializer(),
OperatorCapabilities.serializer(),
Option.serializer(),
Options.serializer(),
Page.serializer(),
PerformerDetail.serializer(),
Person.serializer(),
Provider.serializer(),
Rating.serializer(),
ReadProfile.serializer(),
ReadinessStage.serializer(),
RecommendationCoverage.serializer(),
RecommendationDismissal.serializer(),
RecommendationDismissals.serializer(),
RecommendationFact.serializer(),
RecommendationFeed.serializer(),
RecommendationHideInputBody.serializer(),
RecommendationHideResult.serializer(),
RecommendationProfiles.serializer(),
RecommendationProjection.serializer(),
RecommendationRestoreResult.serializer(),
RequestIntentBody.serializer(),
RequestProgress.serializer(),
RequestReceipt.serializer(),
RequestReceiptActor.serializer(),
RequestReceiptCoverage.serializer(),
RequestReceiptMembership.serializer(),
RequestReceiptSeerr.serializer(),
Season.serializer(),
SeasonCoverage.serializer(),
SeasonDetail.serializer(),
SeasonGroup.serializer(),
SeedContribution.serializer(),
SeerrObservation.serializer(),
SeerrRequest.serializer(),
SourceDetail.serializer(),
Title.serializer(),
TitleCast.serializer(),
TitleMetadata.serializer(),
TitleRequestsBody.serializer(),
TitleVersion.serializer(),
ViewerReadiness.serializer(),
)

File diff suppressed because it is too large Load diff