Add the pinned Conductarr TV client and disk cache
All checks were successful
review/code-claude Claude cross-family review: clean, no blocking findings
All checks were successful
review/code-claude Claude cross-family review: clean, no blocking findings
This commit is contained in:
parent
de452448ba
commit
adbfe5d0e7
87 changed files with 21924 additions and 0 deletions
|
|
@ -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
1
conductarr-client/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build/
|
||||||
64
conductarr-client/README.md
Normal file
64
conductarr-client/README.md
Normal 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.
|
||||||
36
conductarr-client/build.gradle.kts
Normal file
36
conductarr-client/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
1
conductarr-client/openapi/commit.sha
Normal file
1
conductarr-client/openapi/commit.sha
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
481492d3a8a0651a89901de548cd511218c0562e
|
||||||
15923
conductarr-client/openapi/openapi.json
Normal file
15923
conductarr-client/openapi/openapi.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 } }
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
@ -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>?,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>?,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>?,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>?,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>?,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
3173
conductarr-client/src/test/resources/model-contracts.json
Normal file
3173
conductarr-client/src/test/resources/model-contracts.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue