Scope artwork credentials and preserve library recovery

This commit is contained in:
justinzeus 2026-11-06 01:25:47 +01:00
parent 09f1d72f30
commit 34ea983a51
9 changed files with 263 additions and 11 deletions

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ConductarrAudienceRecoveryTest {
@Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
@After fun restoreMain() { Dispatchers.resetMain() }
@Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
for (empty in listOf(false, true)) FakeConductarr().use { fake ->
fake.audiencesStatus = if (empty) 200 else 503
fake.emptyAudiences = empty
ConductarrSession(MemoryCacheStore()).use { session ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
session.authenticate()
assertTrue(session.identity.value!!.signedIn)
val client = session.identity.value!!.client
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertNull(libraries.audiences)
assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES))
assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS))
assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS))
if (!empty) {
fake.audiencesStatus = 200
requireNotNull(model.onForeground(server, user)).join()
assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries)
assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS))
assertSame(client, session.identity.value!!.client)
}
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -15,7 +15,7 @@ class ConductarrProjectionTest {
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)
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)

View file

@ -22,6 +22,8 @@ internal class FakeConductarr : Closeable {
@Volatile var existingRequests = false
@Volatile var notModified = false
@Volatile var ratingsStatus = 200
@Volatile var audiencesStatus = 200
@Volatile var emptyAudiences = false
@Volatile var seasonTwoScore = 8.1
@Volatile var localArt = false
private val imageBytes = java.util.Base64.getDecoder().decode(
@ -61,9 +63,11 @@ internal class FakeConductarr : Closeable {
else -> return response("", 404)
}
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
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 == "audiences" && emptyAudiences) body = JsonObject(body + ("audiences" to JsonArray(emptyList())))
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")))
}

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.Bitmap
import androidx.activity.ComponentActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import coil3.request.SuccessResult
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.CoilConfig
import io.mockk.every
import io.mockk.mockk
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import okhttp3.OkHttpClient
import okio.Buffer
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.WolfskinImages
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The shipped loader decodes both hosts, but only Jellyfin receives the injected credential. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class WolfskinArtworkTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() {
MockWebServer().use { jellyfin ->
MockWebServer().use { artwork ->
jellyfin.start(); artwork.start()
val context = rule.activity
val credential = "MediaBrowser Client=\"Wolfskin test\", Token=\"$SHARED_TOKEN\""
val authenticated = OkHttpClient.Builder().addInterceptor { chain ->
chain.proceed(chain.request().newBuilder().addHeader("Authorization", credential).build())
}.build()
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", jellyfin.url("/").toString(), null)
val repository = mockk<ServerRepository> { every { currentServer } returns server }
val network = WolfskinArtwork(authenticated, repository)
val bytes = ByteArrayOutputStream().also {
Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, it)
}.toByteArray()
jellyfin.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
val urls = listOf(jellyfin.url("/jellyfin.png"),
artwork.url("/public.png").newBuilder().host("127.0.0.1").build(),
artwork.url("/another-port.png"))
val results = CopyOnWriteArrayList<coil3.request.ImageResult>()
try {
rule.setContent {
CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false)
LaunchedEffect(network) {
// Force the upstream authenticated parent to exist before configuring Wolfskin.
context.applicationContext.imageLoader
network.configure(context)
val loader = WolfskinImages.loader(context)
for (url in urls) results.add(loader.execute(ImageRequest.Builder(context).data(url.toString())
.memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED).build()))
}
}
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
results.size == urls.size
}
results.forEach { result -> assertEquals(2, (result as SuccessResult).image.width) }
assertEquals(credential, jellyfin.takeRequest().headers["Authorization"])
assertNull(artwork.takeRequest().headers["Authorization"])
assertNull(artwork.takeRequest().headers["Authorization"])
} finally {
WolfskinImages.loader(context).shutdown()
authenticated.dispatcher.executorService.shutdown()
authenticated.connectionPool.evictAll()
}
}
}
}
}

View file

@ -116,9 +116,13 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
profileNames.value = it.profiles.orEmpty()
}
}
suspend fun audiences(): List<LibraryAudience> {
libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty()
return libraryAudiences
/** Unavailable or empty metadata cannot replace Jellyfin's usable name-based classification. */
suspend fun audiences(): List<LibraryAudience>? {
val reads = SnapshotReads(client, ReadTrigger.IF_STALE)
val observed = reads.read(TvRoutes.audiences())?.audiences?.takeIf { it.isNotEmpty() }
if (reads.failed || observed == null) return null
libraryAudiences = observed
return observed
}
fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy

View file

@ -75,7 +75,9 @@ class WolfskinShellViewModel @Inject constructor(
val libraries: LibraryReadFacade,
val seeAll: WolfskinSeeAllLists,
val conductarr: ConductarrSession,
artwork: WolfskinArtwork,
) : ViewModel() {
init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
private val requestsSnapshot by lazy { readShellRequests(context) }
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
@ -119,18 +121,26 @@ class WolfskinShellViewModel @Inject constructor(
val profiles = identity.provider.profiles()?.profiles.orEmpty()
val audiences = identity.provider.audiences()
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
libraries.audiences = audiences.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
val updated = session.state?.people.orEmpty().map { person ->
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
val current = person.jellyfinUserId == user.id.toString()
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
hasAnime = audiences.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) })
hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime)
}
session.state?.updatePeople(updated)
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
}
/** Foreground recovery retains the current identity and any usable audience mapping. */
fun onForeground(server: JellyfinServer, user: JellyfinUser): kotlinx.coroutines.Job? {
if (ConductarrPreference.fixtures || conductarr.identity.value?.person != "${server.id}:${user.id}") return null
identityJob?.cancel()
return viewModelScope.launch { loadHousehold(user) }.also { identityJob = it }
}
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
loadHousehold(user)
@ -222,6 +232,7 @@ fun WolfskinApp(
) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
LaunchedEffect(identity?.signedIn, identity?.person) {
if (!ConductarrPreference.fixtures) {
if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())

View file

@ -0,0 +1,36 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.content.Context
import coil3.ComponentRegistry
import coil3.network.ConnectivityChecker
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.justinvisser.wolfskin.design.WolfskinImages
/** Strip credentials after upstream auth injection, including each redirect's network request. */
@OptIn(coil3.annotation.ExperimentalCoilApi::class)
@Singleton
class WolfskinArtwork @Inject constructor(
@param:AuthOkHttpClient client: OkHttpClient,
repository: ServerRepository,
) {
private val artworkClient = client.newBuilder().addNetworkInterceptor { chain ->
val request = chain.request()
val jellyfin = repository.currentServer?.url?.toHttpUrlOrNull()
val isJellyfin = jellyfin != null && request.url.scheme == jellyfin.scheme &&
request.url.host == jellyfin.host && request.url.port == jellyfin.port
chain.proceed(if (isJellyfin) request else request.newBuilder().removeHeader("Authorization").build())
}.build()
private val configure: ComponentRegistry.Builder.() -> Unit = {
add(OkHttpNetworkFetcherFactory(callFactory = { artworkClient }, connectivityChecker = { ConnectivityChecker { true } }))
}
/** Install host-scoped networking before any Wolfskin artwork is rendered. */
fun configure(context: Context) = WolfskinImages.configureNetwork(context, configure)
}