Wire selected Jellyfin users to Conductarr TV data

This commit is contained in:
justinzeus 2026-11-05 22:55:54 +01:00
parent f53defad90
commit 59c2728d9c
38 changed files with 1879 additions and 110 deletions

View file

@ -0,0 +1,108 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.design.*
/** Consumer-visible mappings exercise realistic wire responses over a local HTTP server. */
class ConductarrProjectionTest {
@Test fun householdFeedAndCatalogueBecomePersonalRowsWithoutRawLibraryNamesOrHomeComingSoon() = runBlocking {
FakeConductarr().use { fake ->
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
val provider = ConductarrProvider(client)
assertEquals("Shared", provider.profiles()!!.profiles!!.first().name)
assertEquals(listOf("Shared Anime"), provider.audiences().single().libraries)
val snapshot = provider.discover("shared").last()
assertEquals(DiscoverStatus.Ready, snapshot.status)
assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster)
assertEquals("Solaris", snapshot.seeds.single().title)
assertEquals(listOf(42L), snapshot.seeds.single().items.map { it.tmdb })
assertEquals(listOf("New on Netflix", "Trending"), snapshot.lanes.take(2).map { it.title })
assertTrue(snapshot.lanes.first { it.id == "genre-Comedy" }.pending)
assertEquals(99L, snapshot.hidden.single().tmdb)
assertEquals(8L, snapshot.services.single().id)
val rows = snapshot.homeRows()
assertEquals("Moon", rows.first { it.id == "pk-movie" }.cards.single().title)
assertFalse(rows.any { "Coming soon" in it.title || "Shared Anime" in it.title })
assertTrue(fake.calls.filter { it.url.encodedPath == "/catalogue/discover" }.all { it.url.queryParameter("genre_id") == null })
val expanded = provider.lanesThrough(99)
assertFalse(expanded.any { it.pending })
assertTrue(fake.calls.any { it.url.queryParameter("genre_id") == "35" })
assertEquals("Coming soon", expanded.last().title)
val service = provider.service(snapshot.services.single(), "tv")
assertEquals(listOf("Popular series", "New series", "Top rated series"), service.take(3).map { it.title })
assertTrue(fake.calls.filter { it.url.queryParameter("service_id") == "8" }.any { it.url.queryParameter("media_type") == "tv" })
val requests = provider.requests().last()
val request = requests.items.single()
assertEquals(ComingInStage.Downloading, request.stage)
assertEquals("Mindel", request.requesterName)
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
assertEquals(1, requests.openCount)
}
}
}
@Test fun uncertainRequestIsLookedUpRatherThanSentTwiceAndSharedRemainsTheAuthenticatedActor() = runBlocking {
FakeConductarr().use { fake ->
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
val operations = ConductarrDiscoverOperations(client, { DiscoverSnapshot() }, profile = "shared", allowedRequesters = setOf("shared", "mindel"))
val intent = DiscoverRequest(DiscoverTitle(42, "movie", "Moon"), "mindel", "untrusted-ui-profile")
fake.postStatus = 503
assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase)
assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase)
assertEquals(1, fake.calls.count { it.method == "POST" })
val post = fake.calls.single { it.method == "POST" }
val body = post.body!!.utf8()
assertTrue(body.contains("\"requester\":\"mindel\""))
assertTrue(body.contains("\"recommendation_profile\":\"shared\""))
assertFalse(body.contains("actor"))
assertEquals(listOf("GET", "POST", "GET"), fake.calls.map { it.method })
fake.existingRequests = true
val receipt = operations.check(intent)
assertEquals(RequestPhase.Receipt, receipt.phase)
assertEquals("Shared", receipt.actor)
assertEquals("mindel", receipt.requester)
assertEquals(RequestPhase.Refused, operations.request(intent.copy(requester = "outsider")).phase)
assertEquals(RequestPhase.Refused, operations.request(intent.copy(title = DiscoverTitle(44, "tv", "Dark"))).phase)
assertEquals(1, fake.calls.count { it.method == "POST" })
assertTrue(operations.hide("untrusted-ui-profile", intent.title))
assertTrue(operations.restore("untrusted-ui-profile", intent.title))
assertTrue(fake.calls.takeLast(2).all { "/profiles/shared/dismissals" in it.url.encodedPath })
}
}
}
@Test fun episodeVotesReplaceSeriesScoreAndConditionalTitleReadsKeepTheOriginalBody() = runBlocking {
FakeConductarr().use { fake ->
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
val ratings = ConductarrRatings(client)
assertEquals(listOf("rtCritics", "rtAudience", "imdb"), ratings.read(42, "movie", null, null)!!.map { it.source })
val episode = ratings.read(44, "episode", 1, 1)!!
assertEquals(listOf("rtCritics", "rtAudience", "episode"), episode.map { it.source })
assertEquals(8.1, episode.last().score, 0.0)
ratings.read(44, "episode", 1, 2)
assertEquals(1, fake.calls.count { it.url.encodedPath.contains("/seasons/") })
assertFalse(fake.calls.any { it.url.encodedPath.endsWith("/enrichment") })
val provider: ConductarrTitleProvider = ConductarrProvider(client)
val title = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved
fake.notModified = true
val revalidated = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved
assertEquals(title.value, revalidated.value)
assertEquals(title.savedAtMillis, revalidated.savedAtMillis)
assertEquals("\"wire-v1\"", fake.calls.last().headers["If-None-Match"])
fake.notModified = false
assertEquals("Sam Rockwell", (provider.cast(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.items!!.single().name)
assertEquals(28L, (provider.season(44, 1).last() as ReadResult.Value).saved.value!!.episodes!!.single().voteCount)
assertEquals("Sam Rockwell", (provider.performer(216).last() as ReadResult.Value).saved.value!!.name)
assertEquals("Sam Bell", (provider.filmography(216).last() as ReadResult.Value).saved.value!!.items!!.single().credits!!.single().character)
assertEquals("Arrival", (provider.related(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.items!!.single().title)
assertEquals("Moon", (provider.display(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.title)
assertEquals("mindel", (provider.activity(42, MediaType.MOVIE, "mindel").last() as ReadResult.Value).saved.value!!.viewer.key)
}
}
}
}

View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import java.nio.file.Files
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.design.*
/** Real sockets and reopened disk stores prove identity and authentication boundaries. */
class ConductarrSessionTest {
@Test fun switchingPersonRebuildsIdentityAndNeverServesTheOtherPersonsDiskCopy() = runBlocking {
val directory = Files.createTempDirectory("tv-session-").toFile()
try {
FakeConductarr().use { fake ->
ConductarrSession(FileCacheStore(directory)).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
session.authenticate()
val first = session.identity.value!!
assertEquals("shared", first.operator!!.requester)
assertEquals("Moon", first.provider.discover("shared").last().picks.single().title)
session.select("server:mindel", MINDEL_TOKEN, fake.url)
val second = session.identity.value!!
assertNotSame(first.client, second.client)
assertFalse(second.signedIn)
assertTrue(second.client.read(TvRoutes.feed("shared"), ReadTrigger.CACHE_ONLY).toList().isEmpty())
session.authenticate()
assertEquals("Mindel", session.identity.value!!.operator!!.name)
val mindelCall = fake.calls.last { it.url.encodedPath == "/api/v1/operator" }
assertEquals("MediaBrowser Token=\"$MINDEL_TOKEN\"", mindelCall.headers["Authorization"])
session.select("server:other", SHARED_TOKEN, fake.url)
assertTrue(session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).toList().isEmpty())
session.select("server:shared", SHARED_TOKEN, fake.url)
assertEquals("shared", (session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).last() as ReadResult.Value).saved.value!!.requester)
session.select("server:shared", null, fake.url)
assertNull(session.identity.value)
}
fake.calls.forEach { request ->
assertTrue(request.headers["Authorization"] in setOf("MediaBrowser Token=\"$SHARED_TOKEN\"", "MediaBrowser Token=\"$MINDEL_TOKEN\""))
listOf("Cookie", "X-Emby-Token", "X-MediaBrowser-Token", "X-Api-Key").forEach { assertNull(request.headers[it]) }
assertFalse(request.url.toString().contains(SHARED_TOKEN))
}
}
assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(SHARED_TOKEN) || it.readText().contains(MINDEL_TOKEN) })
} finally { directory.deleteRecursively() }
}
@Test fun offlineReopenPaintsSavedDataBut401RemovesRatingsAndRequestAuthority() = runBlocking {
val directory = Files.createTempDirectory("tv-offline-").toFile()
try {
FakeConductarr().use { fake ->
ConductarrSession(FileCacheStore(directory)).use { original ->
original.select("server:shared", SHARED_TOKEN, fake.url)
original.authenticate()
original.identity.value!!.provider.discover("shared").toList()
}
ConductarrSession(FileCacheStore(directory)).use { reopened ->
reopened.select("server:shared", SHARED_TOKEN, fake.url)
fake.status = 503
reopened.authenticate()
assertTrue(reopened.identity.value!!.signedIn)
val snapshots = reopened.identity.value!!.provider.discover("shared", force = true).toList()
assertEquals("Moon", snapshots.first().picks.single().title)
assertEquals("Moon", snapshots.last().picks.single().title)
assertNotNull(snapshots.last().savedAt)
fake.status = 200
fake.ratingsStatus = 401
val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null)
assertNull(ratings)
assertFalse(reopened.identity.value!!.signedIn)
assertNull(reopened.identity.value!!.operator)
assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error)
assertEquals(DiscoverStatus.NeedsSignIn, reopened.identity.value!!.provider.discover("shared").last().status)
fake.ratingsStatus = 200
reopened.select("server:shared", SHARED_TOKEN, fake.url, retry = true)
reopened.authenticate()
assertTrue(reopened.identity.value!!.signedIn)
}
}
} finally { directory.deleteRecursively() }
}
}

