From 53b8358317c4f8697624edb92472ce9b30db96a9 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:25:51 +0100 Subject: [PATCH] Hide Anime destinations without visible library titles --- .../wolfskin/tv/AnimeAvailabilityTest.kt | 101 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 9 +- .../wolfskin/tv/read/LibraryReadFacade.kt | 12 +++ docker/README.md | 8 ++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt new file mode 100644 index 00000000..e5d0dfa5 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -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()) + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + } + + @Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + 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() + val items = mockk() + val api = mockk() + 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() } + } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 52a68beb..b654df48 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -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 diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index cc9497f1..775235f2 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -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> = flow { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> diff --git a/docker/README.md b/docker/README.md index bd1667dc..b1a5993d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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.