Scope artwork credentials and preserve library recovery
This commit is contained in:
parent
09f1d72f30
commit
34ea983a51
9 changed files with 263 additions and 11 deletions
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ class ConductarrProjectionTest {
|
||||||
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
|
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
|
||||||
val provider = ConductarrProvider(client)
|
val provider = ConductarrProvider(client)
|
||||||
assertEquals("Shared", provider.profiles()!!.profiles!!.first().name)
|
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()
|
val snapshot = provider.discover("shared").last()
|
||||||
assertEquals(DiscoverStatus.Ready, snapshot.status)
|
assertEquals(DiscoverStatus.Ready, snapshot.status)
|
||||||
assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster)
|
assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ internal class FakeConductarr : Closeable {
|
||||||
@Volatile var existingRequests = false
|
@Volatile var existingRequests = false
|
||||||
@Volatile var notModified = false
|
@Volatile var notModified = false
|
||||||
@Volatile var ratingsStatus = 200
|
@Volatile var ratingsStatus = 200
|
||||||
|
@Volatile var audiencesStatus = 200
|
||||||
|
@Volatile var emptyAudiences = false
|
||||||
@Volatile var seasonTwoScore = 8.1
|
@Volatile var seasonTwoScore = 8.1
|
||||||
@Volatile var localArt = false
|
@Volatile var localArt = false
|
||||||
private val imageBytes = java.util.Base64.getDecoder().decode(
|
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||||
|
|
@ -61,9 +63,11 @@ internal class FakeConductarr : Closeable {
|
||||||
else -> return response("", 404)
|
else -> return response("", 404)
|
||||||
}
|
}
|
||||||
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
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 (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||||
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||||
var body = wire.getValue(key).jsonObject
|
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) {
|
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")))
|
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -116,9 +116,13 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
||||||
profileNames.value = it.profiles.orEmpty()
|
profileNames.value = it.profiles.orEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
suspend fun audiences(): List<LibraryAudience> {
|
/** Unavailable or empty metadata cannot replace Jellyfin's usable name-based classification. */
|
||||||
libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty()
|
suspend fun audiences(): List<LibraryAudience>? {
|
||||||
return libraryAudiences
|
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
|
fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,9 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val libraries: LibraryReadFacade,
|
val libraries: LibraryReadFacade,
|
||||||
val seeAll: WolfskinSeeAllLists,
|
val seeAll: WolfskinSeeAllLists,
|
||||||
val conductarr: ConductarrSession,
|
val conductarr: ConductarrSession,
|
||||||
|
artwork: WolfskinArtwork,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
init { artwork.configure(context) }
|
||||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||||
private val requestsSnapshot by lazy { readShellRequests(context) }
|
private val requestsSnapshot by lazy { readShellRequests(context) }
|
||||||
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
|
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
|
||||||
|
|
@ -119,18 +121,26 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
val profiles = identity.provider.profiles()?.profiles.orEmpty()
|
val profiles = identity.provider.profiles()?.profiles.orEmpty()
|
||||||
val audiences = identity.provider.audiences()
|
val audiences = identity.provider.audiences()
|
||||||
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
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 updated = session.state?.people.orEmpty().map { person ->
|
||||||
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
|
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
|
||||||
val current = person.jellyfinUserId == user.id.toString()
|
val current = person.jellyfinUserId == user.id.toString()
|
||||||
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
|
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
|
||||||
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
|
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)
|
session.state?.updatePeople(updated)
|
||||||
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
|
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) {
|
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
||||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||||
loadHousehold(user)
|
loadHousehold(user)
|
||||||
|
|
@ -222,6 +232,7 @@ fun WolfskinApp(
|
||||||
) {
|
) {
|
||||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
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) {
|
LaunchedEffect(identity?.signedIn, identity?.person) {
|
||||||
if (!ConductarrPreference.fixtures) {
|
if (!ConductarrPreference.fixtures) {
|
||||||
if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
|
if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -466,6 +466,13 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin
|
||||||
logins; that does not create selectable TV logins. Late name reads preserve the
|
logins; that does not create selectable TV logins. Late name reads preserve the
|
||||||
open chooser's selected recipient and footer focus.
|
open chooser's selected recipient and footer focus.
|
||||||
|
|
||||||
|
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
|
||||||
|
authenticated fetcher. The app's network interceptor removes Authorization from
|
||||||
|
every non-Jellyfin artwork origin (scheme, host and port), including redirects;
|
||||||
|
current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps
|
||||||
|
the existing mapping or Jellyfin name rule. Returning to the foreground retries
|
||||||
|
household metadata without rebuilding the selected identity.
|
||||||
|
|
||||||
| Screen seam | Production supplier |
|
| Screen seam | Production supplier |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins |
|
| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins |
|
||||||
|
|
@ -485,6 +492,10 @@ server tests use representative wire JSON, never a live Conductarr or Jellyfin.
|
||||||
isolation, 401/retry, saved offline data, actor-safe requests, ratings timing,
|
isolation, 401/retry, saved offline data, actor-safe requests, ratings timing,
|
||||||
title playback permissions/episode votes and native real-provider surfaces.
|
title playback permissions/episode votes and native real-provider surfaces.
|
||||||
The existing fixture screen tests remain in the normal `make test` gate.
|
The existing fixture screen tests remain in the normal `make test` gate.
|
||||||
|
`WolfskinArtworkTest` exercises the actual Coil loader against two HTTP hosts
|
||||||
|
and a same-host/different-port origin with an authenticated upstream parent,
|
||||||
|
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
|
||||||
|
operator success plus failed/empty audiences, name-rule retention and foreground healing.
|
||||||
|
|
||||||
Not done here: deploying the dedicated hostname or widening server authority;
|
Not done here: deploying the dedicated hostname or widening server authority;
|
||||||
hardware installation/playback verification; releases, signing or version changes.
|
hardware installation/playback verification; releases, signing or version changes.
|
||||||
|
|
@ -499,13 +510,16 @@ Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin.
|
||||||
|
|
||||||
1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared,
|
1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared,
|
||||||
then another saved person; verify their operator/requester and independent views.
|
then another saved person; verify their operator/requester and independent views.
|
||||||
|
Its hostname must be in Conductarr's TV allowed-host list; arbitrary hosts or
|
||||||
|
IP addresses are refused even with the correct token.
|
||||||
2. Open Home, Discover, a service, Hidden picks and Requests. Open a title, cast
|
2. Open Home, Discover, a service, Hidden picks and Requests. Open a title, cast
|
||||||
member and filmography credit; check real facts, related titles, episode votes,
|
member and filmography credit; check real facts, related titles, episode votes,
|
||||||
stage/actor labels, card dwell ratings and continuous See all loading.
|
stage/actor labels, card dwell ratings and continuous See all loading.
|
||||||
3. Make the sandbox return 503 after a successful read. Reopen: saved content and
|
3. Make the sandbox return 503 after a successful read. Reopen: saved content and
|
||||||
Saved time remain, requests cannot submit, and Try again performs another read.
|
Saved time remain, requests cannot submit, and Try again performs another read.
|
||||||
4. Return 401, then restore authentication. Verify ratings/request actions disappear;
|
4. Return 401, then restore authentication. Verify ratings/request actions disappear
|
||||||
Try again restores data without changing Jellyfin login or copying a token.
|
and this person's saved Conductarr data is cleared. Try again restores data
|
||||||
|
without changing Jellyfin login or copying a token.
|
||||||
5. Have the sandbox accept a Shared-on-behalf request but lose its POST response.
|
5. Have the sandbox accept a Shared-on-behalf request but lose its POST response.
|
||||||
Use Check again: the receipt says Shared, and the sandbox records one POST.
|
Use Check again: the receipt says Shared, and the sandbox records one POST.
|
||||||
6. Restore the blank/default URL when the dedicated TV hostname and server's
|
6. Restore the blank/default URL when the dedicated TV hostname and server's
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
package org.justinvisser.wolfskin.design
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import coil3.ComponentRegistry
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.disk.DiskCache
|
import coil3.disk.DiskCache
|
||||||
import coil3.intercept.Interceptor
|
import coil3.intercept.Interceptor
|
||||||
import coil3.disk.directory
|
import coil3.disk.directory
|
||||||
import coil3.imageLoader
|
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
|
|
@ -18,21 +18,43 @@ import kotlinx.coroutines.sync.withPermit
|
||||||
object WolfskinImages {
|
object WolfskinImages {
|
||||||
private var application: Context? = null
|
private var application: Context? = null
|
||||||
private var loader: ImageLoader? = null
|
private var loader: ImageLoader? = null
|
||||||
|
private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null
|
||||||
private const val MEBIBYTE = 1024L * 1024L
|
private const val MEBIBYTE = 1024L * 1024L
|
||||||
private const val IMAGE_READS = 4
|
private const val IMAGE_READS = 4
|
||||||
private val readPermits = Semaphore(IMAGE_READS)
|
private val readPermits = Semaphore(IMAGE_READS)
|
||||||
|
|
||||||
|
/** The app supplies authenticated networking without exposing its server/session to design code. */
|
||||||
|
@Synchronized
|
||||||
|
fun configureNetwork(context: Context, configure: ComponentRegistry.Builder.() -> Unit) {
|
||||||
|
val owner = context.applicationContext
|
||||||
|
if (application === owner && networkConfiguration === configure) return
|
||||||
|
loader?.shutdown()
|
||||||
|
loader = null
|
||||||
|
application = owner
|
||||||
|
networkConfiguration = configure
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun loader(context: Context): ImageLoader {
|
fun loader(context: Context): ImageLoader {
|
||||||
val owner = context.applicationContext
|
val owner = context.applicationContext
|
||||||
if (application !== owner) {
|
if (application !== owner) {
|
||||||
loader?.shutdown()
|
loader?.shutdown()
|
||||||
|
loader = null
|
||||||
application = owner
|
application = owner
|
||||||
|
networkConfiguration = null
|
||||||
|
}
|
||||||
|
if (loader == null) {
|
||||||
val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS)
|
val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS)
|
||||||
loader = owner.imageLoader.newBuilder()
|
// Parent components may attach a Jellyfin token to every HTTP host.
|
||||||
|
val builder = ImageLoader.Builder(owner)
|
||||||
|
loader = builder
|
||||||
.memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() }
|
.memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() }
|
||||||
.diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() }
|
.diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() }
|
||||||
.components { add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) }
|
.components {
|
||||||
|
// Coil replaces this registry; install networking and bounds together.
|
||||||
|
networkConfiguration?.invoke(this)
|
||||||
|
add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } })
|
||||||
|
}
|
||||||
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
|
.fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build()
|
||||||
}
|
}
|
||||||
return loader!!
|
return loader!!
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue