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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user