View file

@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The shipped host reads a local server and draws real data, dwell ratings and 401 recovery. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ConductarrSurfaceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun realHttpProviderDrawsCatalogueAnd401ReturnsToSignInWithoutRequestActions() {
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply {
pickerVisible = false
enterDestination("discover")
}
lateinit var current: DiscoverState
val art = ImageBitmap(1, 1)
rule.setContent {
val identity by session.identity.collectAsState()
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, identity = identity, fixtures = false)
current = state
CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { ConductarrRatings(it.client) }) {
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
}
}
rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.Ready }
rule.onNodeWithText("Moon").assertIsDisplayed()
rule.mainClock.autoAdvance = false
rule.runOnIdle {
val lane = current.lanes.indexOfFirst { it.titles.any { title -> title.tmdb == 43L } }
current.focus(current.firstShelf + lane, 0)
}
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
rule.mainClock.advanceTimeBy(100)
org.junit.Assert.assertFalse(fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" })
rule.mainClock.advanceTimeBy(500)
rule.waitUntil(10_000) {
rule.mainClock.advanceTimeByFrame()
fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }
}
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "conductarr-real-provider.png").absolutePath)
fake.ratingsStatus = 401
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
rule.mainClock.autoAdvance = true
rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.NeedsSignIn }
rule.onNodeWithText("Discover needs sign-in").assertIsDisplayed()
org.junit.Assert.assertFalse(current.canRequest)
rule.onRoot().captureRoboImage(File(directory, "conductarr-401.png").absolutePath)
}
}
}
}

