Hide Anime destinations without visible library titles
This commit is contained in:
parent
5a84666074
commit
53b8358317
4 changed files with 129 additions and 1 deletions
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue