refactor(media): consolidate metadata update functions into MediaMetadataUpdater

Replace the disparate JellyfinMediaMetadataUpdater, LocalMediaUpdater,
and direct API/repository calls for watched status and progress updates
with a single unified MediaMetadataUpdater interface and implementation.

The new implementation coordinates both local cache (in-memory/Room)
and remote server updates, so callers have a single point of entry
for all metadata mutations. markAsWatched now updates both server
and local state, fixing a gap where the UI would not reflect
watched changes until a full refresh.
This commit is contained in:
2026-06-12 18:08:32 +00:00
parent def92ad111
commit e78a3700a6
11 changed files with 90 additions and 39 deletions

View File

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

View File

@@ -8,7 +8,7 @@ import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshCoordinator 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.settings.SettingsRepository
import hu.bbara.purefin.core.model.EpisodeUiModel import hu.bbara.purefin.core.model.EpisodeUiModel
import hu.bbara.purefin.core.model.LibraryUiModel import hu.bbara.purefin.core.model.LibraryUiModel
@@ -39,7 +39,7 @@ class AppViewModel @Inject constructor(
private val homeRepository: HomeRepository, private val homeRepository: HomeRepository,
private val localMediaRepository: LocalMediaRepository, private val localMediaRepository: LocalMediaRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, private val mediaMetadataUpdater: MediaMetadataUpdater,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val homeRefreshCoordinator: HomeRefreshCoordinator, private val homeRefreshCoordinator: HomeRefreshCoordinator,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
@@ -185,7 +185,7 @@ class AppViewModel @Inject constructor(
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) { fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
viewModelScope.launch { viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched) mediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
} }
} }

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.HomeRepository 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.MediaUiModel
import hu.bbara.purefin.core.model.MovieUiModel import hu.bbara.purefin.core.model.MovieUiModel
import hu.bbara.purefin.core.model.SeriesUiModel import hu.bbara.purefin.core.model.SeriesUiModel
@@ -26,7 +26,7 @@ import javax.inject.Inject
class LibraryViewModel @Inject constructor( class LibraryViewModel @Inject constructor(
private val homeRepository: HomeRepository, private val homeRepository: HomeRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, private val mediaMetadataUpdater: MediaMetadataUpdater,
) : ViewModel() { ) : ViewModel() {
private val selectedLibrary = MutableStateFlow<UUID?>(null) private val selectedLibrary = MutableStateFlow<UUID?>(null)
@@ -73,7 +73,7 @@ class LibraryViewModel @Inject constructor(
fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) { fun markAsWatched(mediaUiModel: MediaUiModel, watched: Boolean) {
viewModelScope.launch { viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched) mediaMetadataUpdater.markAsWatched(mediaUiModel.id, watched)
} }
} }

View File

