mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
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:
parent
639ce0de71
commit
9ae4965c2a
12 changed files with 645 additions and 189 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue