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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue