From 85c53f87045e2165107ae08a11482ccae5e2a188 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Fri, 5 Jun 2026 14:57:20 +0000 Subject: [PATCH] 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. --- .../data/CompositeLocalMediaRepository.kt | 5 + .../purefin/core/data/LocalMediaUpdater.kt | 1 + .../catalog/InMemoryLocalMediaRepository.kt | 12 +- .../catalog/OfflineLocalMediaRepository.kt | 10 +- .../data/jellyfin/JellyfinBindingsModule.kt | 9 + .../data/jellyfin/client/JellyfinApiClient.kt | 24 +++ ...cPlaybackPositionsHomeRefreshSideEffect.kt | 157 ++++++++++++++++++ gradle.properties | 2 +- 8 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SyncPlaybackPositionsHomeRefreshSideEffect.kt diff --git a/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt b/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt index 5f45b136..56cfe788 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt @@ -72,6 +72,11 @@ class CompositeLocalMediaRepository @Inject constructor( 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) { val repository = onlineRepository repository.markAsWatched(mediaId, watched) diff --git a/core/src/main/java/hu/bbara/purefin/core/data/LocalMediaUpdater.kt b/core/src/main/java/hu/bbara/purefin/core/data/LocalMediaUpdater.kt index 2258a174..d9fd3417 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/LocalMediaUpdater.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/LocalMediaUpdater.kt @@ -4,5 +4,6 @@ import java.util.UUID interface LocalMediaUpdater { suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) + suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) suspend fun markAsWatched(mediaId: UUID, watched: Boolean) } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt index c5745e08..155b0e06 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt @@ -167,12 +167,18 @@ class InMemoryLocalMediaRepository @Inject constructor( override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { if (durationMs <= 0) return 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)) { moviesState.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 } @@ -180,7 +186,7 @@ class InMemoryLocalMediaRepository @Inject constructor( var updatedEpisode: Episode? = null episodesState.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 current + (mediaId to updated) } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineLocalMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineLocalMediaRepository.kt index 50357987..8420a502 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineLocalMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineLocalMediaRepository.kt @@ -55,8 +55,14 @@ class OfflineLocalMediaRepository @Inject constructor( override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { if (durationMs <= 0) return val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0 - val watched = progressPercent >= 90.0 - localDataSource.updateWatchProgress(mediaId, progressPercent, watched) + 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 + localDataSource.updateWatchProgress(mediaId, normalizedProgressPercent, watched) } override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) { diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinBindingsModule.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinBindingsModule.kt index d6375ecd..6711f8f8 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinBindingsModule.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinBindingsModule.kt @@ -4,13 +4,16 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet import hu.bbara.purefin.core.data.AuthenticationRepository import hu.bbara.purefin.core.data.DownloadMediaSourceResolver import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.PlayableMediaRepository import hu.bbara.purefin.core.data.PlaybackProgressReporter 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.playback.SyncPlaybackPositionsHomeRefreshSideEffect import hu.bbara.purefin.data.jellyfin.session.JellyfinAuthenticationRepository import hu.bbara.purefin.data.jellyfin.session.JellyfinSessionBootstrapper import javax.inject.Singleton @@ -37,6 +40,12 @@ abstract class JellyfinBindingsModule { @Binds abstract fun bindPlaybackProgressReporter(impl: JellyfinPlaybackProgressReporter): PlaybackProgressReporter + @Binds + @IntoSet + abstract fun bindSyncPlaybackPositionsHomeRefreshSideEffect( + impl: SyncPlaybackPositionsHomeRefreshSideEffect + ): HomeRefreshSideEffect + @Binds @Singleton abstract fun bindNetworkMonitor(impl: ConnectivityNetworkMonitor): NetworkMonitor diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index 72a9e978..8ddae710 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -52,6 +52,7 @@ import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo import org.jellyfin.sdk.model.api.PlaybackStopInfo 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.GetNextUpRequest 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) { logRequest("markAsWatched") { if (!ensureConfigured()) { diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SyncPlaybackPositionsHomeRefreshSideEffect.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SyncPlaybackPositionsHomeRefreshSideEffect.kt new file mode 100644 index 00000000..41af9c08 --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SyncPlaybackPositionsHomeRefreshSideEffect.kt @@ -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 + } +} diff --git a/gradle.properties b/gradle.properties index 705a2c56..91687d0e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,5 +29,5 @@ android.r8.strictFullModeForKeepRules=false android.builtInKotlin=false android.newDsl=false purefinReleaseVersionCode=1000022 -purefinDebugVersionCode=1000001 +purefinDebugVersionCode=1000003 purefinTvVersionCode=2000012