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

@ -288,6 +288,7 @@ tasks.named("preBuild") {
} }
dependencies { dependencies {
implementation(project(":conductarr-client"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))

1
conductarr-client/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build/

View file

@ -0,0 +1,64 @@
# Conductarr TV client
A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app depends on this module; no screen, navigation, login flow or production service is changed.
## Pinned contract and generator decision
`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `481492d3a8a0651a89901de548cd511218c0562e`. `openapi/commit.sha` records the source. It was read with `git show <commit>:openapi.json` from the Conductarr clone, not downloaded from a running service.
The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation.
The OpenAPI Generator **7.26.0** probe used the `kotlin` generator with `serializationLibrary=kotlinx_serialization`, `useCoroutines=true`, `modelMutable=false` and `enumPropertyNaming=UPPERCASE`. Generation completed with its OpenAPI 3.1 beta warning. Nullable arrays were retained. However, the unconstrained JSON schema at `ErrorDetail.value` became `@Contextual Any?`. The generated serializer module has no `Any` adapter. Compiling the generated `ErrorDetail` and decoding `{"value":{"nested":[1,null,true]}}` with kotlinx.serialization produced:
```text
Serializer for class 'Any' is not found.
```
Thus the generator does **not** handle every construct in this pinned document. The fallback uses `JsonElement?` for arbitrary error values. Its regression test covers objects, arrays, numbers, booleans and null.
`ContractTest` checks:
- All 20 TV method/path pairs against the pinned paths, including response and mutation body schemas.
- All 68 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them.
- A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority.
When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema.
## API and identity
Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token="<token>"`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved.
`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary.
`client.read(call, trigger)` is a cold `Flow<ReadResult<T>>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists.
`client.mutate(call)` returns a typed success or error. It never uses cached receipts, retries or follows redirects. Unknown request outcomes must be checked using `TvRoutes.requests(tmdb, media)`; do not blindly resend.
The transport owns its OkHttp client rather than accepting arbitrary credential-logging interceptors. Diagnostics expose only `ClientEvent` enum values. Returned errors exclude bodies and exception text. Default timeouts: connection 10 s, read 20 s, whole call 30 s. At most two catalogue reads run concurrently. Close the client when its identity/session is discarded.
## Saved data and refresh events
Pass an **app-private, backup-excluded directory** to `FileCacheStore`; one store instance owns a directory in a process. Writes use a synced temporary file and atomic same-filesystem replacement. Corrupt records become misses. `MemoryCacheStore` is the deterministic fake. Data is not encrypted: cached household content belongs in private app storage, never shared/external storage.
Cache keys partition by normalized server URL, credential digest, endpoint, path parameters and sorted query. Raw credentials are neither stored nor logged. A stale lifetime means refresh eligibility, **not deletion** of LKG. `Saved.savedAtMillis` supplies the Saved HH:MM timestamp; 304 preserves that timestamp while advancing validation time.
| Operation family | Refresh rule |
|---|---|
| Operator and recommendation profiles | Retain indefinitely; `LAUNCH` refreshes |
| Library audiences | Daily |
| Feed | Server `expires_at`; conditional ETag revalidation |
| Catalogue options | `FOREGROUND` refreshes |
| Discovery pages | 30 minutes per parameterized lane/page |
| Enrichment ratings | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG |
| Title, cast, related, performer, filmography, dismissals, readiness | `OPEN` refreshes |
| Seasons | `OPEN` on season focus; retained per season, 300 entries |
| Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` |
| Title requests and request mutations | No cache |
`IF_STALE` observes lifetimes; `FORCE` explicitly revalidates; `CACHE_ONLY` paints without network. The caller schedules launch, foreground, screen-open and activity polling events; the module does not run a poller or own a UI spinner. Title-related calls should be hoisted, not issued by individual cards. ETags are sent when present on any endpoint, regardless of `Cache-Control: no-store`; this is the TV-owned application cache, not an HTTP response cache.
## Verification
The root `make test` selects `testDefaultDebugUnitTest` in this JVM module as well as the app. That module alias runs the JUnit contract/MockWebServer suite and the `ClientSmokeKt` executable. The smoke exercises a real loopback socket, disk reopen, ETag 304, and 503 recovery. All tests are local; none contact Conductarr or Jellyfin.
The build gate is `make test` followed by `make apk-debug`, using the repository's serialized Docker Gradle wrapper. No signed release, installation, ADB, version change or production activation is part of this module.

View file

@ -0,0 +1,36 @@
plugins {
`java-library`
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.plugin.serialization)
}
kotlin {
compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
dependencies {
api(libs.kotlinx.serialization.json)
api(platform(libs.okhttp.bom))
implementation(libs.okhttp)
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${libs.versions.kotlinxCoroutinesTest.get()}")
testImplementation(libs.junit)
testImplementation("com.squareup.okhttp3:mockwebserver3")
}
sourceSets.test { resources.srcDir("openapi") }
val smoke by tasks.registering(JavaExec::class) {
dependsOn(tasks.testClasses)
classpath = sourceSets.test.get().runtimeClasspath
mainClass.set("org.justinvisser.wolfskin.conductarr.ClientSmokeKt")
mustRunAfter(tasks.test)
}
// The root gate selects this name across all projects, including this JVM module.
tasks.register("testDefaultDebugUnitTest") {
dependsOn(tasks.test, smoke)
}

View file

@ -0,0 +1 @@
481492d3a8a0651a89901de548cd511218c0562e

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
/** Caller lifecycle events; no background poller or UI is owned by the client. */
enum class ReadTrigger { IF_STALE, LAUNCH, FOREGROUND, OPEN, POLL, FORCE, CACHE_ONLY }
/** Refresh lifetimes do not expire last-known-good data or delete it on failure. */
enum class CachePolicy(internal val lifetimeMillis: Long, internal val capacity: Int? = null) {
LAUNCH(Long.MAX_VALUE),
DAILY(24 * 60 * 60 * 1000L),
FEED(0),
FOREGROUND(Long.MAX_VALUE),
DISCOVERY(30 * 60 * 1000L),
RATINGS(6 * 60 * 60 * 1000L, 300),
ON_OPEN(Long.MAX_VALUE),
SEASON(Long.MAX_VALUE, 300),
ACTIVITY(30 * 1000L),
NO_CACHE(0),
;
internal fun shouldRefresh(trigger: ReadTrigger, entry: CacheEntry?, now: Long): Boolean {
if (trigger == ReadTrigger.CACHE_ONLY) return false
if (entry == null || trigger == ReadTrigger.FORCE) return true
if (this == LAUNCH && trigger == ReadTrigger.LAUNCH) return true
if ((this == FOREGROUND || this == ACTIVITY) && trigger == ReadTrigger.FOREGROUND) return true
if ((this == ON_OPEN || this == SEASON) && trigger == ReadTrigger.OPEN) return true
val expiry = entry.expiresAtMillis
return if (this == FEED) expiry == null || now >= expiry
else lifetimeMillis != Long.MAX_VALUE && now - entry.validatedAtMillis >= lifetimeMillis
}
}

View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import kotlinx.serialization.Serializable
/** A validated response; saved time is preserved across ETag revalidation. */
@Serializable
data class CacheEntry(
val body: String,
val etag: String?,
val savedAtMillis: Long,
val validatedAtMillis: Long,
val expiresAtMillis: Long? = null,
val absent: Boolean = false,
val group: String,
)
/** Storage is partitioned by server and credential digest; implementations never receive tokens. */
interface CacheStore {
fun read(scope: String, key: String): CacheEntry?
fun write(scope: String, key: String, entry: CacheEntry, capacity: Int? = null)
fun remove(scope: String, key: String)
fun clear(scope: String)
}
/** Deterministic fake with the same group capacity semantics as the disk store. */
class MemoryCacheStore : CacheStore {
private val entries = mutableMapOf<Pair<String, String>, CacheEntry>()
@Synchronized override fun read(scope: String, key: String): CacheEntry? = entries[scope to key]
@Synchronized override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) {
entries[scope to key] = entry
if (capacity != null) {
entries.filter { it.key.first == scope && it.value.group == entry.group }
.entries.sortedByDescending { it.value.validatedAtMillis }
.drop(capacity).forEach { entries.remove(it.key) }
}
}
@Synchronized override fun remove(scope: String, key: String) { entries.remove(scope to key) }
@Synchronized override fun clear(scope: String) { entries.keys.removeAll { it.first == scope } }
}

View file

@ -0,0 +1,232 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.io.Closeable
import java.io.IOException
import java.time.OffsetDateTime
import java.time.format.DateTimeParseException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
/** Failures contain no response bodies, credentials or transport exception text. */
sealed interface ClientError {
data object NeedsSignIn : ClientError
data class Http(val status: Int) : ClientError
data object Unreachable : ClientError
data object InvalidResponse : ClientError
}
/** A successful copy includes the time needed by the Saved HH:MM indicator. */
data class Saved<T>(val value: T?, val savedAtMillis: Long, val fromDisk: Boolean, val absent: Boolean = false)
/** Saved arrives before network work; a refresh failure retains it except on 401. */
sealed interface ReadResult<out T> {
data class Value<T>(val saved: Saved<T>) : ReadResult<T>
data class Failure<T>(val error: ClientError, val saved: Saved<T>? = null) : ReadResult<T>
}
/** Mutation results deliberately cannot contain a cached receipt. */
sealed interface MutationResult<out T> {
data class Success<T>(val value: T) : MutationResult<T>
data class Failure(val error: ClientError) : MutationResult<Nothing>
}
/** Safe diagnostic vocabulary: there is no string sink that could receive a token. */
enum class ClientEvent { REFRESHED, REVALIDATED, NEEDS_SIGN_IN, HTTP_FAILURE, UNREACHABLE, INVALID_RESPONSE, CACHE_UNAVAILABLE }
/** Time limits apply to the entire call as well as connection and response I/O. */
data class ClientTimeouts(val connectSeconds: Long = 10, val readSeconds: Long = 20, val callSeconds: Long = 30)
/** An immutable identity-scoped client; create another client when the selected person changes. */
class ConductarrClient(
baseUrl: String,
private val jellyfinToken: String,
private val cache: CacheStore,
timeouts: ClientTimeouts = ClientTimeouts(),
private val nowMillis: () -> Long = System::currentTimeMillis,
private val onEvent: (ClientEvent) -> Unit = {},
) : Closeable {
private val base = baseUrl.toHttpUrl()
private val scope: String
private val revoked = AtomicBoolean(false)
private val cacheIdentityLock = Any()
private val catalogueReads = Semaphore(2)
private val http = OkHttpClient.Builder()
.connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS)
.readTimeout(timeouts.readSeconds, TimeUnit.SECONDS)
.callTimeout(timeouts.callSeconds, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.retryOnConnectionFailure(false)
.build()
init {
require(base.username.isEmpty() && base.password.isEmpty() && base.query == null && base.fragment == null) { "Base URL must not contain credentials, query or fragment" }
require(base.isHttps || base.host in setOf("localhost", "127.0.0.1", "::1")) { "Conductarr requires HTTPS" }
require(jellyfinToken.matches(Regex("[0-9a-fA-F]{32}"))) { "Expected a Jellyfin access token" }
scope = digest("$base\n$jellyfinToken")
}
/** Emit disk LKG first, then revalidate when the endpoint lifetime or caller event requires it. */
fun <T> read(call: ReadCall<T>, trigger: ReadTrigger = ReadTrigger.IF_STALE): Flow<ReadResult<T>> = flow {
if (revoked.get()) {
emit(ReadResult.Failure(ClientError.NeedsSignIn))
return@flow
}
val url = url(call.contract, call.segments, call.query)
val group = call.contract.path + if (call.policy == CachePolicy.RATINGS) "-${call.segments["type"]}" else ""
val key = "$group:${digest(url.toString())}"
val entry = if (call.policy != CachePolicy.NO_CACHE) cacheRead(key) else null
val saved = entry?.let { decodeSaved(call.serializer, it, true) }
if (saved != null) emit(ReadResult.Value(saved))
if (!call.policy.shouldRefresh(trigger, if (saved != null) entry else null, nowMillis())) return@flow
val request = request(url).apply { if (saved != null) entry.etag?.let { header("If-None-Match", it) } }.build()
val result = if (call.contract.path.startsWith("/catalogue/")) {
catalogueReads.withPermit { refresh(call, request, key, group, entry.takeIf { saved != null }, saved) }
} else refresh(call, request, key, group, entry.takeIf { saved != null }, saved)
emit(result)
}.flowOn(Dispatchers.IO)
/** Execute once; an uncertain request outcome must be checked with the title-requests read. */
suspend fun <T> mutate(call: MutationCall<T>): MutationResult<T> = withContext(Dispatchers.IO) {
if (revoked.get()) return@withContext MutationResult.Failure(ClientError.NeedsSignIn)
val body = call.body?.toRequestBody("application/json".toMediaType())
val request = request(url(call.contract, call.segments, emptyMap())).method(call.contract.method, body).build()
try {
http.newCall(request).awaitResponse().use { response ->
if (response.code == 401) return@withContext MutationResult.Failure(revoke())
if (!response.isSuccessful) return@withContext MutationResult.Failure(ClientError.Http(response.code))
val value = WireJson.decodeFromString(call.serializer, response.body.string())
if (revoked.get()) MutationResult.Failure(ClientError.NeedsSignIn)
else MutationResult.Success(value)
}
} catch (_: IOException) {
onEvent(ClientEvent.UNREACHABLE)
MutationResult.Failure(ClientError.Unreachable)
} catch (_: SerializationException) {
onEvent(ClientEvent.INVALID_RESPONSE)
MutationResult.Failure(ClientError.InvalidResponse)
}
}
private suspend fun <T> refresh(call: ReadCall<T>, request: Request, key: String, group: String, entry: CacheEntry?, saved: Saved<T>?): ReadResult<T> {
return try {
http.newCall(request).awaitResponse().use { response ->
if (response.code == 401) return ReadResult.Failure(revoke())
if (revoked.get()) return ReadResult.Failure(ClientError.NeedsSignIn)
if (response.code == 304) {
if (entry == null || saved == null) return ReadResult.Failure(ClientError.InvalidResponse)
cacheWrite(key, entry.copy(etag = response.header("ETag") ?: entry.etag, validatedAtMillis = nowMillis()), call.policy)
onEvent(ClientEvent.REVALIDATED)
return ReadResult.Value(saved.copy(fromDisk = false))
}
if (response.code == 404 && call.policy == CachePolicy.RATINGS) {
val absent = CacheEntry("", null, nowMillis(), nowMillis(), absent = true, group = group)
cacheWrite(key, absent, call.policy)
return ReadResult.Value(Saved(null, absent.savedAtMillis, false, true))
}
if (!response.isSuccessful) {
onEvent(ClientEvent.HTTP_FAILURE)
return ReadResult.Failure(ClientError.Http(response.code), saved)
}
val body = response.body.string()
val value = WireJson.decodeFromString(call.serializer, body)
val timestamp = nowMillis()
val updated = CacheEntry(body, response.header("ETag"), timestamp, timestamp, feedExpiry(call.policy, body), group = group)
if (call.policy != CachePolicy.NO_CACHE) cacheWrite(key, updated, call.policy)
onEvent(ClientEvent.REFRESHED)
ReadResult.Value(Saved(value, timestamp, false))
}
} catch (_: IOException) {
onEvent(ClientEvent.UNREACHABLE)
ReadResult.Failure(ClientError.Unreachable, saved)
} catch (_: SerializationException) {
onEvent(ClientEvent.INVALID_RESPONSE)
ReadResult.Failure(ClientError.InvalidResponse, saved)
}
}
private fun request(url: HttpUrl): Request.Builder = Request.Builder().url(url)
.header("Authorization", "MediaBrowser Token=\"$jellyfinToken\"")
.header("Accept", "application/json")
private fun url(contract: RouteContract, segments: Map<String, String>, query: Map<String, String>): HttpUrl {
val builder = base.newBuilder()
for (segment in contract.path.trimStart('/').split('/')) {
builder.addPathSegment(if (segment.startsWith('{')) segments.getValue(segment.removeSurrounding("{", "}")) else segment)
}
query.toSortedMap().forEach { (name, value) -> builder.addQueryParameter(name, value) }
return builder.build()
}
private fun <T> decodeSaved(serializer: KSerializer<T>, entry: CacheEntry, fromDisk: Boolean): Saved<T>? = try {
Saved(if (entry.absent) null else WireJson.decodeFromString(serializer, entry.body), entry.savedAtMillis, fromDisk, entry.absent)
} catch (_: SerializationException) { null }
private fun cacheRead(key: String): CacheEntry? = try { cache.read(scope, key) } catch (_: IOException) {
onEvent(ClientEvent.CACHE_UNAVAILABLE)
null
}
private fun cacheWrite(key: String, entry: CacheEntry, policy: CachePolicy) = synchronized(cacheIdentityLock) {
if (!revoked.get()) {
try { cache.write(scope, key, entry, policy.capacity) } catch (_: IOException) { onEvent(ClientEvent.CACHE_UNAVAILABLE) }
}
}
private fun revoke(): ClientError {
synchronized(cacheIdentityLock) {
revoked.set(true)
try { cache.clear(scope) } catch (_: IOException) { onEvent(ClientEvent.CACHE_UNAVAILABLE) }
}
onEvent(ClientEvent.NEEDS_SIGN_IN)
return ClientError.NeedsSignIn
}
private fun feedExpiry(policy: CachePolicy, body: String): Long? {
if (policy != CachePolicy.FEED) return null
val expiry = WireJson.parseToJsonElement(body).jsonObject["expires_at"]?.jsonPrimitive?.content ?: return null
return try { OffsetDateTime.parse(expiry).toInstant().toEpochMilli() } catch (_: DateTimeParseException) { null }
}
/** Release this instance's network resources; disk LKG survives. */
override fun close() {
http.dispatcher.cancelAll()
http.dispatcher.executorService.shutdown()
http.connectionPool.evictAll()
}
}
private suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation { cancel() }
enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (!continuation.isCancelled) continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) { _, value, _ -> value.close() }
}
})
}

View file

@ -0,0 +1,61 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import kotlinx.serialization.SerializationException
/** App-private disk cache, safe to reopen after process death; one store instance per directory. */
class FileCacheStore(private val directory: File) : CacheStore {
@Synchronized override fun read(scope: String, key: String): CacheEntry? {
val file = entryFile(scope, key)
if (!file.exists()) return null
return try {
WireJson.decodeFromString<CacheEntry>(file.readText())
} catch (_: SerializationException) {
file.delete()
null
}
}
@Synchronized override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) {
val target = entryFile(scope, key)
val parent = target.parentFile!!
if (!parent.isDirectory && !parent.mkdirs()) throw IOException("Cache directory unavailable")
val temporary = File.createTempFile("entry-", ".tmp", parent)
try {
temporary.outputStream().use { output ->
output.write(WireJson.encodeToString(CacheEntry.serializer(), entry).toByteArray(Charsets.UTF_8))
output.fd.sync()
}
if (!temporary.renameTo(target)) throw IOException("Cache replacement failed")
target.setLastModified(entry.validatedAtMillis)
} finally {
temporary.delete()
}
if (capacity != null) {
parent.listFiles { file -> file.extension == "json" }
?.sortedByDescending { it.lastModified() }?.drop(capacity)?.forEach { it.delete() }
}
}
@Synchronized override fun remove(scope: String, key: String) { entryFile(scope, key).delete() }
@Synchronized override fun clear(scope: String) { File(directory, digest(scope)).deleteRecursively() }
private fun entryFile(scope: String, key: String): File =
File(File(File(directory, digest(scope)), digest(key.substringBefore(':'))), digest(key) + ".json")
}
/** Opaque storage names never expose credentials, query parameters or household names. */
internal fun digest(value: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
val hex = "0123456789abcdef"
val characters = CharArray(bytes.size * 2)
bytes.forEachIndexed { index, byte ->
val number = byte.toInt() and 0xff
characters[index * 2] = hex[number ushr 4]
characters[index * 2 + 1] = hex[number and 0x0f]
}
return String(characters)
}

View file

@ -0,0 +1,137 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import kotlinx.serialization.KSerializer
import org.justinvisser.wolfskin.conductarr.model.*
/** A TV-used operation and its pinned request/response contract. */
class RouteContract internal constructor(
val method: String,
val path: String,
val responseSchema: String,
val requestSchema: String? = null,
)
/** A typed read; only TvRoutes can construct paths and policies. */
class ReadCall<T> internal constructor(
internal val contract: RouteContract,
internal val serializer: KSerializer<T>,
internal val segments: Map<String, String> = emptyMap(),
internal val query: Map<String, String> = emptyMap(),
internal val policy: CachePolicy,
)
/** A single mutation; failures are returned without automatic retry or replay. */
class MutationCall<T> internal constructor(
internal val contract: RouteContract,
internal val serializer: KSerializer<T>,
internal val segments: Map<String, String>,
internal val body: String? = null,
)
/** The catalogue media type accepted by TV operations. */
enum class MediaType(internal val wire: String) { MOVIE("movie"), TV("tv") }
/** Feed filter; the unfiltered feed uses all. */
enum class MediaFilter(internal val wire: String) { ALL("all"), MOVIE("movie"), TV("tv") }
/** The four discovery sorts used by the TV. */
enum class DiscoverSort(internal val wire: String) { POPULAR("popular"), NEW("new"), TOP("top"), UPCOMING("upcoming") }
/** The complete TV route allowlist; caller identity is never a request body field. */
object TvRoutes {
private val operator = RouteContract("GET", "/api/v1/operator", "Operator")
private val profiles = RouteContract("GET", "/api/v1/recommendations/profiles", "RecommendationProfiles")
private val feed = RouteContract("GET", "/api/v1/recommendations/profiles/{profile}/feed", "RecommendationFeed")
private val dismissals = RouteContract("GET", "/api/v1/recommendations/profiles/{profile}/dismissals", "RecommendationDismissals")
private val hide = RouteContract("POST", dismissals.path, "RecommendationHideResult", "RecommendationHideInputBody")
private val restore = RouteContract("DELETE", "${dismissals.path}/{type}/{tmdb}", "RecommendationRestoreResult")
private val options = RouteContract("GET", "/catalogue/options", "Options")
private val discover = RouteContract("GET", "/catalogue/discover", "Page")
private val title = RouteContract("GET", "/catalogue/titles/{tmdb}/{type}", "Detail")
private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast")
private val related = RouteContract("GET", "${title.path}/related", "Page")
private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment")
private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail")
private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail")
private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage")
private val activity = RouteContract("GET", "/api/v1/activity", "ActivityFeed")
private val readiness = RouteContract("GET", "/api/v1/activity/titles/{tmdb}/{type}", "ViewerReadiness")
private val requests = RouteContract("GET", "/api/v1/requests/titles/{tmdb}/{type}", "TitleRequestsBody")
private val request = RouteContract("POST", "/api/v1/requests", "RequestReceipt", "RequestIntentBody")
private val audiences = RouteContract("GET", "/curation/library-audiences", "LibraryAudiencesBody")
val contracts: List<RouteContract> = listOf(
operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast,
related, enrichment, season, performer, filmography, activity, readiness, requests, request, audiences,
)
fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH)
fun profiles() = ReadCall(profiles, RecommendationProfiles.serializer(), policy = CachePolicy.LAUNCH)
fun audiences() = ReadCall(audiences, LibraryAudiencesBody.serializer(), policy = CachePolicy.DAILY)
fun feed(profile: String, media: MediaFilter = MediaFilter.ALL) = ReadCall(
feed, RecommendationFeed.serializer(), mapOf("profile" to profile),
mapOf("media_type" to media.wire), CachePolicy.FEED,
)
fun dismissals(profile: String) = ReadCall(
dismissals, RecommendationDismissals.serializer(), mapOf("profile" to profile), policy = CachePolicy.ON_OPEN,
)
fun options(media: MediaType) = ReadCall(
options, Options.serializer(), query = mapOf("media_type" to media.wire), policy = CachePolicy.FOREGROUND,
)
fun discover(media: MediaType, page: Int = 1, genreId: Long? = null, serviceId: Long? = null, sort: DiscoverSort = DiscoverSort.POPULAR): ReadCall<Page> {
require(page in 1..500)
val query = buildMap {
put("media_type", media.wire)
put("page", page.toString())
put("sort", sort.wire)
genreId?.let { put("genre_id", it.toString()) }
serviceId?.let { put("service_id", it.toString()) }
}
return ReadCall(discover, Page.serializer(), query = query, policy = CachePolicy.DISCOVERY)
}
fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS)
fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall(
related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN,
)
fun season(tmdb: Long, season: Int): ReadCall<SeasonDetail> {
require(tmdb > 0 && season >= 0)
return ReadCall(this.season, SeasonDetail.serializer(), mapOf("tmdb" to tmdb.toString(), "season" to season.toString()), policy = CachePolicy.SEASON)
}
fun performer(performer: Long) = ReadCall(
this.performer, PerformerDetail.serializer(), mapOf("performer" to performer.toString()), policy = CachePolicy.ON_OPEN,
)
fun filmography(performer: Long, page: Int = 1, pageSize: Int = 20) = ReadCall(
filmography, FilmographyPage.serializer(), mapOf("performer" to performer.toString()),
mapOf("page" to page.toString(), "page_size" to pageSize.toString()), CachePolicy.ON_OPEN,
)
fun activity() = ReadCall(activity, ActivityFeed.serializer(), query = mapOf("requester" to "all"), policy = CachePolicy.ACTIVITY)
fun readiness(tmdb: Long, media: MediaType, viewer: String) = ReadCall(
readiness, ViewerReadiness.serializer(), titleSegments(tmdb, media), mapOf("viewer" to viewer), CachePolicy.ON_OPEN,
)
fun requests(tmdb: Long, media: MediaType) = ReadCall(
requests, TitleRequestsBody.serializer(), titleSegments(tmdb, media), policy = CachePolicy.NO_CACHE,
)
fun request(body: RequestIntentBody) = MutationCall(
request, RequestReceipt.serializer(), emptyMap(), WireJson.encodeToString(RequestIntentBody.serializer(), body),
)
fun hide(profile: String, body: RecommendationHideInputBody) = MutationCall(
hide, RecommendationHideResult.serializer(), mapOf("profile" to profile),
WireJson.encodeToString(RecommendationHideInputBody.serializer(), body),
)
fun restore(profile: String, tmdb: Long, media: MediaType) = MutationCall(
restore, RecommendationRestoreResult.serializer(), titleSegments(tmdb, media) + ("profile" to profile),
)
private fun titleSegments(tmdb: Long, media: MediaType): Map<String, String> {
require(tmdb > 0)
return mapOf("tmdb" to tmdb.toString(), "type" to media.wire)
}
}

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr
import kotlinx.serialization.json.Json
/** Shared wire encoding omits absent optional request fields. */
internal val WireJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = false
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** ActingCredit wire contract from the pinned Conductarr schema. */
@Serializable
data class ActingCredit(
@SerialName("character") val character: String,
@SerialName("credit_id") val creditId: String,
@SerialName("episode_count") val episodeCount: Long? = null,
)

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** ActivityFeed wire contract from the pinned Conductarr schema. */
@Serializable
data class ActivityFeed(
@SerialName("items") val items: List<RequestProgress>?,
@SerialName("requester") val requester: String,
@SerialName("sources") val sources: List<EvidenceSource>?,
@SerialName("truncated") val truncated: Boolean,
)

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Actor wire contract from the pinned Conductarr schema. */
@Serializable
data class Actor(
@SerialName("name") val name: String,
@SerialName("requester") val requester: String,
@SerialName("role") val role: Role,
) {
/** Wire values for role. */
@Serializable
enum class Role {
@SerialName("admin") ADMIN,
@SerialName("household") HOUSEHOLD,
}
}

View file

@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Candidate wire contract from the pinned Conductarr schema. */
@Serializable
data class Candidate(
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("contributions") val contributions: List<SeedContribution>?,
@SerialName("genre_ids") val genreIds: List<Long>? = null,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("overview") val overview: String,
@SerialName("poster_path") val posterPath: String,
@SerialName("score") val score: Double,
@SerialName("title") val title: String,
@SerialName("tmdb_id") val tmdbId: Long,
@SerialName("tvdb_id") val tvdbId: Long,
@SerialName("year") val year: Long,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

View file

@ -0,0 +1,16 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** CastMember wire contract from the pinned Conductarr schema. */
@Serializable
data class CastMember(
@SerialName("character") val character: String,
@SerialName("credit_id") val creditId: String,
@SerialName("name") val name: String,
@SerialName("order") val order: Long,
@SerialName("performer_id") val performerId: Long,
@SerialName("profile_url") val profileUrl: String? = null,
)

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Detail wire contract from the pinned Conductarr schema. */
@Serializable
data class Detail(
@SerialName("genres") val genres: List<String>?,
@SerialName("held_title") val heldTitle: HeldTitle? = null,
@SerialName("metadata") val metadata: TitleMetadata,
@SerialName("metadata_state") val metadataState: MetadataState,
@SerialName("observations") val observations: List<LibraryObservation>?,
@SerialName("ratings") val ratings: List<Rating>? = null,
@SerialName("runtime") val runtime: Long? = null,
@SerialName("seasons") val seasons: List<Season>?,
@SerialName("state") val state: State,
@SerialName("title") val title: Title,
) {
/** Wire values for metadata_state. */
@Serializable
enum class MetadataState {
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("partial") PARTIAL,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Discovery wire contract from the pinned Conductarr schema. */
@Serializable
data class Discovery(
@SerialName("genre_id") val genreId: Long,
@SerialName("next") val next: String? = null,
@SerialName("region") val region: String,
@SerialName("service_id") val serviceId: Long,
@SerialName("sort") val sort: Sort,
@SerialName("state") val state: State,
) {
/** Wire values for sort. */
@Serializable
enum class Sort {
@SerialName("trending") TRENDING,
@SerialName("popular") POPULAR,
@SerialName("new") NEW,
@SerialName("top") TOP,
@SerialName("upcoming") UPCOMING,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("empty") EMPTY,
@SerialName("partial") PARTIAL,
@SerialName("exhausted") EXHAUSTED,
}
}

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Enrichment wire contract from the pinned Conductarr schema. */
@Serializable
data class Enrichment(
@SerialName("genres") val genres: List<String>?,
@SerialName("metadata") val metadata: TitleMetadata,
@SerialName("metadata_state") val metadataState: MetadataState,
@SerialName("observations") val observations: List<LibraryObservation>?,
@SerialName("ratings") val ratings: List<Rating>?,
@SerialName("runtime") val runtime: Long? = null,
@SerialName("seasons") val seasons: List<Season>?,
@SerialName("state") val state: State,
@SerialName("title") val title: Title,
) {
/** Wire values for metadata_state. */
@Serializable
enum class MetadataState {
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Episode wire contract from the pinned Conductarr schema. */
@Serializable
data class Episode(
@SerialName("air_date") val airDate: String? = null,
@SerialName("name") val name: String,
@SerialName("number") val number: Long,
@SerialName("overview") val overview: String,
@SerialName("recorded") val recorded: Recorded,
) {
/** Wire values for recorded. */
@Serializable
enum class Recorded {
@SerialName("recorded") RECORDED,
@SerialName("missing") MISSING,
@SerialName("unknown") UNKNOWN,
}
}

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** EpisodeFileChip wire contract from the pinned Conductarr schema. */
@Serializable
data class EpisodeFileChip(
@SerialName("quality") val quality: String? = null,
@SerialName("release_name") val releaseName: String? = null,
@SerialName("resolution") val resolution: String? = null,
@SerialName("size_bytes") val sizeBytes: Long,
)

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** EpisodeRow wire contract from the pinned Conductarr schema. */
@Serializable
data class EpisodeRow(
@SerialName("air_date") val airDate: String? = null,
@SerialName("episode") val episode: Long,
@SerialName("file") val file: EpisodeFileChip? = null,
@SerialName("has_file") val hasFile: Boolean,
@SerialName("key") val key: String,
@SerialName("monitored") val monitored: Boolean,
@SerialName("queue_state") val queueState: String? = null,
@SerialName("runtime_minutes") val runtimeMinutes: Long? = null,
@SerialName("season") val season: Long,
@SerialName("title") val title: String? = null,
@SerialName("watched") val watched: Map<String,Boolean>,
)

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** EpisodeView wire contract from the pinned Conductarr schema. */
@Serializable
data class EpisodeView(
@SerialName("owners") val owners: List<String>?,
@SerialName("seasons") val seasons: List<SeasonGroup>?,
@SerialName("title") val title: String,
@SerialName("tmdb_id") val tmdbId: Long,
@SerialName("users") val users: List<String>?,
)

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** ErrorDetail wire contract from the pinned Conductarr schema. */
@Serializable
data class ErrorDetail(
@SerialName("location") val location: String? = null,
@SerialName("message") val message: String? = null,
@SerialName("value") val `value`: JsonElement? = null,
)

View file

@ -0,0 +1,16 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** ErrorModel wire contract from the pinned Conductarr schema. */
@Serializable
data class ErrorModel(
@SerialName("detail") val detail: String? = null,
@SerialName("errors") val errors: List<ErrorDetail>? = null,
@SerialName("instance") val instance: String? = null,
@SerialName("status") val status: Long? = null,
@SerialName("title") val title: String? = null,
@SerialName("type") val type: String? = null,
)

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** EvidenceSource wire contract from the pinned Conductarr schema. */
@Serializable
data class EvidenceSource(
@SerialName("detail") val detail: String? = null,
@SerialName("name") val name: String,
@SerialName("state") val state: State,
) {
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** FileDetail wire contract from the pinned Conductarr schema. */
@Serializable
data class FileDetail(
@SerialName("audio") val audio: String? = null,
@SerialName("container") val container: String? = null,
@SerialName("quality") val quality: String? = null,
@SerialName("release_name") val releaseName: String? = null,
@SerialName("resolution") val resolution: String? = null,
@SerialName("size_bytes") val sizeBytes: Long,
@SerialName("video") val video: String? = null,
)

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** FilmographyPage wire contract from the pinned Conductarr schema. */
@Serializable
data class FilmographyPage(
@SerialName("completeness") val completeness: Completeness,
@SerialName("items") val items: List<FilmographyTitle>?,
@SerialName("metadata_state") val metadataState: MetadataState,
@SerialName("observations") val observations: List<LibraryObservation>?,
@SerialName("page") val page: Long,
@SerialName("page_size") val pageSize: Long,
@SerialName("performer_id") val performerId: Long,
@SerialName("source") val source: Source,
@SerialName("source_paging") val sourcePaging: SourcePaging,
@SerialName("total_pages") val totalPages: Long? = null,
@SerialName("total_results") val totalResults: Long? = null,
) {
/** Wire values for completeness. */
@Serializable
enum class Completeness {
@SerialName("returned-non-adult-cast") RETURNED_NON_ADULT_CAST,
}
/** Wire values for metadata_state. */
@Serializable
enum class MetadataState {
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
/** Wire values for source. */
@Serializable
enum class Source {
@SerialName("seerr") SEERR,
}
/** Wire values for source_paging. */
@Serializable
enum class SourcePaging {
@SerialName("unpaged") UNPAGED,
}
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** FilmographyTitle wire contract from the pinned Conductarr schema. */
@Serializable
data class FilmographyTitle(
@SerialName("credits") val credits: List<ActingCredit>?,
@SerialName("title") val title: Title,
@SerialName("users") val users: List<String>?,
@SerialName("visibility_state") val visibilityState: VisibilityState,
) {
/** Wire values for visibility_state. */
@Serializable
enum class VisibilityState {
@SerialName("configured") CONFIGURED,
@SerialName("unknown") UNKNOWN,
}
}

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Genre wire contract from the pinned Conductarr schema. */
@Serializable
data class Genre(
@SerialName("id") val id: Long,
@SerialName("name") val name: String,
)

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** HealthDetail wire contract from the pinned Conductarr schema. */
@Serializable
data class HealthDetail(
@SerialName("failed") val failed: Boolean,
@SerialName("has_file") val hasFile: Boolean,
@SerialName("in_queue") val inQueue: Boolean,
@SerialName("missing") val missing: Boolean,
)

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** HeldTitle wire contract from the pinned Conductarr schema. */
@Serializable
data class HeldTitle(
@SerialName("detail") val detail: ItemDetail,
@SerialName("episodes") val episodes: EpisodeView? = null,
@SerialName("libraries") val libraries: List<String>?,
@SerialName("observations") val observations: List<LibraryObservation>?,
@SerialName("state") val state: State,
@SerialName("versions") val versions: List<TitleVersion>?,
@SerialName("watched") val watched: Map<String,Boolean>,
) {
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("partial") PARTIAL,
}
}

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** ItemDetail wire contract from the pinned Conductarr schema. */
@Serializable
data class ItemDetail(
@SerialName("backdrop_ref") val backdropRef: String? = null,
@SerialName("file") val file: FileDetail? = null,
@SerialName("health") val health: HealthDetail,
@SerialName("is_anime") val isAnime: Boolean,
@SerialName("media_type") val mediaType: String,
@SerialName("owners") val owners: List<String>?,
@SerialName("poster_ref") val posterRef: String? = null,
@SerialName("runtime") val runtime: Long? = null,
@SerialName("source") val source: SourceDetail,
@SerialName("title") val title: String,
@SerialName("tmdb_id") val tmdbId: Long,
@SerialName("users") val users: List<String>?,
@SerialName("year") val year: Long? = null,
)

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** LibraryAudience wire contract from the pinned Conductarr schema. */
@Serializable
data class LibraryAudience(
@SerialName("is_anime") val isAnime: Boolean,
@SerialName("libraries") val libraries: List<String>?,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("owner") val owner: String,
@SerialName("users") val users: List<String>?,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** LibraryAudiencesBody wire contract from the pinned Conductarr schema. */
@Serializable
data class LibraryAudiencesBody(
@SerialName("audiences") val audiences: List<LibraryAudience>?,
)

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** LibraryObservation wire contract from the pinned Conductarr schema. */
@Serializable
data class LibraryObservation(
@SerialName("detail") val detail: String? = null,
@SerialName("observed_at") val observedAt: String? = null,
@SerialName("source") val source: String,
@SerialName("state") val state: State,
) {
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("partial") PARTIAL,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** OpenLink wire contract from the pinned Conductarr schema. */
@Serializable
data class OpenLink(
@SerialName("detail") val detail: String? = null,
@SerialName("state") val state: State,
@SerialName("url") val url: String? = null,
) {
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
@SerialName("unconfigured") UNCONFIGURED,
@SerialName("not_permitted") NOT_PERMITTED,
}
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Operator wire contract from the pinned Conductarr schema. */
@Serializable
data class Operator(
@SerialName("capabilities") val capabilities: OperatorCapabilities,
@SerialName("name") val name: String,
@SerialName("recommendation_profile") val recommendationProfile: String,
@SerialName("requester") val requester: String,
@SerialName("role") val role: Role,
@SerialName("subject") val subject: String,
@SerialName("watch_user") val watchUser: String,
) {
/** Wire values for role. */
@Serializable
enum class Role {
@SerialName("admin") ADMIN,
@SerialName("household") HOUSEHOLD,
}
}

View file

@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** OperatorCapabilities wire contract from the pinned Conductarr schema. */
@Serializable
data class OperatorCapabilities(
@SerialName("administer") val administer: Boolean,
@SerialName("manage_library") val manageLibrary: Boolean,
@SerialName("manage_memberships") val manageMemberships: Boolean,
@SerialName("recommendation_profiles") val recommendationProfiles: List<String>?,
@SerialName("request_media") val requestMedia: Boolean,
@SerialName("requesters") val requesters: List<String>?,
@SerialName("watch_users") val watchUsers: List<String>?,
)

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Option wire contract from the pinned Conductarr schema. */
@Serializable
data class Option(
@SerialName("id") val id: Long,
@SerialName("logo_url") val logoUrl: String? = null,
@SerialName("name") val name: String,
)

View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Options wire contract from the pinned Conductarr schema. */
@Serializable
data class Options(
@SerialName("genres") val genres: List<Option>?,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("observations") val observations: List<LibraryObservation>?,
@SerialName("region") val region: String,
@SerialName("services") val services: List<Option>?,
@SerialName("state") val state: State,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("partial") PARTIAL,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Page wire contract from the pinned Conductarr schema. */
@Serializable
data class Page(
@SerialName("discovery") val discovery: Discovery? = null,
@SerialName("items") val items: List<Title>?,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("observations") val observations: List<LibraryObservation>?,
@SerialName("page") val page: Long,
@SerialName("query") val query: String,
@SerialName("source") val source: Source,
@SerialName("state") val state: State,
@SerialName("total_pages") val totalPages: Long,
@SerialName("total_results") val totalResults: Long,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("all") ALL,
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
/** Wire values for source. */
@Serializable
enum class Source {
@SerialName("search") SEARCH,
@SerialName("general") GENERAL,
@SerialName("related") RELATED,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("partial") PARTIAL,
}
}

View file

@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** PerformerDetail wire contract from the pinned Conductarr schema. */
@Serializable
data class PerformerDetail(
@SerialName("also_known_as") val alsoKnownAs: List<String>? = null,
@SerialName("biography") val biography: String? = null,
@SerialName("birthday") val birthday: String? = null,
@SerialName("deathday") val deathday: String? = null,
@SerialName("known_for_department") val knownForDepartment: String? = null,
@SerialName("metadata_state") val metadataState: MetadataState,
@SerialName("name") val name: String? = null,
@SerialName("performer_id") val performerId: Long,
@SerialName("place_of_birth") val placeOfBirth: String? = null,
@SerialName("profile_url") val profileUrl: String? = null,
@SerialName("source") val source: Source,
) {
/** Wire values for metadata_state. */
@Serializable
enum class MetadataState {
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
/** Wire values for source. */
@Serializable
enum class Source {
@SerialName("seerr") SEERR,
}
}

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Person wire contract from the pinned Conductarr schema. */
@Serializable
data class Person(
@SerialName("key") val key: String,
@SerialName("name") val name: String,
)

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Provider wire contract from the pinned Conductarr schema. */
@Serializable
data class Provider(
@SerialName("id") val id: Long,
@SerialName("logo_url") val logoUrl: String? = null,
@SerialName("name") val name: String,
)

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Rating wire contract from the pinned Conductarr schema. */
@Serializable
data class Rating(
@SerialName("score") val score: Double? = null,
@SerialName("source") val source: Source,
@SerialName("state") val state: State,
@SerialName("status") val status: String? = null,
@SerialName("url") val url: String? = null,
@SerialName("votes") val votes: Long? = null,
) {
/** Wire values for source. */
@Serializable
enum class Source {
@SerialName("rt_critics") RT_CRITICS,
@SerialName("rt_audience") RT_AUDIENCE,
@SerialName("imdb") IMDB,
@SerialName("tmdb") TMDB,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("unrated") UNRATED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** ReadProfile wire contract from the pinned Conductarr schema. */
@Serializable
data class ReadProfile(
@SerialName("key") val key: String,
@SerialName("name") val name: String,
)

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** ReadinessStage wire contract from the pinned Conductarr schema. */
@Serializable
data class ReadinessStage(
@SerialName("name") val name: Name,
@SerialName("observed_at") val observedAt: String? = null,
@SerialName("source") val source: String,
@SerialName("state") val state: State,
@SerialName("summary") val summary: String,
) {
/** Wire values for name. */
@Serializable
enum class Name {
@SerialName("request") REQUEST,
@SerialName("acquisition") ACQUISITION,
@SerialName("visibility") VISIBILITY,
@SerialName("playback") PLAYBACK,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("done") DONE,
@SerialName("partial") PARTIAL,
@SerialName("in_progress") IN_PROGRESS,
@SerialName("none") NONE,
@SerialName("unknown") UNKNOWN,
@SerialName("stale") STALE,
@SerialName("conflicting") CONFLICTING,
@SerialName("failed") FAILED,
}
}

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationCoverage wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationCoverage(
@SerialName("complete") val complete: Boolean,
@SerialName("excluded") val excluded: Long,
@SerialName("matched") val matched: Long,
@SerialName("total") val total: Long,
@SerialName("unresolved") val unresolved: Long,
)

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationDismissal wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationDismissal(
@SerialName("hidden_at") val hiddenAt: String,
@SerialName("hidden_by") val hiddenBy: String,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("poster_path") val posterPath: String,
@SerialName("title") val title: String,
@SerialName("tmdb_id") val tmdbId: Long,
@SerialName("year") val year: Long,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationDismissals wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationDismissals(
@SerialName("dismissals") val dismissals: List<RecommendationDismissal>?,
@SerialName("profile") val profile: String,
)

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationFact wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationFact(
@SerialName("genre_source") val genreSource: GenreSource,
@SerialName("match") val match: Match,
@SerialName("metadata") val metadata: TitleMetadata,
) {
/** Wire values for genre_source. */
@Serializable
enum class GenreSource {
@SerialName("ranked") RANKED,
@SerialName("metadata") METADATA,
@SerialName("unknown") UNKNOWN,
}
/** Wire values for match. */
@Serializable
enum class Match {
@SerialName("matched") MATCHED,
@SerialName("excluded") EXCLUDED,
@SerialName("unresolved") UNRESOLVED,
}
}

View file

@ -0,0 +1,57 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationFeed wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationFeed(
@SerialName("attempted_at") val attemptedAt: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("failure") val failure: Failure? = null,
@SerialName("materialization") val materialization: Materialization,
@SerialName("movies") val movies: List<Candidate>?,
@SerialName("observed_at") val observedAt: String? = null,
@SerialName("outcome") val outcome: Outcome,
@SerialName("profile") val profile: ReadProfile,
@SerialName("projection") val projection: RecommendationProjection,
@SerialName("shows") val shows: List<Candidate>?,
@SerialName("state") val state: State,
) {
/** Wire values for failure. */
@Serializable
enum class Failure {
@SerialName("profile_resolution") PROFILE_RESOLUTION,
@SerialName("memberships") MEMBERSHIPS,
@SerialName("history") HISTORY,
@SerialName("candidates") CANDIDATES,
@SerialName("cycle") CYCLE,
}
/** Wire values for materialization. */
@Serializable
enum class Materialization {
@SerialName("unknown") UNKNOWN,
@SerialName("pending") PENDING,
@SerialName("current") CURRENT,
@SerialName("failed") FAILED,
}
/** Wire values for outcome. */
@Serializable
enum class Outcome {
@SerialName("unknown") UNKNOWN,
@SerialName("ranked") RANKED,
@SerialName("no_seeds") NO_SEEDS,
@SerialName("empty") EMPTY,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("current") CURRENT,
@SerialName("stale") STALE,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationHideInputBody wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationHideInputBody(
@SerialName("media_type") val mediaType: MediaType,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("title") val title: String? = null,
@SerialName("tmdb_id") val tmdbId: Long,
@SerialName("year") val year: Long? = null,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationHideResult wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationHideResult(
@SerialName("created") val created: Boolean,
@SerialName("dismissal") val dismissal: RecommendationDismissal,
@SerialName("materialization") val materialization: Materialization,
@SerialName("profile") val profile: String,
) {
/** Wire values for materialization. */
@Serializable
enum class Materialization {
@SerialName("current") CURRENT,
@SerialName("failed") FAILED,
@SerialName("unchanged") UNCHANGED,
}
}

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationProfiles wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationProfiles(
@SerialName("default_profile") val defaultProfile: String,
@SerialName("profiles") val profiles: List<ReadProfile>?,
)

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationProjection wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationProjection(
@SerialName("coverage") val coverage: RecommendationCoverage,
@SerialName("facts") val facts: List<RecommendationFact>?,
@SerialName("genre_id") val genreId: Long,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("service_id") val serviceId: Long,
@SerialName("unresolved") val unresolved: List<Candidate>?,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("all") ALL,
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RecommendationRestoreResult wire contract from the pinned Conductarr schema. */
@Serializable
data class RecommendationRestoreResult(
@SerialName("media_type") val mediaType: MediaType,
@SerialName("profile") val profile: String,
@SerialName("restored") val restored: Boolean,
@SerialName("tmdb_id") val tmdbId: Long,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RequestIntentBody wire contract from the pinned Conductarr schema. */
@Serializable
data class RequestIntentBody(
@SerialName("media_type") val mediaType: MediaType,
@SerialName("recommendation_profile") val recommendationProfile: String? = null,
@SerialName("requester") val requester: String? = null,
@SerialName("seasons") val seasons: List<Long>? = null,
@SerialName("source") val source: Source? = null,
@SerialName("tmdb_id") val tmdbId: Long,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
/** Wire values for source. */
@Serializable
enum class Source {
@SerialName("title") TITLE,
@SerialName("recommendation") RECOMMENDATION,
}
}

View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RequestProgress wire contract from the pinned Conductarr schema. */
@Serializable
data class RequestProgress(
@SerialName("actor") val actor: Actor? = null,
@SerialName("awaiting_approval") val awaitingApproval: Boolean? = null,
@SerialName("blocking_stage") val blockingStage: BlockingStage? = null,
@SerialName("filed_by_you") val filedByYou: Boolean,
@SerialName("id") val id: String,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("on_behalf") val onBehalf: Boolean,
@SerialName("requested_at") val requestedAt: String? = null,
@SerialName("requester") val requester: Person,
@SerialName("seasons") val seasons: List<Long>?,
@SerialName("stages") val stages: List<ReadinessStage>?,
@SerialName("title") val title: String? = null,
@SerialName("tmdb_id") val tmdbId: Long,
) {
/** Wire values for blocking_stage. */
@Serializable
enum class BlockingStage {
@SerialName("request") REQUEST,
@SerialName("acquisition") ACQUISITION,
@SerialName("visibility") VISIBILITY,
@SerialName("playback") PLAYBACK,
}
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

View file

@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RequestReceipt wire contract from the pinned Conductarr schema. */
@Serializable
data class RequestReceipt(
@SerialName("actor") val actor: RequestReceiptActor,
@SerialName("attempts") val attempts: Long,
@SerialName("coverage") val coverage: List<RequestReceiptCoverage>?,
@SerialName("created_at") val createdAt: String,
@SerialName("detail") val detail: String? = null,
@SerialName("id") val id: Long,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("membership") val membership: RequestReceiptMembership,
@SerialName("on_behalf") val onBehalf: Boolean,
@SerialName("recommendation_profile") val recommendationProfile: String? = null,
@SerialName("requester") val requester: String,
@SerialName("requester_username") val requesterUsername: String,
@SerialName("seasons") val seasons: List<Long>?,
@SerialName("seerr") val seerr: RequestReceiptSeerr,
@SerialName("source") val source: Source,
@SerialName("state") val state: State,
@SerialName("tmdb_id") val tmdbId: Long,
@SerialName("updated_at") val updatedAt: String,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
/** Wire values for source. */
@Serializable
enum class Source {
@SerialName("title") TITLE,
@SerialName("recommendation") RECOMMENDATION,
}
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("settled") SETTLED,
@SerialName("pending") PENDING,
@SerialName("retired") RETIRED,
}
}

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RequestReceiptActor wire contract from the pinned Conductarr schema. */
@Serializable
data class RequestReceiptActor(
@SerialName("name") val name: String,
@SerialName("requester") val requester: String,
@SerialName("role") val role: Role,
) {
/** Wire values for role. */
@Serializable
enum class Role {
@SerialName("admin") ADMIN,
@SerialName("household") HOUSEHOLD,
}
}

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RequestReceiptCoverage wire contract from the pinned Conductarr schema. */
@Serializable
data class RequestReceiptCoverage(
@SerialName("outcome") val outcome: Outcome,
@SerialName("request_id") val requestId: Long? = null,
@SerialName("season") val season: Long,
) {
/** Wire values for outcome. */
@Serializable
enum class Outcome {
@SerialName("requested") REQUESTED,
@SerialName("already_requested") ALREADY_REQUESTED,
@SerialName("already_held") ALREADY_HELD,
@SerialName("unresolved") UNRESOLVED,
}
}

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RequestReceiptMembership wire contract from the pinned Conductarr schema. */
@Serializable
data class RequestReceiptMembership(
@SerialName("bucket") val bucket: String? = null,
@SerialName("dispatch_log_id") val dispatchLogId: Long? = null,
@SerialName("status") val status: Status,
) {
/** Wire values for status. */
@Serializable
enum class Status {
@SerialName("added") ADDED,
@SerialName("already_owned") ALREADY_OWNED,
@SerialName("pending_dispatch") PENDING_DISPATCH,
@SerialName("conflict") CONFLICT,
@SerialName("failed") FAILED,
}
}

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** RequestReceiptSeerr wire contract from the pinned Conductarr schema. */
@Serializable
data class RequestReceiptSeerr(
@SerialName("outcome") val outcome: Outcome,
@SerialName("request_id") val requestId: Long? = null,
) {
/** Wire values for outcome. */
@Serializable
enum class Outcome {
@SerialName("requested") REQUESTED,
@SerialName("already_requested") ALREADY_REQUESTED,
@SerialName("not_needed") NOT_NEEDED,
@SerialName("not_attempted") NOT_ATTEMPTED,
@SerialName("unresolved") UNRESOLVED,
}
}

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Season wire contract from the pinned Conductarr schema. */
@Serializable
data class Season(
@SerialName("air_date") val airDate: String? = null,
@SerialName("coverage") val coverage: Coverage,
@SerialName("episode_count") val episodeCount: Long,
@SerialName("name") val name: String,
@SerialName("number") val number: Long,
@SerialName("overview") val overview: String,
@SerialName("recorded_episodes") val recordedEpisodes: Long,
@SerialName("request_state") val requestState: RequestState,
@SerialName("seerr_state") val seerrState: SeerrState,
) {
/** Wire values for coverage. */
@Serializable
enum class Coverage {
@SerialName("none") NONE,
@SerialName("partial") PARTIAL,
@SerialName("complete") COMPLETE,
@SerialName("unknown") UNKNOWN,
}
/** Wire values for request_state. */
@Serializable
enum class RequestState {
@SerialName("none") NONE,
@SerialName("requested") REQUESTED,
@SerialName("unknown") UNKNOWN,
}
/** Wire values for seerr_state. */
@Serializable
enum class SeerrState {
@SerialName("untracked") UNTRACKED,
@SerialName("unknown") UNKNOWN,
@SerialName("pending") PENDING,
@SerialName("processing") PROCESSING,
@SerialName("partial") PARTIAL,
@SerialName("available") AVAILABLE,
@SerialName("blocklisted") BLOCKLISTED,
@SerialName("deleted") DELETED,
}
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** SeasonCoverage wire contract from the pinned Conductarr schema. */
@Serializable
data class SeasonCoverage(
@SerialName("coverage") val coverage: Coverage,
@SerialName("season") val season: Long,
) {
/** Wire values for coverage. */
@Serializable
enum class Coverage {
@SerialName("complete") COMPLETE,
@SerialName("partial") PARTIAL,
@SerialName("none") NONE,
@SerialName("unknown") UNKNOWN,
}
}

View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** SeasonDetail wire contract from the pinned Conductarr schema. */
@Serializable
data class SeasonDetail(
@SerialName("episodes") val episodes: List<Episode>?,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("metadata_state") val metadataState: MetadataState,
@SerialName("observations") val observations: List<LibraryObservation>?,
@SerialName("season") val season: Season,
@SerialName("tmdb_id") val tmdbId: Long,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("tv") TV,
}
/** Wire values for metadata_state. */
@Serializable
enum class MetadataState {
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** SeasonGroup wire contract from the pinned Conductarr schema. */
@Serializable
data class SeasonGroup(
@SerialName("episodes") val episodes: List<EpisodeRow>?,
@SerialName("season") val season: Long,
)

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** SeedContribution wire contract from the pinned Conductarr schema. */
@Serializable
data class SeedContribution(
@SerialName("added") val added: Boolean,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("title") val title: String? = null,
@SerialName("tmdb_id") val tmdbId: Long,
@SerialName("watched") val watched: Boolean,
) {
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** SeerrObservation wire contract from the pinned Conductarr schema. */
@Serializable
data class SeerrObservation(
@SerialName("detail") val detail: String? = null,
@SerialName("requests") val requests: List<SeerrRequest>?,
@SerialName("state") val state: State,
) {
/** Wire values for state. */
@Serializable
enum class State {
@SerialName("observed") OBSERVED,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** SeerrRequest wire contract from the pinned Conductarr schema. */
@Serializable
data class SeerrRequest(
@SerialName("id") val id: Long,
@SerialName("status") val status: Status,
) {
/** Wire values for status. */
@Serializable
enum class Status {
@SerialName("pending") PENDING,
@SerialName("approved") APPROVED,
@SerialName("declined") DECLINED,
@SerialName("failed") FAILED,
@SerialName("completed") COMPLETED,
@SerialName("unknown") UNKNOWN,
}
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** SourceDetail wire contract from the pinned Conductarr schema. */
@Serializable
data class SourceDetail(
@SerialName("arr") val arr: String,
@SerialName("imported") val imported: String? = null,
@SerialName("indexer") val indexer: String? = null,
)

View file

@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Title wire contract from the pinned Conductarr schema. */
@Serializable
data class Title(
@SerialName("backdrop_url") val backdropUrl: String? = null,
@SerialName("genre_ids") val genreIds: List<Long>? = null,
@SerialName("held") val held: Held,
@SerialName("libraries") val libraries: List<String>?,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("overview") val overview: String,
@SerialName("poster_url") val posterUrl: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("request_state") val requestState: RequestState,
@SerialName("seerr_state") val seerrState: SeerrState,
@SerialName("title") val title: String,
@SerialName("tmdb_id") val tmdbId: Long,
) {
/** Wire values for held. */
@Serializable
enum class Held {
@SerialName("held") HELD,
@SerialName("unheld") UNHELD,
@SerialName("unknown") UNKNOWN,
}
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
/** Wire values for request_state. */
@Serializable
enum class RequestState {
@SerialName("none") NONE,
@SerialName("requested") REQUESTED,
@SerialName("unknown") UNKNOWN,
}
/** Wire values for seerr_state. */
@Serializable
enum class SeerrState {
@SerialName("untracked") UNTRACKED,
@SerialName("unknown") UNKNOWN,
@SerialName("pending") PENDING,
@SerialName("processing") PROCESSING,
@SerialName("partial") PARTIAL,
@SerialName("available") AVAILABLE,
@SerialName("blocklisted") BLOCKLISTED,
@SerialName("deleted") DELETED,
}
}

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** TitleCast wire contract from the pinned Conductarr schema. */
@Serializable
data class TitleCast(
@SerialName("credit_context") val creditContext: CreditContext,
@SerialName("items") val items: List<CastMember>?,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("metadata_state") val metadataState: MetadataState,
@SerialName("paging") val paging: Paging,
@SerialName("source") val source: Source,
@SerialName("tmdb_id") val tmdbId: Long,
) {
/** Wire values for credit_context. */
@Serializable
enum class CreditContext {
@SerialName("movie-character") MOVIE_CHARACTER,
@SerialName("tv-leading-character") TV_LEADING_CHARACTER,
}
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
/** Wire values for metadata_state. */
@Serializable
enum class MetadataState {
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
/** Wire values for paging. */
@Serializable
enum class Paging {
@SerialName("unpaged") UNPAGED,
}
/** Wire values for source. */
@Serializable
enum class Source {
@SerialName("seerr") SEERR,
}
}

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** TitleMetadata wire contract from the pinned Conductarr schema. */
@Serializable
data class TitleMetadata(
@SerialName("checked_at") val checkedAt: String? = null,
@SerialName("genre_state") val genreState: GenreState,
@SerialName("genres") val genres: List<Genre>?,
@SerialName("genres_observed_at") val genresObservedAt: String? = null,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("provider_state") val providerState: ProviderState,
@SerialName("providers") val providers: List<Provider>?,
@SerialName("providers_observed_at") val providersObservedAt: String? = null,
@SerialName("region") val region: Region,
@SerialName("tmdb_id") val tmdbId: Long,
) {
/** Wire values for genre_state. */
@Serializable
enum class GenreState {
@SerialName("unobserved") UNOBSERVED,
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
/** Wire values for provider_state. */
@Serializable
enum class ProviderState {
@SerialName("unobserved") UNOBSERVED,
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
/** Wire values for region. */
@Serializable
enum class Region {
@SerialName("NL") NL,
}
}

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** TitleRequestsBody wire contract from the pinned Conductarr schema. */
@Serializable
data class TitleRequestsBody(
@SerialName("requests") val requests: List<RequestReceipt>?,
)

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** TitleVersion wire contract from the pinned Conductarr schema. */
@Serializable
data class TitleVersion(
@SerialName("file") val file: FileDetail? = null,
@SerialName("role") val role: Role,
@SerialName("seasons") val seasons: List<SeasonGroup>? = null,
@SerialName("source") val source: String,
) {
/** Wire values for role. */
@Serializable
enum class Role {
@SerialName("primary") PRIMARY,
@SerialName("mirror") MIRROR,
}
}

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** ViewerReadiness wire contract from the pinned Conductarr schema. */
@Serializable
data class ViewerReadiness(
@SerialName("blocking_stage") val blockingStage: BlockingStage? = null,
@SerialName("coverage") val coverage: List<SeasonCoverage>?,
@SerialName("media_type") val mediaType: MediaType,
@SerialName("open_link") val openLink: OpenLink,
@SerialName("seerr") val seerr: SeerrObservation,
@SerialName("sources") val sources: List<EvidenceSource>?,
@SerialName("stages") val stages: List<ReadinessStage>?,
@SerialName("title") val title: String? = null,
@SerialName("tmdb_id") val tmdbId: Long,
@SerialName("viewer") val viewer: Person,
) {
/** Wire values for blocking_stage. */
@Serializable
enum class BlockingStage {
@SerialName("request") REQUEST,
@SerialName("acquisition") ACQUISITION,
@SerialName("visibility") VISIBILITY,
@SerialName("playback") PLAYBACK,
}
/** Wire values for media_type. */
@Serializable
enum class MediaType {
@SerialName("movie") MOVIE,
@SerialName("tv") TV,
}
}

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

View file

@ -30,3 +30,4 @@ dependencyResolutionManagement {
rootProject.name = "Wholphin" rootProject.name = "Wholphin"
include(":app") include(":app")
include(":wholphin-mpv-stub") include(":wholphin-mpv-stub")
include(":conductarr-client")