Keep Home responsive and resolve authorized request recipients
Some checks failed
review/code-claude Block: token sent to image hosts via Coil auth client; failed audiences read hides Jellyfin libraries

This commit is contained in:
justinzeus 2026-11-06 00:31:48 +01:00
parent 78322f588a
commit 09f1d72f30
13 changed files with 120 additions and 29 deletions

View file

@ -9,6 +9,9 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -21,7 +24,6 @@ import java.io.File
import java.util.UUID
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
@ -50,7 +52,8 @@ class ConductarrHomeSurfaceTest {
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val home = mockk<HomeReadFacade>()
coEvery { home.read(user.id) } returns HomeLibrary()
coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf(
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
@ -59,15 +62,18 @@ class ConductarrHomeSurfaceTest {
rule.setContent { WolfskinTheme {
LaunchedEffect(model) { model.loadLibrary(server, user, "shared") }
val snapshot = model.snapshot
LaunchedEffect(snapshot.rows) {
val index = snapshot.rows.indexOfFirst { it.catalogue && it.cards.any { card -> card.title == "Moon" } }
if (index >= 0) { shell.view.row = index; shell.view.home.focusedKey = snapshot.rows[index].id; shell.navFocused = false }
}
CompositionLocalProvider(LocalScreenRatings provides ConductarrRatings(session.identity.value!!.client)) {
CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) {
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
}
} }
rule.waitUntil(10_000) { model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } }
rule.waitUntil(timeoutMillis = 30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } }
}
rule.runOnIdle { shell.navFocused = false; shell.view.row = 0; shell.view.home.focusedKey = "hero" }
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
rule.mainClock.advanceTimeBy(500)
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
rule.onAllNodesWithText("Moon")[0].assertIsDisplayed()
assertTrue(model.snapshot.rows.filter { it.catalogue }.flatMap { it.cards }.none { it.held })
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }

View file

@ -69,7 +69,7 @@ class ConductarrSessionTest {
fake.status = 200
fake.ratingsStatus = 401
val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null)
assertNull(ratings)
assertTrue(ratings!!.isEmpty())
assertFalse(reopened.identity.value!!.signedIn)
assertNull(reopened.identity.value!!.operator)
assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error)

View file

@ -45,11 +45,14 @@ class ConductarrSurfaceTest {
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) }) {
CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { selected -> androidx.compose.runtime.remember(selected.client) { ConductarrRatings(selected.client) } }) {
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
}
}
rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.Ready }
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
current.snapshot.status == DiscoverStatus.Ready
}
rule.onNodeWithText("Moon").assertIsDisplayed()
rule.mainClock.autoAdvance = false
rule.runOnIdle {

View file

@ -52,7 +52,7 @@ class ConductarrTitleSurfaceTest {
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true))
try {
val route = titleRoute("movie:42")
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply {
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination(route)
}
val art = mutableMapOf<String, ImageBitmap>()
@ -67,7 +67,10 @@ class ConductarrTitleSurfaceTest {
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) {
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel, fixtures = false)
} } }
rule.waitUntil(10_000) { viewModel.title?.data?.connection == TitleConnection.SignedIn }
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
viewModel.title?.data?.connection == TitleConnection.SignedIn && session.identity.value!!.provider.requesterProfiles.value.any { it.key == "mindel" }
}
rule.onNodeWithText("Moon").assertIsDisplayed()
assertFalse(viewModel.title!!.options.any { it.id == "play" })
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
@ -75,7 +78,10 @@ class ConductarrTitleSurfaceTest {
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
rule.waitUntil(10_000) { viewModel.title?.data?.requestActor == "Shared" }
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
viewModel.title?.data?.requestActor == "Shared"
}
assertEquals("Mindel", viewModel.title!!.data.requestFor)
rule.onAllNodesWithText("for Mindel · by Shared")[0].assertIsDisplayed()
val post = fake.calls.single { it.method == "POST" && it.url.encodedPath == "/api/v1/requests" }
@ -86,7 +92,10 @@ class ConductarrTitleSurfaceTest {
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
fake.ratingsStatus = 401
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
rule.waitUntil(10_000) { viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable }
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
}
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") })
} finally { viewModel.viewModelScope.cancel() }

View file

@ -75,7 +75,8 @@ internal class FakeConductarr : Closeable {
if (key == "season" && path.endsWith("/2")) body = JsonObject(body + ("episodes" to JsonArray(body.getValue("episodes").jsonArray.map {
JsonObject(it.jsonObject + ("vote_average" to JsonPrimitive(seasonTwoScore)))
})))
return response(if (localArt) localizeImages(body).toString() else body.toString())
val responseBody = if (localArt) localizeImages(body).toString() else body.toString()
return response(responseBody)
}
}
server.start()

View file

@ -9,6 +9,10 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.conductarr.model.*
import org.justinvisser.wolfskin.design.*
@ -21,10 +25,15 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
private val services = mutableMapOf<String, ConductarrCatalogue>()
@Volatile private var libraryAudiences = emptyList<LibraryAudience>()
private var requestOperations: ConductarrDiscoverOperations? = null
private val profilesMutex = Mutex()
private var observedProfiles: RecommendationProfiles? = null
private val profileNames = MutableStateFlow<List<ReadProfile>>(emptyList())
val requesterProfiles = profileNames.asStateFlow()
/** One uncertain-submission ledger survives screen/navigation changes for this identity. */
fun operations(operator: Operator): ConductarrDiscoverOperations = synchronized(this) {
requestOperations ?: ConductarrDiscoverOperations(client, {
profiles()
discover(operator.recommendationProfile, force = true).lastOrNull()
?.copy(requestsAllowed = operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
}, operator.recommendationProfile, operator.capabilities.requesters.orEmpty().toSet(), operator.capabilities.requestMedia)
@ -101,7 +110,12 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
}
}
suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())
suspend fun profiles(): RecommendationProfiles? = profilesMutex.withLock {
observedProfiles ?: SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())?.also {
observedProfiles = it
profileNames.value = it.profiles.orEmpty()
}
}
suspend fun audiences(): List<LibraryAudience> {
libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty()
return libraryAudiences
@ -110,6 +124,17 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy
}
/** Request recipients need an authorized profile, not a saved Jellyfin login on this TV. */
internal fun ConductarrIdentity.requesters(profiles: List<ReadProfile>, saved: List<ShellPerson>): List<ShellPerson> {
val allowed = operator?.capabilities?.requesters.orEmpty()
return buildMap {
saved.filter { it.key in allowed }.forEach { put(it.key, it) }
profiles.filter { it.key in allowed }.forEach { profile ->
put(profile.key, ShellPerson(profile.key, profile.name, profile.name.take(1).uppercase(), false))
}
}.values.toList()
}
/** A batch tracks source failure without replacing saved bodies with empty success. */
internal class SnapshotReads(private val client: ConductarrClient, private val trigger: ReadTrigger) {
@Volatile var failed = false

View file

@ -39,7 +39,7 @@ class ConductarrSession(private val cache: CacheStore) : Closeable {
selection = fingerprint
if (token.isNullOrBlank()) return
val client = try {
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), onEvent = { event ->
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) {
mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) }
}

View file

