Merge branch 'main' into fea/music

This commit is contained in:
Damontecres 2026-02-24 12:40:57 -05:00
commit cd06c0380e
No known key found for this signature in database
122 changed files with 8844 additions and 1446 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)
}

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,9 @@ class ImageUrlService
useSeriesForPrimary: Boolean,
imageTags: Map<ImageType, String?>,
imageType: ImageType,
parentThumbId: UUID? = null,
parentBackdropId: UUID? = null,
backdropTags: List<String> = emptyList(),
fillWidth: Int? = null,
fillHeight: Int? = null,
): String? =
@ -54,8 +57,65 @@ class ImageUrlService
}
}
ImageType.THUMB -> {
if (useSeriesForPrimary && parentThumbId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
) {
// Use parent's thumb
getItemImageUrl(
itemId = parentThumbId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (useSeriesForPrimary && parentBackdropId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
) {
// No parent thumb, so use backdrop instead
getItemImageUrl(
itemId = parentBackdropId,
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
itemId = parentThumbId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (useSeriesForPrimary &&
parentThumbId == null &&
itemType == BaseItemKind.EPISODE &&
imageType !in imageTags
) {
// Workaround to fall back to episode image if no parent thumb
getItemImageUrl(
itemId = itemId,
imageType = ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
// If no thumb, use backdrop if available
getItemImageUrl(
itemId = itemId,
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
getItemImageUrl(
itemId = itemId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
}
}
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BANNER,
-> {
if (useSeriesForPrimary && seriesId != null &&
@ -99,15 +159,19 @@ class ImageUrlService
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
useSeriesForPrimary: Boolean? = null,
): String? =
if (item != null) {
getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = item.data.seriesId,
useSeriesForPrimary = item.useSeriesForPrimary,
useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary,
imageTags = item.data.imageTags.orEmpty(),
imageType = imageType,
parentThumbId = item.data.parentThumbItemId,
parentBackdropId = item.data.parentBackdropItemId,
backdropTags = item.data.backdropImageTags.orEmpty(),
fillWidth = fillWidth,
fillHeight = fillHeight,
)

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,8 @@ 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.ImageType
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 +44,7 @@ class LatestNextUpService
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
@ -60,13 +61,19 @@ class LatestNextUpService
remove(BaseItemKind.EPISODE)
}
},
enableImageTypes =
listOf(
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BACKDROP,
),
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.from(it, api, true) }
.map { BaseItem.from(it, api, useSeriesForPrimary) }
return items
}
@ -76,6 +83,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 +104,7 @@ class LatestNextUpService
.getNextUp(request)
.content
.items
.map { BaseItem.from(it, api, true) }
.map { BaseItem.from(it, api, useSeriesForPrimary) }
return nextUp
}
@ -192,3 +200,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

@ -0,0 +1,184 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.lifecycle.asFlow
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NavDrawerService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:DefaultCoroutineScope private val coroutineScope: CoroutineScope,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) {
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state
init {
serverRepository.currentUser
.asFlow()
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
Pair(user, userDto)
}.onEach { (user, userDto) ->
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
_state.update {
it.copy(
items = emptyList(),
moreItems = emptyList(),
)
}
if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto)
}
}.launchIn(coroutineScope)
seerrServerRepository.active
.onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope)
}
suspend fun getAllUserLibraries(
userId: UUID,
tvAccess: Boolean,
): List<Library> {
val userViews =
api.userViewsApi
.getUserViews(userId = userId)
.content.items
val recordingFolders =
if (tvAccess) {
api.liveTvApi
.getRecordingFolders(userId = userId)
.content.items
.map { it.id }
.toSet()
} else {
setOf()
}
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
.map {
Library(
itemId = it.id,
name = it.name ?: "",
type = it.type,
collectionType = it.collectionType ?: CollectionType.UNKNOWN,
isRecordingFolder = it.id in recordingFolders,
)
}
return libraries
}
suspend fun getFilteredUserLibraries(
user: JellyfinUser,
tvAccess: Boolean,
): List<Library> {
val pins =
serverPreferencesDao
.getNavDrawerPinnedItems(user)
.associateBy { it.itemId }
val libraries =
getAllUserLibraries(user.id, tvAccess)
.filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED }
return libraries
}
suspend fun updateNavDrawer(
user: JellyfinUser,
userDto: UserDto,
) {
val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
val libraries =
allLibraries
.map {
val destination =
if (it.isRecordingFolder) {
Destination.Recordings(it.itemId)
} else {
Destination.MediaItem(
it.itemId,
it.type,
it.collectionType,
)
}
ServerNavDrawerItem(
itemId = it.itemId,
name = it.name,
destination = destination,
type = it.collectionType,
)
}
val allItems = builtins + libraries
val navDrawerPins =
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
val items = mutableListOf<NavDrawerItem>()
val moreItems = mutableListOf<NavDrawerItem>()
allItems
// Sort by order if non-default, existing items before customize will have -1 value
// New items from the server will get Int.MAX_VALUE
// Items the user doesn't have access to anymore will be skipped
.sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE }
.forEach {
// Assume pinned if unknown
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
if (pinned == NavPinType.PINNED) {
items.add(it)
} else {
moreItems.add(it)
}
}
_state.update {
it.copy(
items = items,
moreItems = moreItems,
)
}
}
}
data class NavDrawerItemState(
val items: List<NavDrawerItem>,
val moreItems: List<NavDrawerItem>,
val discoverEnabled: Boolean,
) {
companion object {
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
}
}
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true

