Update favorites page with more types (#1864)

## Description
This PR adds many more data types as tabs to the favorites page.
Additionally, only tabs for the types the user has favorites for will
appear. For example, if you have no favorited music videos, that tab
will not show.

Added types:
- Collections
- TV Channels
- Music albums
- Music artists
- Music videos
- Songs
- Home videos
- Photos
- Photo albums

Dev note: The tabs are implemented using keys instead of indexes which
could be useful for the series overview season tabs. Plus it maybe even
be adapted for the episode list to eliminate the terrible index search
logic. A major difference is that the possible favorites tabs are
known/static list where seasons/episodes are queried & paginated.

### Related issues
Closes #1041
Closes #1844

### Testing
Manually on emulator, plus unit tests

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-08-26 21:50:13 -04:00 • committed by GitHub
parent 786b95a393
commit fdde986029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1783 additions and 397 deletions

View file

@ -80,6 +80,23 @@ fun playlist(
genreItems = genres,
)
/**
* Create a simple [BaseItemDto] of the specified type
*/
fun item(
type: BaseItemKind,
id: UUID = UUID.randomUUID(),
name: String = "Test $type",
genres: List<NameGuidPair>? = null,
): BaseItemDto =
BaseItemDto(
id = id,
type = type,
name = name,
seriesId = null,
genreItems = genres,
)
fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11")
fun user(

View file

@ -0,0 +1,427 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.FilterOptionCache
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerItemState
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.RememberedTabService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.test.item
import com.github.damontecres.wholphin.test.movie
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.util.DataLoadingState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.artistsApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.personsApi
import org.jellyfin.sdk.api.operations.ArtistsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.PersonsApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class TestFavoritesViewModel {
private val context: Context = mockk()
private val api: ApiClient = mockk()
private val navigationManager: NavigationManager = mockk()
private val serverRepository: ServerRepository = mockk()
private val libraryDisplayInfoDao: LibraryDisplayInfoDao = mockk()
private val favoriteWatchManager: FavoriteWatchManager = mockk()
private val backdropService: BackdropService = mockk()
private val userPreferencesService: UserPreferencesService = mockk()
private val mediaManagementService: MediaManagementService = mockk()
private val streamChoiceService: StreamChoiceService = mockk()
private val mediaReportService: MediaReportService = mockk()
private val filterOptionCache: FilterOptionCache = mockk()
private val rememberedTabService: RememberedTabService = mockk()
private val navDrawerService: NavDrawerService = mockk()
private val itemsApi: ItemsApi = mockk()
private val artistsApi: ArtistsApi = mockk()
private val personsApi: PersonsApi = mockk()
@Before
fun setup() {
every { api.itemsApi } returns itemsApi
every { api.artistsApi } returns artistsApi
every { api.personsApi } returns personsApi
every { navDrawerService.state } returns
MutableStateFlow(
NavDrawerItemState(
allLibraries =
listOf(
library(CollectionType.MOVIES),
library(CollectionType.TVSHOWS),
library(CollectionType.BOXSETS),
),
),
)
}
private fun createViewModel(): FavoritesViewModel =
FavoritesViewModel(
context = context,
api = api,
navigationManager = navigationManager,
serverRepository = serverRepository,
libraryDisplayInfoDao = libraryDisplayInfoDao,
favoriteWatchManager = favoriteWatchManager,
backdropService = backdropService,
userPreferencesService = userPreferencesService,
mediaManagementService = mediaManagementService,
streamChoiceService = streamChoiceService,
mediaReportService = mediaReportService,
filterOptionCache = filterOptionCache,
rememberedTabService = rememberedTabService,
navDrawerService = navDrawerService,
).apply {
setupPossibleTypes()
}
private val items = listOf(BaseItem(movie()))
private val empty = emptyList<BaseItem>()
private fun successJob(items: List<BaseItem>) = CompletableDeferred(DataLoadingState.Success(items))
private fun errorJob() = CompletableDeferred(DataLoadingState.Error())
private suspend fun testInitialFetchAndWait(
firstTabKey: BaseItemKind,
jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>,
asserts: (FavoritesPageState) -> Unit,
) {
createViewModel().let { viewModel ->
viewModel.initialFetchAndWait(
firstTabKey = firstTabKey,
jobs = jobs,
)
viewModel.state.value.let { state ->
asserts.invoke(state)
}
}
}
@Test
fun `Test tabKey for first success`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to successJob(items),
BaseItemKind.SERIES to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.MOVIE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.MOVIE, state.tabKey)
}
}
@Test
fun `Test tabKey for second success`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to successJob(empty),
BaseItemKind.SERIES to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.MOVIE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.SERIES,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
}
@Test
fun `Test tabKey for first failure`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to errorJob(),
BaseItemKind.SERIES to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.MOVIE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.MOVIE, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.SERIES,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
}
@Test
fun `Test tabKey for multiple empty`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to successJob(empty),
BaseItemKind.SERIES to successJob(items),
BaseItemKind.EPISODE to successJob(empty),
BaseItemKind.BOX_SET to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.MOVIE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.SERIES,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.EPISODE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
}
@Test
fun `Test tabKey for multiple empty with first error`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to errorJob(),
BaseItemKind.SERIES to successJob(empty),
BaseItemKind.EPISODE to successJob(items),
BaseItemKind.BOX_SET to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.SERIES,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.MOVIE, state.tabKey)
}
}
@Test
fun `Test tabKey for multiple empty with first empty`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to successJob(empty),
BaseItemKind.SERIES to successJob(empty),
BaseItemKind.EPISODE to successJob(empty),
BaseItemKind.BOX_SET to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.EPISODE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.BOX_SET, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.BOX_SET,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.BOX_SET, state.tabKey)
}
}
@Test
fun `Test fetchType basic`() =
runTest {
val movie = movie()
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(movie))
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.MOVIE,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Success)
assertEquals(BaseItem(movie), (data as DataLoadingState.Success).data.first())
viewModel.state.value.let { state ->
assertTrue(BaseItemKind.MOVIE in state.tabs)
assertTrue(BaseItemKind.MOVIE in state.favorites)
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Success)
}
}
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
}
@Test
fun `Test fetchType with error`() =
runTest {
val ex = Exception()
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } throws ex
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.MOVIE,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Error)
assertEquals(ex, (data as DataLoadingState.Error).exception)
viewModel.state.value.let { state ->
assertTrue(BaseItemKind.MOVIE in state.tabs)
assertTrue(BaseItemKind.MOVIE in state.favorites)
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Error)
assertEquals(ex, (st.items as DataLoadingState.Error).exception)
}
}
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
}
@Test
fun `Test fetchType with empty result does not add tab`() =
runTest {
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult()
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.MOVIE,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Success)
assertEquals(0, (data as DataLoadingState.Success).data.size)
viewModel.state.value.let { state ->
assertFalse(BaseItemKind.MOVIE in state.tabs)
assertTrue(BaseItemKind.MOVIE in state.favorites)
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Success)
assertEquals(0, (st.items as DataLoadingState.Success).data.size)
}
}
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
}
@Test
fun `Test fetchType for artists`() =
runTest {
val item = item(BaseItemKind.MUSIC_ARTIST)
coEvery { artistsApi.getArtists(any<GetArtistsRequest>()) } returns successQueryResult(listOf(item))
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.MUSIC_ARTIST,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Success)
assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first())
viewModel.state.value.let { state ->
assertTrue(BaseItemKind.MUSIC_ARTIST in state.tabs)
assertTrue(BaseItemKind.MUSIC_ARTIST in state.favorites)
state.favorites[BaseItemKind.MUSIC_ARTIST]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Success)
}
}
coVerify(exactly = 1) { artistsApi.getArtists(any<GetArtistsRequest>()) }
coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) }
coVerify(exactly = 0) { personsApi.getPersons(any<GetPersonsRequest>()) }
}
@Test
fun `Test fetchType for persons`() =
runTest {
val item = item(BaseItemKind.PERSON)
coEvery { personsApi.getPersons(any<GetPersonsRequest>()) } returns successQueryResult(listOf(item))
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.PERSON,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Success)
assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first())
viewModel.state.value.let { state ->
assertTrue(BaseItemKind.PERSON in state.tabs)
assertTrue(BaseItemKind.PERSON in state.favorites)
state.favorites[BaseItemKind.PERSON]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Success)
}
}
coVerify(exactly = 1) { personsApi.getPersons(any<GetPersonsRequest>()) }
coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) }
coVerify(exactly = 0) { artistsApi.getArtists(any<GetArtistsRequest>()) }
}
}
private fun library(type: CollectionType) =
Library(
itemId = UUID.randomUUID(),
name = "name-${type.serialName}",
type = BaseItemKind.COLLECTION_FOLDER,
collectionType = type,
isRecordingFolder = false,
)