Option to combine continue watching & next up rows (#192)

A real implementation for combining the `Continue Watching` and `Next
Up` rows on the home page.

It requires a query for every next up item, so it could become slow
especially for many items and larger servers. The app makes a best
effort to cache the information.

The order is determined by querying each episode in next up to associate
it to its immediately previous episode's last played date. Then both
continue watching & next up items are reversed sorted by the last played
data. So this only makes sense if you watch series in order.

Closes #19
This commit is contained in:
damontecres 2025-11-18 15:21:50 -05:00 committed by GitHub
parent 46415fcdd0
commit cda5b118a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 232 additions and 63 deletions

View file

@ -0,0 +1,124 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.google.common.cache.CacheBuilder
import com.google.common.cache.CacheLoader
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.runBlocking
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import timber.log.Timber
import java.time.LocalDateTime
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DatePlayedService
@Inject
constructor(
private val api: ApiClient,
) {
private val datePlayedCache =
CacheBuilder
.newBuilder()
.maximumSize(AppPreference.HomePageItems.max)
.expireAfterWrite(2, TimeUnit.HOURS)
.build<SeriesItemId, LocalDateTime?>(
object :
CacheLoader<SeriesItemId, LocalDateTime>() {
override fun load(key: SeriesItemId): LocalDateTime = getLastPlayed(key)
},
)
private fun getLastPlayed(key: SeriesItemId): LocalDateTime {
val request =
GetEpisodesRequest(
seriesId = key.seriesId,
adjacentTo = key.itemId,
limit = 1,
)
return try {
val result =
runBlocking {
GetEpisodesRequestHandler
.execute(
api,
request,
).content.items
}
result.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN
} catch (ex: InvalidStatusException) {
Timber.w("Error fetching %s: %s", key, ex.localizedMessage)
LocalDateTime.MIN
}
}
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? {
val seriesId = item.data.seriesId
return if (seriesId != null) {
datePlayedCache.get(SeriesItemId(seriesId, item.id))
} else {
null
}
}
fun invalidate(item: BaseItem) {
item.data.seriesId?.let { seriesId ->
Timber.d("Invalidating %s", seriesId)
datePlayedCache.asMap().keys.removeIf { it.seriesId == seriesId }
}
}
suspend fun invalidate(itemId: UUID) {
val seriesId =
api.userLibraryApi.getItem(itemId = itemId).content.let {
if (it.type == BaseItemKind.SERIES) {
itemId
} else {
it.seriesId
}
}
if (seriesId != null) {
Timber.d("Invalidating %s", seriesId)
datePlayedCache.asMap().keys.removeIf { it.seriesId == seriesId }
}
}
fun invalidateAll() {
Timber.d("invalidateAll")
datePlayedCache.invalidateAll()
}
}
@ActivityScoped
class DatePlayedInvalidationService
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val datePlayedService: DatePlayedService,
) {
private val activity = (context as AppCompatActivity)
init {
serverRepository.current.observe(activity) {
datePlayedService.invalidateAll()
}
}
}
private data class SeriesItemId(
val seriesId: UUID,
val itemId: UUID,
)

View file

@ -5,39 +5,35 @@ import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.UserItemDataDto
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
interface FavoriteWatchManager {
suspend fun setWatched(
itemId: UUID,
played: Boolean,
): UserItemDataDto
suspend fun setFavorite(
itemId: UUID,
favorite: Boolean,
): UserItemDataDto
}
class FavoriteWatchManagerImpl(
private val api: ApiClient,
) : FavoriteWatchManager {
override suspend fun setWatched(
itemId: UUID,
played: Boolean,
): UserItemDataDto =
if (played) {
api.playStateApi.markPlayedItem(itemId).content
} else {
api.playStateApi.markUnplayedItem(itemId).content
@Singleton
class FavoriteWatchManager
@Inject
constructor(
private val api: ApiClient,
private val datePlayedService: DatePlayedService,
) {
suspend fun setWatched(
itemId: UUID,
played: Boolean,
): UserItemDataDto {
datePlayedService.invalidate(itemId)
return if (played) {
api.playStateApi.markPlayedItem(itemId).content
} else {
api.playStateApi.markUnplayedItem(itemId).content
}
}
override suspend fun setFavorite(
itemId: UUID,
favorite: Boolean,
): UserItemDataDto =
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId).content
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content
}
}
suspend fun setFavorite(
itemId: UUID,
favorite: Boolean,
): UserItemDataDto =
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId).content
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content
}
}

View file

@ -10,8 +10,6 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.FavoriteWatchManagerImpl
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module
@ -25,7 +23,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
@ -185,8 +182,4 @@ object AppModule {
@Singleton
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun favoriteWatchManager(api: ApiClient): FavoriteWatchManager = FavoriteWatchManagerImpl(api)
}