View file

@ -28,21 +28,6 @@ class RefreshRateService
constructor(
@param:ApplicationContext private val context: Context,
) {
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
val supportedDisplayModes get() = display.supportedModes.orEmpty()
private val displayModes: List<DisplayMode> by lazy {
display.supportedModes
.orEmpty()
.map { DisplayMode(it) }
.sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
}
/**
* Find the best display mode for the given stream and signal to change to it
*/
@ -55,6 +40,18 @@ class RefreshRateService
Timber.v("Not switching either refresh rate nor resolution")
return@withContext
}
val displayManager =
MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
val displayModes =
display.supportedModes
.orEmpty()
.map { DisplayMode(it) }
.sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
val width = stream.width

View file

@ -44,18 +44,33 @@ class SeerrServerRepository
private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
private val _current = MutableStateFlow<CurrentSeerr?>(null)
val current: StateFlow<CurrentSeerr?> = _current
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
private val _connection =
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
val connection: StateFlow<SeerrConnectionStatus> = _connection
val current: Flow<CurrentSeerr?> =
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
val currentServer: Flow<SeerrServer?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
/**
* Whether Seerr integration is currently active of not
*/
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
val active: Flow<Boolean> =
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() {
_current.update { null }
_connection.update { SeerrConnectionStatus.NotConfigured }
seerrApi.update("", null)
}
fun error(
serverUrl: String,
exception: Exception,
) {
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
seerrApi.update("", null)
}
@ -65,8 +80,10 @@ class SeerrServerRepository
userConfig: SeerrUserConfig,
) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_current.update {
CurrentSeerr(server, user, userConfig, publicSettings)
_connection.update {
SeerrConnectionStatus.Success(
CurrentSeerr(server, user, userConfig, publicSettings),
)
}
}
@ -154,7 +171,7 @@ class SeerrServerRepository
}
suspend fun removeServer() {
val current = _current.value ?: return
val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
clear()
}
@ -165,6 +182,19 @@ class SeerrServerRepository
*/
typealias SeerrUserConfig = User
sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus
data class Error(
val serverUrl: String,
val ex: Exception,
) : SeerrConnectionStatus
data class Success(
val current: CurrentSeerr,
) : SeerrConnectionStatus
}
data class CurrentSeerr(
val server: SeerrServer,
val user: SeerrUser,
@ -226,6 +256,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 +264,47 @@ 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
}
} else {
try {
seerrApi.api.usersApi.authMeGet()
} 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)
.lastOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} else {
seerrApi.api.usersApi.authMeGet()
}
seerrServerRepository.set(server, seerrUser, userConfig)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server.url, ex)
}
seerrServerRepository.set(server, seerrUser, userConfig)
}
}
}
}
}
}
}

View file

@ -6,6 +6,7 @@ import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
@ -49,21 +50,8 @@ class SuggestionService
.asFlow()
.flatMapLatest { user ->
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
if (cachedIds.isNotEmpty()) {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedIds, itemKind),
),
)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} else {
val cachedSuggestions = cache.get(userId, parentId, itemKind)
if (cachedSuggestions == null) {
workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos ->
@ -73,6 +61,23 @@ class SuggestionService
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
} else if (cachedSuggestions.ids.isEmpty()) {
flowOf(SuggestionsResource.Empty)
} else {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedSuggestions.ids, itemKind),
),
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
}
}
}

View file

@ -60,15 +60,20 @@ class SuggestionsCache
): CachedSuggestions? {
val key = cacheKey(userId, libraryId, itemKind)
return memoryCache.getOrPut(key) {
try {
mutex.withLock {
File(cacheDir, "$key.json").inputStream().use {
mutex.withLock {
try {
val cacheFile = File(cacheDir, "$key.json")
if (!cacheFile.exists()) {
return@withLock null
}
cacheFile.inputStream().use {
json.decodeFromStream<CachedSuggestions>(it)
}
} catch (ex: Exception) {
Timber.e(ex, "Exception reading from disk cache")
null
}
} catch (ex: Exception) {
Timber.e(ex, "Exception reading from disk cache")
null
}
}
}

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

@ -44,6 +44,10 @@ annotation class StandardOkHttpClient
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@ -177,6 +181,11 @@ object AppModule {
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
@DefaultCoroutineScope
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun workManager(

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> =