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.
This commit is contained in:
2026-02-04 21:59:17 +01:00
parent 08373eb878
commit 3fed91aa27
3 changed files with 161 additions and 1 deletions

View File

@@ -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.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi 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.api.client.extensions.videosApi
import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo 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.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlaybackInfoDto 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.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.SubtitleProfile import org.jellyfin.sdk.model.api.SubtitleProfile
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
@@ -279,4 +286,46 @@ class JellyfinApiClient @Inject constructor(
return response 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
)
)
}
} }

View File

@@ -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<PlaybackStateSnapshot>,
progress: StateFlow<PlaybackProgressSnapshot>,
metadata: StateFlow<MetadataState>
) {
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()
}
}

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.player.data.MediaRepository import hu.bbara.purefin.player.data.MediaRepository
import hu.bbara.purefin.player.manager.PlayerManager 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.PlayerUiState
import hu.bbara.purefin.player.model.TrackOption import hu.bbara.purefin.player.model.TrackOption
import javax.inject.Inject import javax.inject.Inject
@@ -22,7 +23,8 @@ import java.util.UUID
class PlayerViewModel @Inject constructor( class PlayerViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val playerManager: PlayerManager, private val playerManager: PlayerManager,
private val mediaRepository: MediaRepository private val mediaRepository: MediaRepository,
private val progressManager: ProgressManager
) : ViewModel() { ) : ViewModel() {
val player get() = playerManager.player val player get() = playerManager.player
@@ -40,6 +42,11 @@ class PlayerViewModel @Inject constructor(
private var dataErrorMessage: String? = null private var dataErrorMessage: String? = null
init { init {
progressManager.bind(
playerManager.playbackState,
playerManager.progress,
playerManager.metadata
)
observePlayerState() observePlayerState()
loadInitialMedia() loadInitialMedia()
} }
@@ -226,6 +233,7 @@ class PlayerViewModel @Inject constructor(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
autoHideJob?.cancel() autoHideJob?.cancel()
progressManager.release()
playerManager.release() playerManager.release()
} }