Wire selected Jellyfin users to Conductarr TV data
This commit is contained in:
parent
f53defad90
commit
59c2728d9c
38 changed files with 1879 additions and 110 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
621
app/src/test/resources/conductarr/wire.json
Normal file
621
app/src/test/resources/conductarr/wire.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue