Fix TV startup navigation and complete title pages #39

Merged
justinzeus merged 7 commits from fm/tv-walk-fix1 into main 2026-11-06 13:16:43 +01:00
4 changed files with 129 additions and 1 deletions
Showing only changes of commit 53b8358317 - Show all commits

View file

@ -0,0 +1,101 @@
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 com.github.damontecres.wholphin.ui.successQueryResult
import io.mockk.coEvery
import io.mockk.every
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.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
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.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class AnimeAvailabilityTest {
private var previousFixtures = false
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
}
@Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) {
for (count in listOf(0, 1)) FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token)
conductarr.select("${server.id}:${user.id}", token, fake.url)
conductarr.authenticate()
val identity = conductarr.identity.value!!
val audiences = identity.provider.audiences()!!.filter {
it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty())
}
val names = audiences.flatMap { it.libraries.orEmpty() }.distinct()
assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty())
val views = mockk<UserViewsApi>()
val items = mockk<ItemsApi>()
val api = mockk<ApiClient>()
every { api.userViewsApi } returns views
every { api.itemsApi } returns items
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map {
BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
})
val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) }
coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles)
val libraries = LibraryReadFacade(api)
val session = WolfskinSession().apply {
state = ShellState(listOf(ShellPerson(key, key, key.take(1), false, user.id.toString())), key)
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }),
destinationTotals = mapOf("anime" to count))
assertEquals("An empty Anime audience must not create a Home shelf", count > 0,
buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" })
} finally { model.viewModelScope.cancel() }
}
}
}
}
}

View file

@ -123,12 +123,19 @@ class WolfskinShellViewModel @Inject constructor(
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
val hasAnime = try {
libraries.hasAnimeTitles(user.id)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false
}
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
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()) } ?: person.hasAnime)
hasAnime = if (current) hasAnime else person.hasAnime)
}
session.state?.updatePeople(updated)
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson

View file

@ -48,6 +48,18 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
}
}
/** Audience metadata names a grouping; only this user's visible titles make Anime available. */
suspend fun hasAnimeTitles(userId: UUID): Boolean {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
for (library in libraries) {
if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue
val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty()
if (titles.isNotEmpty()) return true
}
return false
}
/** A destination spans every matching accessible library; containers never become titles. */
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->

View file

@ -494,6 +494,11 @@ 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.
An Anime audience grants a library grouping, not proof of owned titles. Household
refresh enables Anime only after a one-title Jellyfin read confirms visible series
in an accessible Anime library for the signed-in user. This runs after the live
picker is published; failures retain that user's last confirmed availability.
Empty Anime Home shelves remain omitted.
| Screen seam | Production supplier |
| --- | --- |
@ -530,6 +535,9 @@ immediate signed-in replies, then continues as Mindel and renders her six-film
Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also
checks that Continue closes the real session picker and selecting Home does not
reopen it.
`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared
and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or
Home shelf, while a real visible series enables both.
Not done here: deploying the dedicated hostname or widening server authority;
hardware installation/playback verification; releases, signing or version changes.