Customize home page (#803)

## Description

This PR adds the ability to customize the home page in-app

### Features
- Add, remove, & reorder rows
- Persist the configuration locally
- Save the configuration to the server, allowing to pull down on other
devices
- Adjust view options for rows such as card height, image type,
preferring series images, or aspect ratio (similar to libraries)
- Pull down the web client's home rows (no plugins are supported yet!)
- Preview of the home page which is usable & updated as changes are made

### Row options

These are row types that can be added in-app via the UI:
- Continue watching
- Next up
- Combined continue waiting & next up
- Recently added in a library
- Recently released in a library
- Genres in a library
- Suggestions for a library (movie & TV show libraries only)
- Favorite movies, tv shows, episodes, etc

Additionally, there are more row types that don't have a UI to add them
(yet):
- Simple query to get items from a parent ID such as a collection or
playlist
- Complex query to get arbitrary items via the `/Items` API endpoint

### Dev notes

Settings are loaded in order:
1. Locally saved
2. Remote saved
3. Fallback to default similar to Wholphin's original home rows

The remote saved settings are stored via the display preferences API.

I know some server admins would prefer to push a default setup to their
clients. This PR does not have that ability, but it does define a
straightforward API for defining the settings. Something like the
potential server plugin work started in #625 could be slimmed down to
expose a URL to be added in the load order.

I'm also investigating integration with popular home page plugins to
allow for further customization, but will take more time.

### Related issues
Closes #399
Closes #361
Closes #282
Related to #340
This commit is contained in:
Ray 2026-02-12 19:54:51 -05:00 committed by GitHub
parent b7679b3aa1
commit fcba1c7444
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 4297 additions and 321 deletions

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject
@ -47,7 +48,12 @@ class BackdropService
suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
val imageUrl =
if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride
} else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
}
submit(item.id.toString(), imageUrl)
}

View file

