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 -> 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)

View file

@ -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")))
} }

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() 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

View file

@ -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())

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)
}

View file

@ -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

View file

@ -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!!