Add resume/continue watching items to Android TV watch next row (#372)

## Details

Adds resume and continue watching items for the logged in user to the
Android TV OS level watch next "channel"
(https://developer.android.com/training/tv/discovery/watch-next-add-programs).

## Issues
Closes #206
Related to #581
This commit is contained in:
Ray 2025-12-27 15:40:45 -05:00 committed by GitHub
parent 639ce0de71
commit 9ae4965c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 645 additions and 189 deletions

View file

@ -0,0 +1,190 @@
package com.github.damontecres.wholphin.services
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
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.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber
import java.time.LocalDateTime
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@Singleton
class LatestNextUpService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val datePlayedService: DatePlayedService,
) {
suspend fun getResume(
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
userId = userId,
fields = SlimItemFields,
limit = limit,
includeItemTypes =
if (includeEpisodes) {
supportItemKinds
} else {
supportItemKinds
.toMutableSet()
.apply {
remove(BaseItemKind.EPISODE)
}
},
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.from(it, api, true) }
return items
}
suspend fun getNextUp(
userId: UUID,
limit: Int,
enableRewatching: Boolean,
enableResumable: Boolean,
): List<BaseItem> {
val request =
GetNextUpRequest(
userId = userId,
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,
enableResumable = enableResumable,
enableUserData = true,
enableRewatching = enableRewatching,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { BaseItem.from(it, api, true) }
return nextUp
}
suspend fun getLatest(
user: UserDto,
limit: Int,
includedIds: List<UUID>,
): List<LatestData> {
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
val views by api.userViewsApi.getUserViews()
val latestData =
views.items
.filter {
it.id in includedIds && it.id !in excluded &&
it.collectionType in supportedLatestCollectionTypes
}.map { view ->
val title =
view.name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = view.id,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
LatestData(title, request)
}
return latestData
}
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
val rows =
latestData.mapNotNull { (title, request) ->
try {
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api, true) }
if (latest.isNotEmpty()) {
HomeRowLoadingState.Success(
title = title,
items = latest,
)
} else {
null
}
} catch (ex: Exception) {
Timber.e(ex, "Exception fetching %s", title)
HomeRowLoadingState.Error(
title = title,
exception = ex,
)
}
}
return rows
}
suspend fun buildCombined(
resume: List<BaseItem>,
nextUp: List<BaseItem>,
): List<BaseItem> =
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
val semaphore = Semaphore(3)
val deferred =
nextUp
.filter { it.data.seriesId != null }
.map { item ->
async(Dispatchers.IO) {
try {
semaphore.withPermit {
datePlayedService.getLastPlayed(item)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", item.id)
null
}
}
}
val nextUpLastPlayed = deferred.awaitAll()
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
val duration = (System.currentTimeMillis() - start).milliseconds
Timber.v("buildCombined took %s", duration)
return@withContext result
}
}

View file

@ -68,9 +68,20 @@ class NavigationManager
log()
}
/**
* Resets the backstack to the specified destination
*/
fun replace(destination: Destination) {
while (backStack.size > 1) {
backStack.removeLastOrNull()
}
backStack[0] = destination
log()
}
private fun log() {
val dest = backStack.lastOrNull().toString()
Timber.Forest.i("Current Destination: %s", dest)
Timber.i("Current Destination: %s", dest)
ACRA.errorReporter.putCustomData("destination", dest)
}
}

View file

@ -0,0 +1,89 @@
package com.github.damontecres.wholphin.services.tvprovider
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.toJavaDuration
@ActivityScoped
class TvProviderSchedulerService
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
) {
private val activity = (context as AppCompatActivity)
private val workManager = WorkManager.getInstance(context)
private val supportsTvProvider =
// TODO <=25 has limited support
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
init {
serverRepository.current.observe(activity) { user ->
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
if (supportsTvProvider) {
if (user != null) {
activity.lifecycleScope.launchIO(ExceptionHandler()) {
Timber.i("Scheduling TvProviderWorker for ${user.user}")
workManager
.enqueueUniquePeriodicWork(
uniqueWorkName = TvProviderWorker.WORK_NAME,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
request =
PeriodicWorkRequestBuilder<TvProviderWorker>(
repeatInterval = 1.hours.toJavaDuration(),
).setBackoffCriteria(
BackoffPolicy.LINEAR,
15.minutes.toJavaDuration(),
).setInputData(
workDataOf(
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
),
).build(),
).await()
}
}
}
}
}
fun launchOneTimeRefresh() {
if (supportsTvProvider) {
activity.lifecycleScope.launchIO(ExceptionHandler()) {
serverRepository.current.value?.let { user ->
Timber.i("Scheduling on-time TvProviderWorker for ${user.user}")
workManager.enqueue(
OneTimeWorkRequestBuilder<TvProviderWorker>()
.setInputData(
workDataOf(
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
),
).build(),
)
}
}
}
}
}

View file

@ -0,0 +1,245 @@
package com.github.damontecres.wholphin.services.tvprovider
import android.content.Context
import android.content.Intent
import android.database.Cursor
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
import androidx.tvprovider.media.tv.WatchNextProgram
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.time.ZoneId
import java.util.Date
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
@HiltWorker
class TvProviderWorker
@AssistedInject
constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val serverRepository: ServerRepository,
private val preferences: DataStore<AppPreferences>,
private val api: ApiClient,
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Timber.d("Start")
val serverId =
inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure()
val userId =
inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure()
if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) {
// Not active
var currentUser = serverRepository.current.value
if (currentUser == null) {
serverRepository.restoreSession(serverId, userId)
currentUser = serverRepository.current.value
}
if (currentUser == null) {
Timber.w("No user found during run")
return Result.failure()
}
}
try {
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val potentialItemsToAdd =
getPotentialItems(
userId,
prefs.homePagePreferences.enableRewatchingNextUp,
prefs.homePagePreferences.combineContinueNext,
)
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds)
val currentItems = getCurrentTvChannelNextUp()
val currentItemIds = currentItems.map { it.internalProviderId }
val toRemove =
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
val userRemoved = currentItems.filterNot { it.isBrowsable }
val userRemovedIds = userRemoved.map { it.internalProviderId }
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
val toAdd =
potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds }
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
// Remove existing items if they are no longer in the next up from server
(toRemove + userRemoved)
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
.forEach {
context.contentResolver.delete(it, null, null)
}
// Add new ones
val addedCount =
context.contentResolver.bulkInsert(
WatchNextPrograms.CONTENT_URI,
toAdd
.map { convert(it).toContentValues() }
.toTypedArray(),
)
Timber.v("Added %s", addedCount)
Timber.d("Completed successfully")
} catch (_: ApiClientException) {
return Result.retry()
} catch (_: Exception) {
return Result.failure()
}
return Result.success()
}
private suspend fun getPotentialItems(
userId: UUID,
enableRewatching: Boolean,
combineContinueNext: Boolean,
): List<BaseItem> {
val resumeItems = latestNextUpService.getResume(userId, 10, true)
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
val nextUpItems =
latestNextUpService
.getNextUp(userId, 10, enableRewatching, false)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
return if (combineContinueNext) {
latestNextUpService.buildCombined(resumeItems, nextUpItems)
} else {
resumeItems + nextUpItems
}
}
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =
context.contentResolver
.query(
WatchNextPrograms.CONTENT_URI,
WatchNextProgram.PROJECTION,
null,
null,
null,
)?.map(WatchNextProgram::fromCursor)
.orEmpty()
private fun convert(item: BaseItem): WatchNextProgram =
WatchNextProgram
.Builder()
.apply {
val dto = item.data
setInternalProviderId(item.id.toString())
val type =
when (item.type) {
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
else -> WatchNextPrograms.TYPE_CLIP
}
setType(type)
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
if (resumePosition != null && resumePosition >= 2.minutes) {
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
} else {
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
}
dto.runTimeTicks
?.ticks
?.inWholeMilliseconds
?.toInt()
?.let(::setDurationMillis)
setLastEngagementTimeUtcMillis(
dto.userData
?.lastPlayedDate
?.atZone(ZoneId.systemDefault())
?.toEpochSecond()
?: Date().time, // TODO
)
setTitle(item.title)
setDescription(dto.overview)
if (item.type == BaseItemKind.EPISODE) {
setEpisodeTitle(item.name)
dto.indexNumber?.let(::setEpisodeNumber)
dto.parentIndexNumber?.let(::setSeasonNumber)
}
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
val imageType =
when (item.type) {
BaseItemKind.EPISODE -> ImageType.THUMB
else -> ImageType.PRIMARY
}
setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri())
setIntent(
Intent(context, MainActivity::class.java)
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
MainActivity.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
MainActivity.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
MainActivity.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
MainActivity.INTENT_EPISODE_NUMBER,
it,
)
}
}
},
)
}.build()
companion object {
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId"
}
}
fun <T> Cursor.map(transform: (Cursor) -> T): List<T> =
this.use {
buildList {
if (moveToFirst()) {
do {
add(transform.invoke(this@map))
} while (moveToNext())
}
}
}