@ -0,0 +1,951 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success
import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToStream
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HomeSettingsService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val userPreferencesService: UserPreferencesService,
private val navDrawerItemRepository: NavDrawerItemRepository,
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
private val suggestionService: SuggestionService,
) {
@OptIn(ExperimentalSerializationApi::class)
val jsonParser =
Json {
isLenient = true
ignoreUnknownKeys = true
allowTrailingComma = true
}
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
/**
* Saves a [HomePageSettings] to the server for the user under the display preference ID
*
* @see loadFromServer
*/
suspend fun saveToServer(
userId: UUID,
settings: HomePageSettings,
displayPreferencesId: String = DISPLAY_PREF_ID,
) {
val current = getDisplayPreferences(userId, DISPLAY_PREF_ID)
val customPrefs =
current.customPrefs.toMutableMap().apply {
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
}
api.displayPreferencesApi.updateDisplayPreferences(
displayPreferencesId = displayPreferencesId,
userId = userId,
client = context.getString(R.string.app_name),
data = current.copy(customPrefs = customPrefs),
)
}
/**
* Reads a [HomePageSettings] from the server for the user and display preference ID
*
* Returns null if there is none saved
*
* @see saveToServer
*/
suspend fun loadFromServer(
userId: UUID,
displayPreferencesId: String = DISPLAY_PREF_ID,
): HomePageSettings? {
val current = getDisplayPreferences(userId, displayPreferencesId)
return current.customPrefs[CUSTOM_PREF_ID]?.let {
val jsonElement = jsonParser.parseToJsonElement(it)
decode(jsonElement)
}
}
private suspend fun getDisplayPreferences(
userId: UUID,
displayPreferencesId: String,
) = api.displayPreferencesApi
.getDisplayPreferences(
userId = userId,
displayPreferencesId = displayPreferencesId,
client = context.getString(R.string.app_name),
).content
/**
* Computes the filename for locally saved [HomePageSettings]
*/
private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json"
/**
* Save the [HomePageSettings] for the user locally on the device
*
* @see loadFromLocal
*/
@OptIn(ExperimentalSerializationApi::class)
suspend fun saveToLocal(
userId: UUID,
settings: HomePageSettings,
) {
val dir = File(context.filesDir, CUSTOM_PREF_ID)
dir.mkdirs()
File(dir, filename(userId)).outputStream().use {
jsonParser.encodeToStream(settings, it)
}
}
/**
* Reads [HomePageSettings] for the user if it exists
*
* @see saveToLocal
*/
@OptIn(ExperimentalSerializationApi::class)
suspend fun loadFromLocal(userId: UUID): HomePageSettings? {
val dir = File(context.filesDir, CUSTOM_PREF_ID)
val file = File(dir, filename(userId))
return if (file.exists()) {
val fileContents = file.readText()
val jsonElement = jsonParser.parseToJsonElement(fileContents)
decode(jsonElement)
} else {
null
}
}
/**
* Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows
*
* This is public only for testing
*/
fun decode(element: JsonElement): HomePageSettings {
val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull
if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) {
throw UnsupportedHomeSettingsVersionException(version)
}
val rowsElement = element.jsonObject["rows"]?.jsonArray
val rows =
rowsElement
?.mapNotNull { row ->
try {
jsonParser.decodeFromJsonElement<HomeRowConfig>(row)
} catch (ex: Exception) {
Timber.w(ex, "Unknown row %s", row)
// TODO maybe use placeholder instead of null?
null
}
}.orEmpty()
return HomePageSettings(rows, version)
}
/**
* Loads [HomePageSettings] into [currentSettings]
*
* First checks locally, then on the server, and finally creates a default if needed
*
* Does not persist either the server nor default
*/
suspend fun loadCurrentSettings(userId: UUID) {
Timber.v("Getting setting for %s", userId)
// User local then server/remote otherwise create a default
val settings =
try {
val local = loadFromLocal(userId)
Timber.v("Found local? %s", local != null)
local
} catch (ex: Exception) {
Timber.w(ex, "Error loading local settings")
// TODO show toast?
null
} ?: try {
val remote = loadFromServer(userId)
Timber.v("Found remote? %s", remote != null)
remote
} catch (ex: Exception) {
Timber.w(ex, "Error loading remote settings")
null
}
val resolvedSettings =
if (settings != null) {
Timber.v("Found settings")
// Resolve
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
HomePageResolvedSettings(resolvedRows)
} else {
createDefault()
}
currentSettings.update { resolvedSettings }
}
suspend fun updateCurrent(settings: HomePageSettings) {
val resolvedRows =
settings.rows.mapIndexed { index, config ->
resolve(index, config)
}
val resolvedSettings = HomePageResolvedSettings(resolvedRows)
currentSettings.update { resolvedSettings }
}
/**
* Create a default [HomePageResolvedSettings] using the available libraries
*/
suspend fun createDefault(): HomePageResolvedSettings {
Timber.v("Creating default settings")
val navDrawerItems = navDrawerItemRepository.getNavDrawerItems()
val libraries =
navDrawerItems
.filter { it is ServerNavDrawerItem }
.map {
it as ServerNavDrawerItem
Library(it.itemId, it.name, it.type)
}
val prefs =
userPreferencesService.getCurrent().appPreferences.homePagePreferences
val includedIds =
navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItems)
.filter { it is ServerNavDrawerItem }
.mapIndexed { index, it ->
val parentId = (it as ServerNavDrawerItem).itemId
val name = libraries.firstOrNull { it.itemId == parentId }?.name
val title =
name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
HomeRowConfigDisplay(
id = index,
title = title,
config = HomeRowConfig.RecentlyAdded(parentId),
)
}
val continueWatchingRows =
if (prefs.combineContinueNext) { // TODO
listOf(
HomeRowConfigDisplay(
id = includedIds.size + 1,
title = context.getString(R.string.combine_continue_next),
config = HomeRowConfig.ContinueWatchingCombined(),
),
)
} else {
listOf(
HomeRowConfigDisplay(
id = includedIds.size + 1,
title = context.getString(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(),
),
HomeRowConfigDisplay(
id = includedIds.size + 2,
title = context.getString(R.string.next_up),
config = HomeRowConfig.NextUp(),
),
)
}
val rowConfig = continueWatchingRows + includedIds
return HomePageResolvedSettings(rowConfig)
}
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
val customPrefs =
api.displayPreferencesApi
.getDisplayPreferences(
displayPreferencesId = "usersettings",
userId = userId,
client = "emby",
).content.customPrefs
val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration
val libraries =
api.userViewsApi
.getUserViews(userId = userId)
.content.items
.filter {
it.collectionType in supportedHomeCollectionTypes &&
it.id !in config.latestItemsExcludes
}
return if (customPrefs.isNotEmpty()) {
var id = 0
val rowConfigs =
(0..9)
.mapNotNull { idx ->
val sectionType =
HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase())
Timber.v(
"sectionType=$sectionType, %s",
customPrefs["homesection$idx"]?.lowercase(),
)
val config =
when (sectionType) {
HomeSectionType.ACTIVE_RECORDINGS -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.active_recordings),
config = HomeRowConfig.Recordings(),
)
}
HomeSectionType.RESUME -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.continue_watching),
config = HomeRowConfig.ContinueWatching(),
)
}
HomeSectionType.NEXT_UP -> {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.next_up),
config = HomeRowConfig.NextUp(),
)
}
HomeSectionType.LIVE_TV -> {
if (userDto.policy?.enableLiveTvAccess == true) {
HomeRowConfigDisplay(
id = id++,
title = context.getString(R.string.live_tv),
config = HomeRowConfig.TvPrograms(),
)
} else {
null
}
}
HomeSectionType.LATEST_MEDIA -> {
// Handled below
null
}
// Unsupported
HomeSectionType.RESUME_AUDIO,
HomeSectionType.RESUME_BOOK,
-> {
null
}
HomeSectionType.SMALL_LIBRARY_TILES,
HomeSectionType.LIBRARY_BUTTONS,
HomeSectionType.NONE,
null,
-> {
null
}
}
if (sectionType == HomeSectionType.LATEST_MEDIA) {
libraries.map {
HomeRowConfigDisplay(
id = id++,
title =
context.getString(
R.string.recently_added_in,
it.name ?: "",
),
config = HomeRowConfig.RecentlyAdded(it.id),
)
}
} else if (config != null) {
listOf(config)
} else {
null
}
}.flatten()
HomePageResolvedSettings(rowConfigs)
} else {
null
}
}
/**
* Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes
*/
suspend fun resolve(
id: Int,
config: HomeRowConfig,
): HomeRowConfigDisplay =
when (config) {
is HomeRowConfig.ByParent -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id,
name,
config,
)
}
is HomeRowConfig.ContinueWatching -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.continue_watching),
config,
)
}
is HomeRowConfig.ContinueWatchingCombined -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.combine_continue_next),
config,
)
}
is HomeRowConfig.Genres -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.genres_in, name),
config,
)
}
is HomeRowConfig.GetItems -> {
HomeRowConfigDisplay(id, config.name, config)
}
is HomeRowConfig.NextUp -> {
HomeRowConfigDisplay(
id,
context.getString(R.string.next_up),
config,
)
}
is HomeRowConfig.RecentlyAdded -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.recently_added_in, name),
config,
)
}
is HomeRowConfig.RecentlyReleased -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id,
context.getString(R.string.recently_released_in, name),
config,
)
}
is HomeRowConfig.Favorite -> {
val name = context.getString(R.string.favorites) // TODO "Favorite <type>"
HomeRowConfigDisplay(id, name, config)
}
is HomeRowConfig.Recordings -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.active_recordings),
config,
)
}
is HomeRowConfig.TvPrograms -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.live_tv),
config,
)
}
is HomeRowConfig.Suggestions -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.suggestions_for, name),
config,
)
}
}
/**
* Fetch the data from the server for a given [HomeRowConfig]
*/
suspend fun fetchDataForRow(
row: HomeRowConfig,
scope: CoroutineScope,
prefs: HomePagePreferences,
userDto: UserDto,
libraries: List<Library>,
limit: Int = prefs.maxItemsPerRow,
): HomeRowLoadingState =
when (row) {
is HomeRowConfig.ContinueWatching -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
Success(
title = context.getString(R.string.continue_watching),
items = resume,
viewOptions = row.viewOptions,
)
}
is HomeRowConfig.NextUp -> {
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = context.getString(R.string.next_up),
items = nextUp,
viewOptions = row.viewOptions,
)
}
is HomeRowConfig.ContinueWatchingCombined -> {
val resume =
latestNextUpService.getResume(
userDto.id,
limit,
true,
row.viewOptions.useSeries,
)
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries,
)
Success(
title = context.getString(R.string.continue_watching),
items =
latestNextUpService.buildCombined(
resume,
nextUp,
),
viewOptions = row.viewOptions,
)
}
is HomeRowConfig.Genres -> {
val request =
GetGenresRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
)
val items =
GetGenresRequestHandler
.execute(api, request)
.content.items
val genreIds = items.map { it.id }
val genreImages =
getGenreImageMap(
api = api,
scope = scope,
imageUrlService = imageUrlService,
genres = genreIds,
parentId = row.parentId,
includeItemTypes = null,
cardWidthPx = null,
)
val genres =
items.map {
BaseItem(it, false, genreImages[it.id])
}
val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
name?.let { context.getString(R.string.genres_in, it) }
?: context.getString(R.string.genres)
Success(
title,
genres,
viewOptions = row.viewOptions,
)
}
is HomeRowConfig.RecentlyAdded -> {
val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
.let {
Success(
title,
it,
row.viewOptions,
)
}
latest
}
is HomeRowConfig.RecentlyReleased -> {
val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
name?.let {
context.getString(R.string.recently_released_in, it)
} ?: context.getString(R.string.recently_released)
val request =
GetItemsRequest(
parentId = row.parentId,
limit = limit,
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING),
fields = DefaultItemFields,
recursive = true,
)
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) }
.let {
Success(
title,
it,
row.viewOptions,
)
}
}
is HomeRowConfig.ByParent -> {
val request =
GetItemsRequest(
userId = userDto.id,
parentId = row.parentId,
recursive = row.recursive,
sortBy = row.sort?.let { listOf(it.sort) },
sortOrder = row.sort?.let { listOf(it.direction) },
limit = limit,
fields = DefaultItemFields,
)
val name =
api.userLibraryApi
.getItem(itemId = row.parentId)
.content.name
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
name ?: context.getString(R.string.collection),
it,
row.viewOptions,
)
}
}
is HomeRowConfig.GetItems -> {
val request =
row.getItems.let {
if (it.limit == null) {
it.copy(
userId = userDto.id,
limit = limit,
)
} else {
it.copy(
userId = userDto.id,
)
}
}
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
row.name,
it,
row.viewOptions,
)
}
}
is HomeRowConfig.Favorite -> {
if (row.kind == BaseItemKind.PERSON) {
val request =
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = DefaultItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
)
GetPersonsHandler
.execute(api, request)
.content.items
.map { BaseItem(it, true) }
.let {
Success(
context.getString(R.string.favorites), // TODO
it,
row.viewOptions,
)
}
} else {
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = DefaultItemFields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
context.getString(R.string.favorites), // TODO
it,
row.viewOptions,
)
}
}
}
is HomeRowConfig.Recordings -> {
val request =
GetRecordingsRequest(
userId = userDto.id,
isInProgress = true,
fields = DefaultItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
)
api.liveTvApi
.getRecordings(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
context.getString(R.string.active_recordings),
it,
row.viewOptions,
)
}
}
is HomeRowConfig.TvPrograms -> {
val request =
GetRecommendedProgramsRequest(
userId = userDto.id,
fields = DefaultItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
)
api.liveTvApi
.getRecommendedPrograms(request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
.let {
Success(
context.getString(R.string.live_tv),
it,
row.viewOptions,
)
}
}
is HomeRowConfig.Suggestions -> {
val library =
api.userLibraryApi
.getItem(itemId = row.parentId)
.content
val title = context.getString(R.string.suggestions_for, library.name ?: "")
val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType)
val suggestions =
itemKind?.let {
suggestionService
.getSuggestionsFlow(row.parentId, itemKind)
.firstOrNull()
}
if (suggestions != null && suggestions is SuggestionsResource.Success) {
Success(
title,
suggestions.items,
row.viewOptions,
)
} else if (suggestions is SuggestionsResource.Empty) {
Success(
title,
listOf(),
row.viewOptions,
)
} else {
HomeRowLoadingState.Error(
title,
message = "Unsupported type ${library.collectionType}",
)
}
}
}
companion object {
const val DISPLAY_PREF_ID = "default"
const val CUSTOM_PREF_ID = "home_settings"
}
}
/**
* A [HomeRowConfig] with a resolved ID and title so it is usable in the UI
*/
data class HomeRowConfigDisplay(
val id: Int,
val title: String,
val config: HomeRowConfig,
)
/**
* List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s
*
* @see HomePageSettings
*/
data class HomePageResolvedSettings(
val rows: List<HomeRowConfigDisplay>,
) {
companion object {
val EMPTY = HomePageResolvedSettings(listOf())
}
}
// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs
enum class HomeSectionType(
val serialName: String,
) {
NONE("none"),
SMALL_LIBRARY_TILES("smalllibrarytitles"),
LIBRARY_BUTTONS("librarybuttons"),
ACTIVE_RECORDINGS("activerecordings"),
RESUME("resume"),
RESUME_AUDIO("resumeaudio"),
LATEST_MEDIA("latestmedia"),
NEXT_UP("nextup"),
LIVE_TV("livetv"),
RESUME_BOOK("resumebook"),
;
companion object {
fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } }
}
}
class UnsupportedHomeSettingsVersionException(
val unsupportedVersion: Int?,
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")

View file

@ -4,8 +4,6 @@ import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.main.LatestData
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
@ -21,6 +19,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
@ -44,6 +43,7 @@ class LatestNextUpService
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
@ -66,7 +66,7 @@ class LatestNextUpService
.getResumeItems(request)
.content
.items
.map { BaseItem.from(it, api, true) }
.map { BaseItem.from(it, api, useSeriesForPrimary) }
return items
}
@ -76,6 +76,7 @@ class LatestNextUpService
enableRewatching: Boolean,
enableResumable: Boolean,
maxDays: Int,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
@ -96,7 +97,7 @@ class LatestNextUpService
.getNextUp(request)
.content
.items
.map { BaseItem.from(it, api, true) }
.map { BaseItem.from(it, api, useSeriesForPrimary) }
return nextUp
}
@ -192,3 +193,17 @@ class LatestNextUpService
return@withContext result
}
}
val supportedLatestCollectionTypes =
setOf(
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS,
// Exclude Live TV because a recording folder view will be used instead
null, // Recordings & mixed collection types
)
data class LatestData(
val title: String,
val request: GetLatestMediaRequest,
)

