mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user