View file

@ -0,0 +1,78 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import java.io.Closeable
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.serialization.json.*
import mockwebserver3.Dispatcher
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import mockwebserver3.RecordedRequest
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
/** A loopback server supplies wire JSON, never app projections or live service state. */
internal class FakeConductarr : Closeable {
val server = MockWebServer()
val calls = CopyOnWriteArrayList<RecordedRequest>()
val wire = Json.parseToJsonElement(javaClass.getResourceAsStream("/conductarr/wire.json")!!.bufferedReader().use { it.readText() }).jsonObject
@Volatile var status = 200
@Volatile var postStatus = 200
@Volatile var existingRequests = false
@Volatile var notModified = false
@Volatile var ratingsStatus = 200
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
calls.add(request)
if (status != 200) return response("", status)
val path = request.url.encodedPath
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
if (request.method == "POST") return response(wire.getValue("hide").toString())
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
val key = when {
path == "/api/v1/operator" -> "operator"
path == "/api/v1/recommendations/profiles" -> "profiles"
path == "/curation/library-audiences" -> "audiences"
path.endsWith("/feed") -> "feed"
path.endsWith("/dismissals") -> "dismissals"
path == "/catalogue/options" -> "options"
path == "/catalogue/discover" -> "discover"
path.endsWith("/ratings") -> "ratings"
path.endsWith("/cast") -> "cast"
path.endsWith("/related") -> "discover"
path.endsWith("/enrichment") -> "enrichment"
path.endsWith("/display") -> "display"
path.contains("/seasons/") -> "season"
path.endsWith("/filmography") -> "filmography"
path.startsWith("/catalogue/performers/") -> "performer"
path.startsWith("/catalogue/titles/") -> "detail"
path.startsWith("/api/v1/activity/titles/") -> "readiness"
path.startsWith("/api/v1/requests/titles/") -> "requests"
path == "/api/v1/activity" -> "activity"
else -> return response("", 404)
}
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
var body = wire.getValue(key).jsonObject
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
}
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
body = JsonObject(body + ("media_type" to JsonPrimitive("tv")))
if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark")))))))
}
if (key == "discover" && request.url.queryParameter("page") == "2") body = JsonObject(body + ("page" to JsonPrimitive(2)))
return response(body.toString())
}
}
server.start()
}
val url: String get() = server.url("/").toString()
private fun response(body: String, code: Int = 200) = MockResponse.Builder().code(code).body(body).addHeader("ETag", "\"wire-v1\"").build()
override fun close() = server.close()
}