View file

@ -226,6 +226,7 @@ class UserSwitchListener
private val seerrServerRepository: SeerrServerRepository,
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService,
) {
init {
context as AppCompatActivity
@ -233,41 +234,58 @@ class UserSwitchListener
serverRepository.currentUser.asFlow().collect { user ->
Timber.d("New user")
seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.firstOrNull()
?.let { seerrUser ->
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
try {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} catch (ex: Exception) {
Timber.w(ex, "Error logging into %s", server.url)
seerrServerRepository.clear()
return@let
// Check for home settings
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
}
// Check for seerr server
launchIO {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.firstOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
try {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.clear()
return@let
}
} else {
try {
seerrApi.api.usersApi.authMeGet()
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.clear()
return@let
}
}
} else {
try {
seerrApi.api.usersApi.authMeGet()
} catch (ex: Exception) {
Timber.w(ex, "Error logging into %s", server.url)
seerrServerRepository.clear()
return@let
}
}
seerrServerRepository.set(server, seerrUser, userConfig)
seerrServerRepository.set(server, seerrUser, userConfig)
}
}
}
}
}
}
}

View file

@ -85,11 +85,8 @@ class SuggestionsWorker
views
.mapNotNull { view ->
val itemKind =
when (view.collectionType) {
CollectionType.MOVIES -> BaseItemKind.MOVIE
CollectionType.TVSHOWS -> BaseItemKind.SERIES
else -> return@mapNotNull null
}
getTypeForCollection(view.collectionType)
?: return@mapNotNull null
async(Dispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for view %s", view.id)
@ -267,5 +264,12 @@ class SuggestionsWorker
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId"
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
when (collectionType) {
CollectionType.MOVIES -> BaseItemKind.MOVIE
CollectionType.TVSHOWS -> BaseItemKind.SERIES
else -> null
}
}
}

View file

@ -80,7 +80,6 @@ class TvProviderWorker
getPotentialItems(
userId,
prefs.homePagePreferences.enableRewatchingNextUp,
prefs.homePagePreferences.combineContinueNext,
prefs.homePagePreferences.maxDaysNextUp,
)
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
@ -145,7 +144,6 @@ class TvProviderWorker
private suspend fun getPotentialItems(
userId: UUID,
enableRewatching: Boolean,
combineContinueNext: Boolean,
maxDaysNextUp: Int,
): List<BaseItem> {
val resumeItems = latestNextUpService.getResume(userId, 10, true)
@ -154,11 +152,7 @@ class TvProviderWorker
latestNextUpService
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
return if (combineContinueNext) {
latestNextUpService.buildCombined(resumeItems, nextUpItems)
} else {
resumeItems + nextUpItems
}
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
}
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =