mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat(playback): sync offline positions with Jellyfin
Add a home refresh side effect that compares offline progress with Jellyfin user data and syncs the furthest playback position. Use Jellyfin playbackPositionTicks for server updates while keeping percent-based writes for local offline and in-memory repositories.
This commit is contained in:
@@ -72,6 +72,11 @@ class CompositeLocalMediaRepository @Inject constructor(
|
|||||||
offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
|
||||||
|
onlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
|
||||||
|
offlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
||||||
val repository = onlineRepository
|
val repository = onlineRepository
|
||||||
repository.markAsWatched(mediaId, watched)
|
repository.markAsWatched(mediaId, watched)
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ import java.util.UUID
|
|||||||
|
|
||||||
interface LocalMediaUpdater {
|
interface LocalMediaUpdater {
|
||||||
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
|
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
|
||||||
|
suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double)
|
||||||
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
|
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,12 +167,18 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||||
if (durationMs <= 0) return
|
if (durationMs <= 0) return
|
||||||
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
|
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
|
||||||
val watched = progressPercent >= 90.0
|
updateWatchProgressPercent(mediaId, progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
|
||||||
|
if (progressPercent.isNaN()) return
|
||||||
|
val normalizedProgressPercent = progressPercent.coerceIn(0.0, 100.0)
|
||||||
|
val watched = normalizedProgressPercent >= 90.0
|
||||||
|
|
||||||
if (moviesState.value.containsKey(mediaId)) {
|
if (moviesState.value.containsKey(mediaId)) {
|
||||||
moviesState.update { current ->
|
moviesState.update { current ->
|
||||||
val movie = current[mediaId] ?: return@update current
|
val movie = current[mediaId] ?: return@update current
|
||||||
current + (mediaId to movie.copy(progress = progressPercent, watched = watched))
|
current + (mediaId to movie.copy(progress = normalizedProgressPercent, watched = watched))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -180,7 +186,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
var updatedEpisode: Episode? = null
|
var updatedEpisode: Episode? = null
|
||||||
episodesState.update { current ->
|
episodesState.update { current ->
|
||||||
val episode = current[mediaId] ?: return@update current
|
val episode = current[mediaId] ?: return@update current
|
||||||
val updated = episode.copy(progress = progressPercent, watched = watched)
|
val updated = episode.copy(progress = normalizedProgressPercent, watched = watched)
|
||||||
updatedEpisode = updated
|
updatedEpisode = updated
|
||||||
current + (mediaId to updated)
|
current + (mediaId to updated)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,14 @@ class OfflineLocalMediaRepository @Inject constructor(
|
|||||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||||
if (durationMs <= 0) return
|
if (durationMs <= 0) return
|
||||||
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
|
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
|
||||||
val watched = progressPercent >= 90.0
|
updateWatchProgressPercent(mediaId, progressPercent)
|
||||||
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
|
}
|
||||||
|
|
||||||
|
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
|
||||||
|
if (progressPercent.isNaN()) return
|
||||||
|
val normalizedProgressPercent = progressPercent.coerceIn(0.0, 100.0)
|
||||||
|
val watched = normalizedProgressPercent >= 90.0
|
||||||
|
localDataSource.updateWatchProgress(mediaId, normalizedProgressPercent, watched)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import dagger.Binds
|
|||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.IntoSet
|
||||||
import hu.bbara.purefin.core.data.AuthenticationRepository
|
import hu.bbara.purefin.core.data.AuthenticationRepository
|
||||||
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
||||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||||
import hu.bbara.purefin.core.data.PlayableMediaRepository
|
import hu.bbara.purefin.core.data.PlayableMediaRepository
|
||||||
import hu.bbara.purefin.core.data.PlaybackProgressReporter
|
import hu.bbara.purefin.core.data.PlaybackProgressReporter
|
||||||
import hu.bbara.purefin.core.data.SessionBootstrapper
|
import hu.bbara.purefin.core.data.SessionBootstrapper
|
||||||
|
import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshSideEffect
|
||||||
import hu.bbara.purefin.data.jellyfin.download.JellyfinDownloadMediaSourceResolver
|
import hu.bbara.purefin.data.jellyfin.download.JellyfinDownloadMediaSourceResolver
|
||||||
|
import hu.bbara.purefin.data.jellyfin.playback.SyncPlaybackPositionsHomeRefreshSideEffect
|
||||||
import hu.bbara.purefin.data.jellyfin.session.JellyfinAuthenticationRepository
|
import hu.bbara.purefin.data.jellyfin.session.JellyfinAuthenticationRepository
|
||||||
import hu.bbara.purefin.data.jellyfin.session.JellyfinSessionBootstrapper
|
import hu.bbara.purefin.data.jellyfin.session.JellyfinSessionBootstrapper
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -37,6 +40,12 @@ abstract class JellyfinBindingsModule {
|
|||||||
@Binds
|
@Binds
|
||||||
abstract fun bindPlaybackProgressReporter(impl: JellyfinPlaybackProgressReporter): PlaybackProgressReporter
|
abstract fun bindPlaybackProgressReporter(impl: JellyfinPlaybackProgressReporter): PlaybackProgressReporter
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@IntoSet
|
||||||
|
abstract fun bindSyncPlaybackPositionsHomeRefreshSideEffect(
|
||||||
|
impl: SyncPlaybackPositionsHomeRefreshSideEffect
|
||||||
|
): HomeRefreshSideEffect
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor
|
abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
|||||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||||
import org.jellyfin.sdk.model.api.RepeatMode
|
import org.jellyfin.sdk.model.api.RepeatMode
|
||||||
|
import org.jellyfin.sdk.model.api.UpdateUserItemDataDto
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
@@ -384,6 +385,29 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updatePlaybackPosition(
|
||||||
|
mediaId: UUID,
|
||||||
|
playbackPositionTicks: Long,
|
||||||
|
runtimeTicks: Long,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
if (runtimeTicks <= 0L) return@withContext
|
||||||
|
val normalizedPlaybackPositionTicks = playbackPositionTicks.coerceIn(0L, runtimeTicks)
|
||||||
|
logRequest("updatePlaybackPosition") {
|
||||||
|
if (!ensureConfigured()) {
|
||||||
|
return@logRequest
|
||||||
|
}
|
||||||
|
val result = api.itemsApi.updateItemUserData(
|
||||||
|
itemId = mediaId,
|
||||||
|
userId = getUserId(),
|
||||||
|
data = UpdateUserItemDataDto(
|
||||||
|
playbackPositionTicks = normalizedPlaybackPositionTicks,
|
||||||
|
played = normalizedPlaybackPositionTicks.toDouble() / runtimeTicks.toDouble() >= 0.9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun markAsWatched(mediaId: UUID) = withContext(Dispatchers.IO) {
|
suspend fun markAsWatched(mediaId: UUID) = withContext(Dispatchers.IO) {
|
||||||
logRequest("markAsWatched") {
|
logRequest("markAsWatched") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package hu.bbara.purefin.data.jellyfin.playback
|
||||||
|
|
||||||
|
import hu.bbara.purefin.core.Offline
|
||||||
|
import hu.bbara.purefin.core.Online
|
||||||
|
import hu.bbara.purefin.core.data.LocalMediaRepository
|
||||||
|
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||||
|
import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshSideEffect
|
||||||
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
|
import hu.bbara.purefin.model.Episode
|
||||||
|
import hu.bbara.purefin.model.Movie
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
|
||||||
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
@param:Offline private val offlineRepository: LocalMediaRepository,
|
||||||
|
@param:Online private val onlineRepository: LocalMediaRepository,
|
||||||
|
) : HomeRefreshSideEffect {
|
||||||
|
|
||||||
|
override suspend fun run() {
|
||||||
|
if (!networkMonitor.isOnline.first()) return
|
||||||
|
|
||||||
|
val localPlaybackPositions = buildList {
|
||||||
|
addAll(offlineRepository.movies.first().values.map { it.toLocalPlaybackPosition() })
|
||||||
|
addAll(offlineRepository.episodes.first().values.map { it.toLocalPlaybackPosition() })
|
||||||
|
}
|
||||||
|
|
||||||
|
localPlaybackPositions.forEach { localPlaybackPosition ->
|
||||||
|
syncPlaybackPosition(localPlaybackPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncPlaybackPosition(localPlaybackPosition: LocalPlaybackPosition) {
|
||||||
|
try {
|
||||||
|
val remoteItem = jellyfinApiClient.getItemInfo(localPlaybackPosition.mediaId) ?: return
|
||||||
|
val remotePlaybackPosition = remoteItem.remotePlaybackPosition() ?: return
|
||||||
|
val localPlaybackPositionTicks = localPlaybackPosition.toPlaybackPositionTicks(
|
||||||
|
runtimeTicks = remotePlaybackPosition.runtimeTicks,
|
||||||
|
)
|
||||||
|
val tickTolerance = remotePlaybackPosition.runtimeTicks.progressToleranceTicks()
|
||||||
|
|
||||||
|
// Offline Room stores progress percent without last-updated metadata, so sync the
|
||||||
|
// furthest known playback position as the best available source of truth.
|
||||||
|
when {
|
||||||
|
localPlaybackPositionTicks > remotePlaybackPosition.playbackPositionTicks + tickTolerance -> {
|
||||||
|
jellyfinApiClient.updatePlaybackPosition(
|
||||||
|
mediaId = localPlaybackPosition.mediaId,
|
||||||
|
playbackPositionTicks = localPlaybackPositionTicks,
|
||||||
|
runtimeTicks = remotePlaybackPosition.runtimeTicks,
|
||||||
|
)
|
||||||
|
onlineRepository.updateWatchProgressPercent(
|
||||||
|
mediaId = localPlaybackPosition.mediaId,
|
||||||
|
progressPercent = localPlaybackPositionTicks.toProgressPercent(
|
||||||
|
runtimeTicks = remotePlaybackPosition.runtimeTicks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
remotePlaybackPosition.playbackPositionTicks > localPlaybackPositionTicks + tickTolerance -> {
|
||||||
|
val remoteProgressPercent = remotePlaybackPosition.playbackPositionTicks.toProgressPercent(
|
||||||
|
runtimeTicks = remotePlaybackPosition.runtimeTicks,
|
||||||
|
)
|
||||||
|
offlineRepository.updateWatchProgressPercent(
|
||||||
|
mediaId = localPlaybackPosition.mediaId,
|
||||||
|
progressPercent = remoteProgressPercent,
|
||||||
|
)
|
||||||
|
onlineRepository.updateWatchProgressPercent(
|
||||||
|
mediaId = localPlaybackPosition.mediaId,
|
||||||
|
progressPercent = remoteProgressPercent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.tag(TAG).w(error, "Unable to sync playback position for ${localPlaybackPosition.mediaId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Movie.toLocalPlaybackPosition(): LocalPlaybackPosition =
|
||||||
|
LocalPlaybackPosition(
|
||||||
|
mediaId = id,
|
||||||
|
progressPercent = progress.toLocalProgressPercent(watched),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Episode.toLocalPlaybackPosition(): LocalPlaybackPosition =
|
||||||
|
LocalPlaybackPosition(
|
||||||
|
mediaId = id,
|
||||||
|
progressPercent = progress.toLocalProgressPercent(watched),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Double?.toLocalProgressPercent(watched: Boolean): Double {
|
||||||
|
if (watched) return 100.0
|
||||||
|
return this?.normalizedProgressPercent() ?: 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BaseItemDto.remotePlaybackPosition(): RemotePlaybackPosition? {
|
||||||
|
val runtimeTicks = runTimeTicks?.takeIf { it > 0L } ?: return null
|
||||||
|
val userData = userData ?: return null
|
||||||
|
val userPlaybackPositionTicks = userData.playbackPositionTicks
|
||||||
|
val playbackPositionTicks = when {
|
||||||
|
userData.played -> runtimeTicks
|
||||||
|
userPlaybackPositionTicks != null -> userPlaybackPositionTicks
|
||||||
|
else -> userData.playedPercentage?.toPlaybackPositionTicks(runtimeTicks) ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
return RemotePlaybackPosition(
|
||||||
|
playbackPositionTicks = playbackPositionTicks.coerceIn(0L, runtimeTicks),
|
||||||
|
runtimeTicks = runtimeTicks,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalPlaybackPosition.toPlaybackPositionTicks(runtimeTicks: Long): Long {
|
||||||
|
return progressPercent.toPlaybackPositionTicks(runtimeTicks)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Double.normalizedProgressPercent(): Double? {
|
||||||
|
if (isNaN()) return null
|
||||||
|
return coerceIn(0.0, 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Double.toPlaybackPositionTicks(runtimeTicks: Long): Long {
|
||||||
|
val progressPercent = normalizedProgressPercent() ?: 0.0
|
||||||
|
return ((progressPercent / 100.0) * runtimeTicks.toDouble())
|
||||||
|
.roundToLong()
|
||||||
|
.coerceIn(0L, runtimeTicks)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.toProgressPercent(runtimeTicks: Long): Double {
|
||||||
|
if (runtimeTicks <= 0L) return 0.0
|
||||||
|
return ((toDouble() / runtimeTicks.toDouble()) * 100.0).coerceIn(0.0, 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.progressToleranceTicks(): Long {
|
||||||
|
return ((toDouble() * PROGRESS_TOLERANCE_PERCENT) / 100.0).roundToLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class LocalPlaybackPosition(
|
||||||
|
val mediaId: UUID,
|
||||||
|
val progressPercent: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class RemotePlaybackPosition(
|
||||||
|
val playbackPositionTicks: Long,
|
||||||
|
val runtimeTicks: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "PlaybackPositionSync"
|
||||||
|
const val PROGRESS_TOLERANCE_PERCENT = 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,5 +29,5 @@ android.r8.strictFullModeForKeepRules=false
|
|||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
purefinReleaseVersionCode=1000022
|
purefinReleaseVersionCode=1000022
|
||||||
purefinDebugVersionCode=1000001
|
purefinDebugVersionCode=1000003
|
||||||
purefinTvVersionCode=2000012
|
purefinTvVersionCode=2000012
|
||||||
|
|||||||
Reference in New Issue
Block a user