Hide Anime destinations without visible library titles

This commit is contained in:
justinzeus 2026-11-06 12:25:51 +01:00
parent 5a84666074
commit 53b8358317
4 changed files with 129 additions and 1 deletions

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 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() } audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it } ?.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 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()) } ?: person.hasAnime) hasAnime = if (current) hasAnime else 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

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. */ /** A destination spans every matching accessible library; containers never become titles. */
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow { fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> 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 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 the existing mapping or Jellyfin name rule. Returning to the foreground retries
household metadata without rebuilding the selected identity. 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 | | 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 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 checks that Continue closes the real session picker and selecting Home does not
reopen it. 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; 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.