View file

@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
}
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists)
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val facade = mockk<LibraryReadFacade>()
rule.setContent {
val destination = navigation.backStack.last()

View file

@ -112,6 +112,7 @@ class UpstreamContentFocusTest {
val state = shellState()
val model = mockk<WolfskinShellViewModel>(relaxed = true)
every { model.session } returns WolfskinSession().also { it.state = state }
every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
every { model.libraryDestinations } returns emptyMap()
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
every { model.signedInPerson } returns "unavailable"

View file

@ -0,0 +1,621 @@
{
"operator": {
"capabilities": {
"administer": false,
"manage_library": false,
"manage_memberships": false,
"recommendation_profiles": [
"shared"
],
"request_media": true,
"requesters": [
"shared",
"mindel"
],
"watch_users": [
"shared"
]
},
"name": "Shared",
"recommendation_profile": "shared",
"requester": "shared",
"role": "household",
"subject": "jellyfin:shared",
"watch_user": "shared"
},
"profiles": {
"profiles": [
{
"key": "shared",
"name": "Shared"
},
{
"key": "mindel",
"name": "Mindel"
}
],
"default_profile": "shared"
},
"audiences": {
"audiences": [
{
"is_anime": true,
"libraries": [
"Shared Anime"
],
"media_type": "tv",
"owner": "shared",
"users": [
"shared",
"mindel"
]
}
]
},
"feed": {
"profile": {
"key": "shared",
"name": "Shared"
},
"projection": {
"coverage": {
"complete": true,
"excluded": 0,
"matched": 1,
"total": 1,
"unresolved": 0
},
"facts": [],
"genre_id": 0,
"media_type": "all",
"service_id": 0,
"unresolved": []
},
"state": "current",
"outcome": "ranked",
"materialization": "current",
"movies": [
{
"backdrop_path": "/moon-wide.jpg",
"contributions": [
{
"added": false,
"media_type": "movie",
"title": "Solaris",
"tmdb_id": 33,
"watched": true
}
],
"genre_ids": [
35
],
"media_type": "movie",
"overview": "An astronaut reaches the end of his assignment.",
"poster_path": "/moon.jpg",
"score": 4.2,
"title": "Moon",
"tmdb_id": 42,
"tvdb_id": 0,
"year": 2009
}
],
"shows": [],
"expires_at": "2099-11-05T12:00:00Z"
},
"options": {
"genres": [
{
"id": 35,
"name": "Comedy"
},
{
"id": 16,
"name": "Animation"
}
],
"media_type": "movie",
"observations": [],
"region": "NL",
"services": [
{
"id": 8,
"name": "Netflix"
}
],
"state": "observed"
},
"discover": {
"items": [
{
"tmdb_id": 43,
"media_type": "movie",
"title": "Arrival",
"overview": "An astronaut reaches the end of his assignment.",
"poster_url": "https://images.example.invalid/moon.jpg",
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
"release_date": "2009-06-12",
"held": "unheld",
"libraries": [],
"request_state": "none",
"seerr_state": "untracked",
"genre_ids": [
35
]
}
],
"media_type": "movie",
"observations": [],
"page": 1,
"query": "",
"source": "general",
"state": "observed",
"total_pages": 2,
"total_results": 24
},
"dismissals": {
"profile": "shared",
"dismissals": [
{
"hidden_at": "2026-11-05T12:00:00Z",
"hidden_by": "shared",
"media_type": "movie",
"poster_path": "/hidden.jpg",
"title": "Hidden film",
"tmdb_id": 99,
"year": 2017
}
]
},
"ratings": {
"ratings": [
{
"source": "rt_critics",
"state": "observed",
"score": 90,
"status": "Certified Fresh"
},
{
"source": "rt_audience",
"state": "observed",
"score": 89,
"status": "Upright"
},
{
"source": "imdb",
"state": "observed",
"score": 7.8,
"votes": 300000
},
{
"source": "tmdb",
"state": "observed",
"score": 7.6,
"votes": 4000
}
]
},
"detail": {
"genres": [
"Comedy"
],
"metadata": {
"genre_state": "observed",
"genres": [
{
"id": 35,
"name": "Comedy"
}
],
"media_type": "movie",
"provider_state": "observed",
"providers": [],
"region": "NL",
"tmdb_id": 42
},
"metadata_state": "observed",
"observations": [],
"ratings": [
{
"source": "rt_critics",
"state": "observed",
"score": 90,
"status": "Certified Fresh"
},
{
"source": "rt_audience",
"state": "observed",
"score": 89,
"status": "Upright"
},
{
"source": "imdb",
"state": "observed",
"score": 7.8,
"votes": 300000
},
{
"source": "tmdb",
"state": "observed",
"score": 7.6,
"votes": 4000
}
],
"runtime": 97,
"seasons": [
{
"number": 1,
"name": "Season 1",
"overview": "A missing child.",
"episode_count": 10,
"recorded_episodes": 0,
"coverage": "none",
"request_state": "none",
"seerr_state": "untracked"
}
],
"state": "observed",
"title": {
"tmdb_id": 42,
"media_type": "movie",
"title": "Moon",
"overview": "An astronaut reaches the end of his assignment.",
"poster_url": "https://images.example.invalid/moon.jpg",
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
"release_date": "2009-06-12",
"held": "unheld",
"libraries": [],
"request_state": "none",
"seerr_state": "untracked",
"genre_ids": [
35
]
}
},
"enrichment": {
"genres": [
"Comedy"
],
"metadata": {
"genre_state": "observed",
"genres": [
{
"id": 35,
"name": "Comedy"
}
],
"media_type": "movie",
"provider_state": "observed",
"providers": [],
"region": "NL",
"tmdb_id": 42
},
"metadata_state": "observed",
"observations": [],
"ratings": [
{
"source": "rt_critics",
"state": "observed",
"score": 90,
"status": "Certified Fresh"
},
{
"source": "rt_audience",
"state": "observed",
"score": 89,
"status": "Upright"
},
{
"source": "imdb",
"state": "observed",
"score": 7.8,
"votes": 300000
},
{
"source": "tmdb",
"state": "observed",
"score": 7.6,
"votes": 4000
}
],
"runtime": 97,
"seasons": [
{
"number": 1,
"name": "Season 1",
"overview": "A missing child.",
"episode_count": 10,
"recorded_episodes": 0,
"coverage": "none",
"request_state": "none",
"seerr_state": "untracked"
}
],
"state": "observed",
"title": {
"tmdb_id": 42,
"media_type": "movie",
"title": "Moon",
"overview": "An astronaut reaches the end of his assignment.",
"poster_url": "https://images.example.invalid/moon.jpg",
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
"release_date": "2009-06-12",
"held": "unheld",
"libraries": [],
"request_state": "none",
"seerr_state": "untracked",
"genre_ids": [
35
]
}
},
"display": {
"media_type": "movie",
"metadata_state": "observed",
"poster_url": "https://images.example.invalid/moon.jpg",
"title": "Moon",
"tmdb_id": 42
},
"cast": {
"tmdb_id": 42,
"media_type": "movie",
"metadata_state": "observed",
"source": "seerr",
"paging": "unpaged",
"credit_context": "movie-character",
"items": [
{
"performer_id": 216,
"name": "Sam Rockwell",
"character": "Sam Bell",
"credit_id": "cast-216",
"order": 0,
"profile_url": "https://images.example.invalid/sam.jpg"
}
]
},
"season": {
"tmdb_id": 44,
"media_type": "tv",
"metadata_state": "observed",
"season": {
"number": 1,
"name": "Season 1",
"overview": "A missing child.",
"episode_count": 10,
"recorded_episodes": 0,
"coverage": "none",
"request_state": "none",
"seerr_state": "untracked"
},
"episodes": [
{
"number": 1,
"name": "Secrets",
"overview": "A child disappears.",
"recorded": "missing",
"vote_average": 8.1,
"vote_count": 28
}
],
"observations": []
},
"performer": {
"performer_id": 216,
"metadata_state": "observed",
"source": "seerr",
"name": "Sam Rockwell",
"biography": "Actor.",
"also_known_as": []
},
"filmography": {
"performer_id": 216,
"metadata_state": "observed",
"source": "seerr",
"source_paging": "unpaged",
"completeness": "returned-non-adult-cast",
"page": 1,
"page_size": 20,
"items": [
{
"title": {
"tmdb_id": 42,
"media_type": "movie",
"title": "Moon",
"overview": "An astronaut reaches the end of his assignment.",
"poster_url": "https://images.example.invalid/moon.jpg",
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
"release_date": "2009-06-12",
"held": "unheld",
"libraries": [],
"request_state": "none",
"seerr_state": "untracked",
"genre_ids": [
35
]
},
"credits": [
{
"credit_id": "cast-216",
"character": "Sam Bell"
}
],
"visibility_state": "unknown",
"users": []
}
],
"observations": []
},
"activity": {
"items": [
{
"actor": {
"name": "Shared",
"requester": "shared",
"role": "household"
},
"filed_by_you": true,
"id": "request:7",
"media_type": "movie",
"on_behalf": true,
"requester": {
"key": "mindel",
"name": "Mindel"
},
"seasons": [],
"stages": [
{
"name": "request",
"source": "conductarr",
"state": "done",
"summary": "Request accepted"
},
{
"name": "acquisition",
"source": "sonarr",
"state": "in_progress",
"summary": "Downloading"
},
{
"name": "visibility",
"source": "jellyfin",
"state": "none",
"summary": "Not yet"
},
{
"name": "playback",
"source": "jellyfin",
"state": "none",
"summary": "Not checked"
}
],
"title": "Moon",
"tmdb_id": 42,
"blocking_stage": "acquisition"
}
],
"requester": "all",
"sources": [],
"truncated": false
},
"readiness": {
"tmdb_id": 42,
"media_type": "movie",
"viewer": {
"key": "mindel",
"name": "Mindel"
},
"stages": [
{
"name": "request",
"source": "conductarr",
"state": "done",
"summary": "Request accepted"
},
{
"name": "acquisition",
"source": "sonarr",
"state": "in_progress",
"summary": "Downloading"
},
{
"name": "visibility",
"source": "jellyfin",
"state": "none",
"summary": "Not yet"
},
{
"name": "playback",
"source": "jellyfin",
"state": "none",
"summary": "Not checked"
}
],
"seerr": {
"state": "observed",
"requests": []
},
"coverage": [],
"open_link": {
"state": "missing"
},
"sources": []
},
"requests": {
"requests": [
{
"actor": {
"name": "Shared",
"requester": "shared",
"role": "household"
},
"attempts": 1,
"coverage": [],
"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": "recommendation",
"state": "settled",
"tmdb_id": 42,
"updated_at": "2026-11-05T12:00:00Z"
}
]
},
"receipt": {
"actor": {
"name": "Shared",
"requester": "shared",
"role": "household"
},
"attempts": 1,
"coverage": [],
"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": "recommendation",
"state": "settled",
"tmdb_id": 42,
"updated_at": "2026-11-05T12:00:00Z"
},
"hide": {
"created": true,
"dismissal": {
"hidden_at": "2026-11-05T12:00:00Z",
"hidden_by": "shared",
"media_type": "movie",
"poster_path": "/hidden.jpg",
"title": "Moon",
"tmdb_id": 42,
"year": 2017
},
"materialization": "current",
"profile": "shared"
},
"restore": {
"media_type": "movie",
"profile": "shared",
"restored": true,
"tmdb_id": 42
}
}