From 3fed91aa27a69cbc39d9b646c82e731aa852cd4d Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Wed, 4 Feb 2026 21:59:17 +0100 Subject: [PATCH] feat: add playback reporting to Jellyfin server Report playback start, progress (every 5s), and stop events to enable session tracking on the Jellyfin dashboard and accurate resume positions. --- .../bbara/purefin/client/JellyfinApiClient.kt | 49 +++++++++ .../purefin/player/manager/ProgressManager.kt | 103 ++++++++++++++++++ .../player/viewmodel/PlayerViewModel.kt | 10 +- 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/hu/bbara/purefin/player/manager/ProgressManager.kt diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index bf02177..14dd906 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -13,6 +13,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo @@ -24,6 +25,12 @@ import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PlaybackInfoDto +import org.jellyfin.sdk.model.api.PlaybackOrder +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.PlayMethod +import org.jellyfin.sdk.model.api.RepeatMode import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.api.SubtitleProfile import org.jellyfin.sdk.model.api.request.GetItemsRequest @@ -279,4 +286,46 @@ class JellyfinApiClient @Inject constructor( return response } + suspend fun reportPlaybackStart(itemId: UUID, positionTicks: Long = 0L) { + if (!ensureConfigured()) return + api.playStateApi.reportPlaybackStart( + PlaybackStartInfo( + itemId = itemId, + positionTicks = positionTicks, + canSeek = true, + isPaused = false, + isMuted = false, + playMethod = PlayMethod.DIRECT_PLAY, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT + ) + ) + } + + suspend fun reportPlaybackProgress(itemId: UUID, positionTicks: Long, isPaused: Boolean) { + if (!ensureConfigured()) return + api.playStateApi.reportPlaybackProgress( + PlaybackProgressInfo( + itemId = itemId, + positionTicks = positionTicks, + canSeek = true, + isPaused = isPaused, + isMuted = false, + playMethod = PlayMethod.DIRECT_PLAY, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT + ) + ) + } + + suspend fun reportPlaybackStopped(itemId: UUID, positionTicks: Long) { + if (!ensureConfigured()) return + api.playStateApi.reportPlaybackStopped( + PlaybackStopInfo( + itemId = itemId, + positionTicks = positionTicks, + failed = false + ) + ) + } } diff --git a/app/src/main/java/hu/bbara/purefin/player/manager/ProgressManager.kt b/app/src/main/java/hu/bbara/purefin/player/manager/ProgressManager.kt new file mode 100644 index 0000000..6bd9f4f --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/player/manager/ProgressManager.kt @@ -0,0 +1,103 @@ +package hu.bbara.purefin.player.manager + +import android.util.Log +import dagger.hilt.android.scopes.ViewModelScoped +import hu.bbara.purefin.client.JellyfinApiClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@ViewModelScoped +class ProgressManager @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var progressJob: Job? = null + private var activeItemId: UUID? = null + private var lastPositionMs: Long = 0L + private var isPaused: Boolean = false + + fun bind( + playbackState: StateFlow, + progress: StateFlow, + metadata: StateFlow + ) { + scope.launch { + combine(playbackState, progress, metadata) { state, prog, meta -> + Triple(state, prog, meta) + }.collect { (state, prog, meta) -> + lastPositionMs = prog.positionMs + isPaused = !state.isPlaying + val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() } + + // Media changed or ended - stop session + if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) { + stopSession() + } + + // Start session when we have a media item and none is active + if (activeItemId == null && mediaId != null && !state.isEnded) { + startSession(mediaId, prog.positionMs) + } + } + } + } + + private fun startSession(itemId: UUID, positionMs: Long) { + activeItemId = itemId + report(itemId, positionMs, isStart = true) + progressJob = scope.launch { + while (isActive) { + delay(5000) + report(itemId, lastPositionMs, isPaused = isPaused) + } + } + } + + private fun stopSession() { + progressJob?.cancel() + activeItemId?.let { report(it, lastPositionMs, isStop = true) } + activeItemId = null + } + + private fun report(itemId: UUID, positionMs: Long, isPaused: Boolean = false, isStart: Boolean = false, isStop: Boolean = false) { + val ticks = positionMs * 10_000 + scope.launch(Dispatchers.IO) { + try { + when { + isStart -> jellyfinApiClient.reportPlaybackStart(itemId, ticks) + isStop -> jellyfinApiClient.reportPlaybackStopped(itemId, ticks) + else -> jellyfinApiClient.reportPlaybackProgress(itemId, ticks, isPaused) + } + Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused") + } catch (e: Exception) { + Log.e("ProgressManager", "Report failed", e) + } + } + } + + fun release() { + progressJob?.cancel() + activeItemId?.let { itemId -> + val ticks = lastPositionMs * 10_000 + CoroutineScope(Dispatchers.IO).launch { + try { + jellyfinApiClient.reportPlaybackStopped(itemId, ticks) + Log.d("ProgressManager", "Stop: $itemId at ${lastPositionMs}ms") + } catch (e: Exception) { + Log.e("ProgressManager", "Report failed", e) + } + } + } + scope.cancel() + } +} diff --git a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt index 509f3e0..d80c9c4 100644 --- a/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/player/viewmodel/PlayerViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.player.data.MediaRepository import hu.bbara.purefin.player.manager.PlayerManager +import hu.bbara.purefin.player.manager.ProgressManager import hu.bbara.purefin.player.model.PlayerUiState import hu.bbara.purefin.player.model.TrackOption import javax.inject.Inject @@ -22,7 +23,8 @@ import java.util.UUID class PlayerViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val playerManager: PlayerManager, - private val mediaRepository: MediaRepository + private val mediaRepository: MediaRepository, + private val progressManager: ProgressManager ) : ViewModel() { val player get() = playerManager.player @@ -40,6 +42,11 @@ class PlayerViewModel @Inject constructor( private var dataErrorMessage: String? = null init { + progressManager.bind( + playerManager.playbackState, + playerManager.progress, + playerManager.metadata + ) observePlayerState() loadInitialMedia() } @@ -226,6 +233,7 @@ class PlayerViewModel @Inject constructor( override fun onCleared() { super.onCleared() autoHideJob?.cancel() + progressManager.release() playerManager.release() }