@ -27,6 +27,13 @@ internal fun rememberDiscoverState(
val context = LocalContext.current
val scope = rememberCoroutineScope()
val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() }
val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty()
val requesters = remember(profileNames, shell.people, identity?.operator, fixtures) {
if (fixtures) shell.people else identity?.requesters(profileNames, shell.people).orEmpty()
}
LaunchedEffect(identity?.client, identity?.signedIn, fixtures) {
if (!fixtures && identity?.signedIn == true) identity.provider.profiles()
}
val refresh: suspend () -> DiscoverSnapshot = {
if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
else { retrySignIn(); DiscoverSnapshot() }
@ -37,7 +44,7 @@ internal fun rememberDiscoverState(
lateinit var loaded: DiscoverState
loaded = DiscoverState(shell, DiscoverSnapshot(status = if (fixtures || identity?.signedIn == true) DiscoverStatus.Loading else if (identity?.error != null && identity.error != ClientError.NeedsSignIn) DiscoverStatus.Unavailable else DiscoverStatus.NeedsSignIn), operations, scope, push, pop,
{ service, type -> servicePages["${service.id}:$type"].orEmpty() },
requesters = if (fixtures) shell.people else shell.people.filter { it.key in identity?.operator?.capabilities?.requesters.orEmpty() },
requesters = requesters,
onMore = if (fixtures || identity?.signedIn != true) null else { lane ->
val service = loaded.service
if (service == null) identity.provider.more(lane)
@ -48,6 +55,7 @@ internal fun rememberDiscoverState(
})
loaded
}
SideEffect { if (!fixtures) state.updateRequesters(requesters) }
var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf<DiscoverState?>(null) }
LaunchedEffect(state, fixtures) {
if (fixtures) {
@ -68,7 +76,7 @@ internal fun rememberDiscoverState(
}
}
LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) {
if (!fixtures && identity?.signedIn == true) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) ->
if (!fixtures && identity?.signedIn == true && state.hasCatalogue) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) ->
val service = state.service
if (service == null) {
state.updateLanes(identity.provider.readAhead(lane))

View file

@ -53,7 +53,6 @@ class WolfskinHomeViewModel @Inject constructor(
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
if (retry && conductarr.identity.value?.signedIn != true) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
conductarr.authenticate()
}
loadLibrary(server, user, person, retry)
}
@ -77,7 +76,7 @@ class WolfskinHomeViewModel @Inject constructor(
var libraryAvailable = true
var librarySavedAt: String? = null
val identity = conductarr.identity.value
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
fun publish() {
if (revision != loadRevision || sourceScope != scope) return
val status = when (catalogueCopy.status) {
@ -92,17 +91,24 @@ class WolfskinHomeViewModel @Inject constructor(
catalogueSnapshot = catalogueCopy
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
if (saved != null) publish()
if (identity?.signedIn == true) catalogueJob = viewModelScope.launch {
identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue ->
if (identity != null && (identity.signedIn || retry)) catalogueJob = viewModelScope.launch {
if (retry && !identity.signedIn) conductarr.authenticate()
val source = conductarr.identity.value
if (source == null || source.client !== identity.client) return@launch
if (!source.signedIn) {
publishCatalogue?.invoke(DiscoverSnapshot(status = if (source.error == org.justinvisser.wolfskin.conductarr.ClientError.NeedsSignIn) DiscoverStatus.NeedsSignIn else DiscoverStatus.Unavailable))
return@launch
}
source.provider.discover(source.operator!!.recommendationProfile, retry).collect { catalogue ->
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
}
}
try {
val library = withContext(Dispatchers.IO) { home.read(user.id) }
val savedAt = System.currentTimeMillis()
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* A disk failure must not turn a reachable source into an outage. */ } }
libraryCopy = library
publish()
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } }
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
if (revision != loadRevision) return

View file

@ -9,15 +9,21 @@ import org.justinvisser.wolfskin.design.*
internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: ShellState, route: String,
identity: ConductarrIdentity?, fixtures: Boolean, title: TitleState?): DiscoverState? {
val scope = rememberCoroutineScope()
val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty()
val requesters = remember(profileNames, shell.people, identity?.operator) { identity?.requesters(profileNames, shell.people).orEmpty() }
LaunchedEffect(identity?.client, identity?.signedIn, fixtures) {
if (!fixtures && identity?.signedIn == true) identity.provider.profiles()
}
val requests = if (fixtures) rememberDiscoverState(shell, viewModel::openRoute, viewModel::popRoute,
identity = null, fixtures = true, retrySignIn = viewModel::retrySignIn)
else remember(shell, shell.person.key, route, identity?.client, identity?.signedIn) {
identity?.takeIf { it.signedIn }?.let {
DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), it.provider.operations(it.operator!!),
scope, viewModel::openRoute, viewModel::popRoute,
requesters = shell.people.filter { person -> person.key in it.operator.capabilities.requesters.orEmpty() })
requesters = requesters)
}
}
SideEffect { if (!fixtures) requests?.updateRequesters(requesters) }
LaunchedEffect(requests, title?.data) {
val data = title?.data ?: return@LaunchedEffect
if (!fixtures && requests != null && data.item.tmdbId != null) {

View file

@ -459,6 +459,13 @@ the old client and in-memory providers. LKG files are isolated by person and cli
identity in app-private, backup-excluded storage. A 401 clears that identity's
cache and returns to sign-in states; Try again reconstructs and verifies the client.
Home publishes Jellyfin/LKG rows independently of Conductarr, including retry
authentication, and paints new Jellyfin data before writing its disk copy.
Production calls use 3-second connect, 5-second read and 8-second total limits.
Request recipients include authorized Conductarr profiles without saved Jellyfin
logins; that does not create selectable TV logins. Late name reads preserve the
open chooser's selected recipient and footer focus.
| Screen seam | Production supplier |
| --- | --- |
| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins |

View file

@ -19,12 +19,22 @@ class DiscoverState(
private val onPush: (String) -> Unit = {},
private val onPop: () -> Unit = {},
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
private val requesters: List<ShellPerson> = shell.people,
requesters: List<ShellPerson> = shell.people,
private val onMore: (suspend (String) -> List<DiscoverLane>?)? = null,
private val nowMillis: () -> Long = System::currentTimeMillis,
) : ShellContentFocus {
var snapshot by mutableStateOf(initial)
private set
private var requesters = requesters
fun updateRequesters(people: List<ShellPerson>) {
if (requesters == people) return
requesters = people
requestSheets.values.forEach { it.updatePeople(people) }
(overlay as? DiscoverOverlayState.Request)?.state?.let { current ->
if (requestSheets.values.none { it === current }) current.updatePeople(people)
}
}
private var savedTime = initial.savedAt ?: formatSavedTime()
private var serviceFilter by mutableStateOf("movie")
var filter: String

View file

@ -20,10 +20,11 @@ class RequestSheetState(
other: Boolean,
initialPhase: RequestPhase = RequestPhase.Choose,
) {
val people = listOf(viewer) + people.filter { it.key != viewer.key }
var people by mutableStateOf(listOf(viewer) + people.filter { it.key != viewer.key })
private set
val held = viewer.key in title.heldBy
val blocked = title.type == "tv" && !held && title.seasons.isNullOrEmpty()
val buttonIndex = this.people.size
val buttonIndex: Int get() = people.size
var selected by mutableStateOf(if (other || held) this.people.indexOfFirst { it.key != viewer.key && it.key !in title.heldBy }.coerceAtLeast(0) else 0)
var phase by mutableStateOf(initialPhase)
var focus by mutableStateOf(if (initialPhase != RequestPhase.Choose || blocked || !other) buttonIndex else selected)
@ -36,6 +37,15 @@ class RequestSheetState(
else -> "Request for ${selectedPerson.name}"
}
fun updatePeople(value: List<ShellPerson>) {
val selectedKey = selectedPerson.key
val focusedKey = people.getOrNull(focus)?.key
val footerOffset = focus - buttonIndex
people = listOf(viewer) + value.filter { it.key != viewer.key }
selected = people.indexOfFirst { it.key == selectedKey }.coerceAtLeast(0)
focus = if (footerOffset >= 0) buttonIndex + footerOffset else people.indexOfFirst { it.key == focusedKey }.coerceAtLeast(0)
}
fun key(key: ShellKey, send: (RequestSheetState, Boolean) -> Unit, close: () -> Unit, retryRecipients: () -> Unit) {
if (key == ShellKey.Back) { close(); return }
when (phase) {