@@ -7,7 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController 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.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
@@ -31,7 +31,7 @@ class EpisodeScreenViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, private val mediaMetadataUpdater: MediaMetadataUpdater,
): ViewModel() { ): ViewModel() {
private val _episode = MutableStateFlow<EpisodeDto?>(null) private val _episode = MutableStateFlow<EpisodeDto?>(null)
@@ -86,7 +86,7 @@ class EpisodeScreenViewModel @Inject constructor(
fun markAsWatched(watched: Boolean) { fun markAsWatched(watched: Boolean) {
val episodeId = _episode.value?.id ?: return val episodeId = _episode.value?.id ?: return
viewModelScope.launch { viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(episodeId, watched) mediaMetadataUpdater.markAsWatched(episodeId, watched)
} }
} }

View File

@@ -7,7 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController 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.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
@@ -29,7 +29,7 @@ class MovieScreenViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, private val mediaMetadataUpdater: MediaMetadataUpdater,
): ViewModel() { ): ViewModel() {
private val _movie = MutableStateFlow<MovieDto?>(null) private val _movie = MutableStateFlow<MovieDto?>(null)
@@ -73,7 +73,7 @@ class MovieScreenViewModel @Inject constructor(
fun markAsWatched(watched: Boolean) { fun markAsWatched(watched: Boolean) {
val movieId = movie.value?.id ?: return val movieId = movie.value?.id ?: return
viewModelScope.launch { viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(movieId, watched) mediaMetadataUpdater.markAsWatched(movieId, watched)
} }
} }

View File

@@ -7,7 +7,7 @@ import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController 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.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
@@ -34,7 +34,7 @@ class SeriesViewModel @Inject constructor(
@param:Offline private val offlineMediaCatalogReader: LocalMediaRepository, @param:Offline private val offlineMediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, private val mediaMetadataUpdater: MediaMetadataUpdater,
) : ViewModel() { ) : ViewModel() {
private val _series = MutableStateFlow<SeriesDto?>(null) private val _series = MutableStateFlow<SeriesDto?>(null)
@@ -188,7 +188,7 @@ class SeriesViewModel @Inject constructor(
fun markAsWatched(watched: Boolean) { fun markAsWatched(watched: Boolean) {
val seriesId = _series.value?.id ?: return val seriesId = _series.value?.id ?: return
viewModelScope.launch { viewModelScope.launch {
jellyfinMediaMetadataUpdater.markAsWatched(seriesId, watched) mediaMetadataUpdater.markAsWatched(seriesId, watched)
} }
} }

View File

@@ -1,7 +0,0 @@
package hu.bbara.purefin.core.jellyfin
import java.util.UUID
interface JellyfinMediaMetadataUpdater {
suspend fun markAsWatched(mediaId: UUID, watched: Boolean)
}

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.core.player.manager package hu.bbara.purefin.core.player.manager
import dagger.hilt.android.scopes.ViewModelScoped 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.PlaybackProgressReporter
import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.player.model.MetadataState import hu.bbara.purefin.core.player.model.MetadataState
@@ -24,7 +24,7 @@ import javax.inject.Inject
@ViewModelScoped @ViewModelScoped
class ProgressManager @Inject constructor( class ProgressManager @Inject constructor(
private val playbackProgressReporter: PlaybackProgressReporter, private val playbackProgressReporter: PlaybackProgressReporter,
private val localMediaUpdater: LocalMediaUpdater, private val mediaMetadataUpdater: MediaMetadataUpdater,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var progressJob: Job? = null private var progressJob: Job? = null
@@ -80,7 +80,7 @@ class ProgressManager @Inject constructor(
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true) report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
localMediaUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs) mediaMetadataUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Local cache update failed") Timber.tag(TAG).e(e, "Local cache update failed")
} }
@@ -127,7 +127,7 @@ class ProgressManager @Inject constructor(
activePlaybackReportContext?.let { reportContext -> activePlaybackReportContext?.let { reportContext ->
playbackProgressReporter.reportPlaybackStopped(itemId, ticks, 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") Timber.tag(TAG).d("Stop: $itemId at ${posMs}ms")
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Report failed") Timber.tag(TAG).e(e, "Report failed")

View File

@@ -1,13 +1,17 @@
package hu.bbara.purefin.data.jellyfin 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.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class JellyfinMediaMetadataUpdaterImpl @Inject constructor( class JellyfinMediaMetadataUpdaterImpl @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient private val jellyfinApiClient: JellyfinApiClient,
) : JellyfinMediaMetadataUpdater { private val localMediaRepository: LocalMediaRepository,
) : MediaMetadataUpdater {
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) { override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
if (watched) { if (watched) {
@@ -15,5 +19,22 @@ class JellyfinMediaMetadataUpdaterImpl @Inject constructor(
} else { } else {
jellyfinApiClient.markAsUnwatched(mediaId) jellyfinApiClient.markAsUnwatched(mediaId)
} }
localMediaRepository.markAsWatched(mediaId, watched)
} }
}
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)
}
}

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.data.jellyfin.playback
import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.Online import hu.bbara.purefin.core.Online
import hu.bbara.purefin.core.data.LocalMediaRepository 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.data.NetworkMonitor
import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshSideEffect import hu.bbara.purefin.core.feature.browse.home.refresh.HomeRefreshSideEffect
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
@@ -19,6 +20,7 @@ import kotlin.math.roundToLong
class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor( class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val mediaMetadataUpdater: MediaMetadataUpdater,
@param:Offline private val offlineRepository: LocalMediaRepository, @param:Offline private val offlineRepository: LocalMediaRepository,
@param:Online private val onlineRepository: LocalMediaRepository, @param:Online private val onlineRepository: LocalMediaRepository,
) : HomeRefreshSideEffect { ) : HomeRefreshSideEffect {
@@ -49,12 +51,12 @@ class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
// furthest known playback position as the best available source of truth. // furthest known playback position as the best available source of truth.
when { when {
localPlaybackPositionTicks > remotePlaybackPosition.playbackPositionTicks + tickTolerance -> { localPlaybackPositionTicks > remotePlaybackPosition.playbackPositionTicks + tickTolerance -> {
jellyfinApiClient.updatePlaybackPosition( mediaMetadataUpdater.updatePlaybackPosition(
mediaId = localPlaybackPosition.mediaId, mediaId = localPlaybackPosition.mediaId,
playbackPositionTicks = localPlaybackPositionTicks, playbackPositionTicks = localPlaybackPositionTicks,
runtimeTicks = remotePlaybackPosition.runtimeTicks, runtimeTicks = remotePlaybackPosition.runtimeTicks,
) )
onlineRepository.updateWatchProgressPercent( mediaMetadataUpdater.updateWatchProgressPercent(
mediaId = localPlaybackPosition.mediaId, mediaId = localPlaybackPosition.mediaId,
progressPercent = localPlaybackPositionTicks.toProgressPercent( progressPercent = localPlaybackPositionTicks.toProgressPercent(
runtimeTicks = remotePlaybackPosition.runtimeTicks, runtimeTicks = remotePlaybackPosition.runtimeTicks,
@@ -65,11 +67,7 @@ class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
val remoteProgressPercent = remotePlaybackPosition.playbackPositionTicks.toProgressPercent( val remoteProgressPercent = remotePlaybackPosition.playbackPositionTicks.toProgressPercent(
runtimeTicks = remotePlaybackPosition.runtimeTicks, runtimeTicks = remotePlaybackPosition.runtimeTicks,
) )
offlineRepository.updateWatchProgressPercent( mediaMetadataUpdater.updateWatchProgressPercent(
mediaId = localPlaybackPosition.mediaId,
progressPercent = remoteProgressPercent,
)
onlineRepository.updateWatchProgressPercent(
mediaId = localPlaybackPosition.mediaId, mediaId = localPlaybackPosition.mediaId,
progressPercent = remoteProgressPercent, progressPercent = remoteProgressPercent,
) )

View File

@@ -14,7 +14,7 @@ import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository
import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository
import hu.bbara.purefin.data.jellyfin.JellyfinMediaMetadataUpdaterImpl import hu.bbara.purefin.data.jellyfin.JellyfinMediaMetadataUpdaterImpl
import hu.bbara.purefin.data.jellyfin.SearchManagerImpl import hu.bbara.purefin.data.jellyfin.SearchManagerImpl
import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater import hu.bbara.purefin.core.data.MediaMetadataUpdater
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -32,7 +32,7 @@ abstract class MediaRepositoryModule {
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
@Binds @Binds
abstract fun bindJellyfinMediaMetadataUpdater(impl: JellyfinMediaMetadataUpdaterImpl): JellyfinMediaMetadataUpdater abstract fun bindMediaMetadataUpdater(impl: JellyfinMediaMetadataUpdaterImpl): MediaMetadataUpdater
@Binds @Binds
abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager