diff --git a/core/src/main/java/hu/bbara/purefin/core/data/MediaMetadataUpdater.kt b/core/src/main/java/hu/bbara/purefin/core/data/MediaMetadataUpdater.kt new file mode 100644 index 00000000..d45ea72a --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/core/data/MediaMetadataUpdater.kt @@ -0,0 +1,39 @@ +package hu.bbara.purefin.core.data + +import java.util.UUID + +/** + * Unified interface for updating media metadata (watched status, watch progress, + * and server-side playback position). + * + * Implementations coordinate both local cache updates and remote server calls + * so that callers have a single point of entry for all metadata mutations. + */ +interface MediaMetadataUpdater { + /** + * Mark the given media item as watched or unwatched. + * Updates both the local cache and the remote server. + */ + suspend fun markAsWatched(mediaId: UUID, watched: Boolean) + + /** + * Compute progress from absolute position/duration and persist it + * to the local cache (in-memory and/or Room). + */ + suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) + + /** + * Persist a raw progress percentage (0.0 – 100.0) to the local cache. + */ + suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) + + /** + * Update the server-side playback position (in ticks) and optionally + * mark the item as played when past the threshold (≥ 80 %). + */ + suspend fun updatePlaybackPosition( + mediaId: UUID, + playbackPositionTicks: Long, + runtimeTicks: Long, + ) +} diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt index dfc35d21..0ddcdd40 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt @@ -8,7 +8,7 @@ import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshCoordinator -import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater +import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.settings.SettingsRepository import hu.bbara.purefin.core.model.EpisodeUiModel import hu.bbara.purefin.core.model.LibraryUiModel @@ -39,7 +39,7 @@ class AppViewModel @Inject constructor( private val homeRepository: HomeRepository, private val localMediaRepository: LocalMediaRepository, private val userSessionRepository: UserSessionRepository, - private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, + private val mediaMetadataUpdater: MediaMetadataUpdater, private val navigationManager: NavigationManager, private val homeRefreshCoordinator: HomeRefreshCoordinator, private val settingsRepository: SettingsRepository, @@ -185,7 +185,7 @@ class AppViewModel @Inject constructor( fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) { viewModelScope.launch { - jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched) + mediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched) } } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/browse/library/LibraryViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/browse/library/LibraryViewModel.kt index da7c2b4c..84de0796 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/browse/library/LibraryViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/browse/library/LibraryViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.core.data.HomeRepository -import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater +import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.model.MediaUiModel import hu.bbara.purefin.core.model.MovieUiModel import hu.bbara.purefin.core.model.SeriesUiModel @@ -26,7 +26,7 @@ import javax.inject.Inject class LibraryViewModel @Inject constructor( private val homeRepository: HomeRepository, private val navigationManager: NavigationManager, - private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, + private val mediaMetadataUpdater: MediaMetadataUpdater, ) : ViewModel() { private val selectedLibrary = MutableStateFlow(null) @@ -73,7 +73,7 @@ class LibraryViewModel @Inject constructor( fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) { viewModelScope.launch { - jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched) + mediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched) } } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/content/episode/EpisodeScreenViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/content/episode/EpisodeScreenViewModel.kt index 41b764a5..1cada4ca 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/content/episode/EpisodeScreenViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/content/episode/EpisodeScreenViewModel.kt @@ -7,7 +7,7 @@ import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.MediaDownloadController -import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater +import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.Route @@ -31,7 +31,7 @@ class EpisodeScreenViewModel @Inject constructor( @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, private val navigationManager: NavigationManager, private val mediaDownloadManager: MediaDownloadController, - private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, + private val mediaMetadataUpdater: MediaMetadataUpdater, ): ViewModel() { private val _episode = MutableStateFlow(null) @@ -86,7 +86,7 @@ class EpisodeScreenViewModel @Inject constructor( fun markAsWatched(watched: Boolean) { val episodeId = _episode.value?.id ?: return viewModelScope.launch { - jellyfinMediaMetadataUpdater.markAsWatched(episodeId, watched) + mediaMetadataUpdater.markAsWatched(episodeId, watched) } } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/content/movie/MovieScreenViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/content/movie/MovieScreenViewModel.kt index 67bc9abb..ad5f9b3b 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/content/movie/MovieScreenViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/content/movie/MovieScreenViewModel.kt @@ -7,7 +7,7 @@ import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.MediaDownloadController -import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater +import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.navigation.MovieDto import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.Route @@ -29,7 +29,7 @@ class MovieScreenViewModel @Inject constructor( @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, private val navigationManager: NavigationManager, private val mediaDownloadManager: MediaDownloadController, - private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, + private val mediaMetadataUpdater: MediaMetadataUpdater, ): ViewModel() { private val _movie = MutableStateFlow(null) @@ -73,7 +73,7 @@ class MovieScreenViewModel @Inject constructor( fun markAsWatched(watched: Boolean) { val movieId = movie.value?.id ?: return viewModelScope.launch { - jellyfinMediaMetadataUpdater.markAsWatched(movieId, watched) + mediaMetadataUpdater.markAsWatched(movieId, watched) } } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/content/series/SeriesViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/content/series/SeriesViewModel.kt index 2c378d26..8d38059e 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/content/series/SeriesViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/content/series/SeriesViewModel.kt @@ -7,7 +7,7 @@ import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.MediaDownloadController -import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater +import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.Route @@ -34,7 +34,7 @@ class SeriesViewModel @Inject constructor( @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, private val navigationManager: NavigationManager, private val mediaDownloadManager: MediaDownloadController, - private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, + private val mediaMetadataUpdater: MediaMetadataUpdater, ) : ViewModel() { private val _series = MutableStateFlow(null) @@ -188,7 +188,7 @@ class SeriesViewModel @Inject constructor( fun markAsWatched(watched: Boolean) { val seriesId = _series.value?.id ?: return viewModelScope.launch { - jellyfinMediaMetadataUpdater.markAsWatched(seriesId, watched) + mediaMetadataUpdater.markAsWatched(seriesId, watched) } } diff --git a/core/src/main/java/hu/bbara/purefin/core/jellyfin/JellyfinMediaMetadataUpdater.kt b/core/src/main/java/hu/bbara/purefin/core/jellyfin/JellyfinMediaMetadataUpdater.kt deleted file mode 100644 index c2980541..00000000 --- a/core/src/main/java/hu/bbara/purefin/core/jellyfin/JellyfinMediaMetadataUpdater.kt +++ /dev/null @@ -1,7 +0,0 @@ -package hu.bbara.purefin.core.jellyfin - -import java.util.UUID - -interface JellyfinMediaMetadataUpdater { - suspend fun markAsWatched(mediaId: UUID, watched: Boolean) -} \ No newline at end of file diff --git a/core/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt b/core/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt index 2c6b398d..246ee710 100644 --- a/core/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt +++ b/core/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt @@ -1,7 +1,7 @@ package hu.bbara.purefin.core.player.manager import dagger.hilt.android.scopes.ViewModelScoped -import hu.bbara.purefin.core.data.LocalMediaUpdater +import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.data.PlaybackProgressReporter import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.player.model.MetadataState @@ -24,7 +24,7 @@ import javax.inject.Inject @ViewModelScoped class ProgressManager @Inject constructor( private val playbackProgressReporter: PlaybackProgressReporter, - private val localMediaUpdater: LocalMediaUpdater, + private val mediaMetadataUpdater: MediaMetadataUpdater, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var progressJob: Job? = null @@ -80,7 +80,7 @@ class ProgressManager @Inject constructor( report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true) scope.launch(Dispatchers.IO) { try { - localMediaUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs) + mediaMetadataUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs) } catch (e: Exception) { Timber.tag(TAG).e(e, "Local cache update failed") } @@ -127,7 +127,7 @@ class ProgressManager @Inject constructor( activePlaybackReportContext?.let { reportContext -> playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext) } - localMediaUpdater.updateWatchProgress(itemId, posMs, durMs) + mediaMetadataUpdater.updateWatchProgress(itemId, posMs, durMs) Timber.tag(TAG).d("Stop: $itemId at ${posMs}ms") } catch (e: Exception) { Timber.tag(TAG).e(e, "Report failed") diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinMediaMetadataUpdaterImpl.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinMediaMetadataUpdaterImpl.kt index 9635579a..ab4ba9be 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinMediaMetadataUpdaterImpl.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/JellyfinMediaMetadataUpdaterImpl.kt @@ -1,13 +1,17 @@ package hu.bbara.purefin.data.jellyfin +import hu.bbara.purefin.core.data.LocalMediaRepository +import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient -import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater import java.util.UUID import javax.inject.Inject +import javax.inject.Singleton +@Singleton class JellyfinMediaMetadataUpdaterImpl @Inject constructor( - private val jellyfinApiClient: JellyfinApiClient -) : JellyfinMediaMetadataUpdater { + private val jellyfinApiClient: JellyfinApiClient, + private val localMediaRepository: LocalMediaRepository, +) : MediaMetadataUpdater { override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) { if (watched) { @@ -15,5 +19,22 @@ class JellyfinMediaMetadataUpdaterImpl @Inject constructor( } else { jellyfinApiClient.markAsUnwatched(mediaId) } + localMediaRepository.markAsWatched(mediaId, watched) } -} \ No newline at end of file + + override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { + localMediaRepository.updateWatchProgress(mediaId, positionMs, durationMs) + } + + override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) { + localMediaRepository.updateWatchProgressPercent(mediaId, progressPercent) + } + + override suspend fun updatePlaybackPosition( + mediaId: UUID, + playbackPositionTicks: Long, + runtimeTicks: Long, + ) { + jellyfinApiClient.updatePlaybackPosition(mediaId, playbackPositionTicks, runtimeTicks) + } +} 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 index 41af9c08..4a24e992 100644 --- 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 @@ -3,6 +3,7 @@ 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.MediaMetadataUpdater 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 @@ -19,6 +20,7 @@ import kotlin.math.roundToLong class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor( private val networkMonitor: NetworkMonitor, private val jellyfinApiClient: JellyfinApiClient, + private val mediaMetadataUpdater: MediaMetadataUpdater, @param:Offline private val offlineRepository: LocalMediaRepository, @param:Online private val onlineRepository: LocalMediaRepository, ) : HomeRefreshSideEffect { @@ -49,12 +51,12 @@ class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor( // furthest known playback position as the best available source of truth. when { localPlaybackPositionTicks > remotePlaybackPosition.playbackPositionTicks + tickTolerance -> { - jellyfinApiClient.updatePlaybackPosition( + mediaMetadataUpdater.updatePlaybackPosition( mediaId = localPlaybackPosition.mediaId, playbackPositionTicks = localPlaybackPositionTicks, runtimeTicks = remotePlaybackPosition.runtimeTicks, ) - onlineRepository.updateWatchProgressPercent( + mediaMetadataUpdater.updateWatchProgressPercent( mediaId = localPlaybackPosition.mediaId, progressPercent = localPlaybackPositionTicks.toProgressPercent( runtimeTicks = remotePlaybackPosition.runtimeTicks, @@ -65,11 +67,7 @@ class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor( val remoteProgressPercent = remotePlaybackPosition.playbackPositionTicks.toProgressPercent( runtimeTicks = remotePlaybackPosition.runtimeTicks, ) - offlineRepository.updateWatchProgressPercent( - mediaId = localPlaybackPosition.mediaId, - progressPercent = remoteProgressPercent, - ) - onlineRepository.updateWatchProgressPercent( + mediaMetadataUpdater.updateWatchProgressPercent( mediaId = localPlaybackPosition.mediaId, progressPercent = remoteProgressPercent, ) diff --git a/data/src/main/java/hu/bbara/purefin/data/module/MediaRepositoryModule.kt b/data/src/main/java/hu/bbara/purefin/data/module/MediaRepositoryModule.kt index db869f22..bd1fb24f 100644 --- a/data/src/main/java/hu/bbara/purefin/data/module/MediaRepositoryModule.kt +++ b/data/src/main/java/hu/bbara/purefin/data/module/MediaRepositoryModule.kt @@ -14,7 +14,7 @@ import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository import hu.bbara.purefin.data.jellyfin.JellyfinMediaMetadataUpdaterImpl import hu.bbara.purefin.data.jellyfin.SearchManagerImpl -import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater +import hu.bbara.purefin.core.data.MediaMetadataUpdater @Module @InstallIn(SingletonComponent::class) @@ -32,7 +32,7 @@ abstract class MediaRepositoryModule { abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository @Binds - abstract fun bindJellyfinMediaMetadataUpdater(impl: JellyfinMediaMetadataUpdaterImpl): JellyfinMediaMetadataUpdater + abstract fun bindMediaMetadataUpdater(impl: JellyfinMediaMetadataUpdaterImpl): MediaMetadataUpdater @Binds abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager