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:
2026-06-05 14:57:20 +00:00
parent 448550b5ab
commit 85c53f8704
8 changed files with 214 additions and 6 deletions

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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) {

View File

@@ -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

View File

@@ -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()) {

View File

@@ -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
}
}

View File

@@ -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