diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt index 954be5a35..ce749b994 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt @@ -5,25 +5,21 @@ package ch.srgssr.pillarbox.player.analytics.metrics import androidx.annotation.VisibleForTesting -import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.Player import androidx.media3.common.Timeline.Window +import androidx.media3.common.util.Size import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation -import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime +import androidx.media3.exoplayer.drm.DrmSession import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData import ch.srgssr.pillarbox.player.PillarboxExoPlayer import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter import ch.srgssr.pillarbox.player.analytics.extension.getUidOfPeriod -import ch.srgssr.pillarbox.player.source.PillarboxMediaSource import ch.srgssr.pillarbox.player.utils.DebugLogger -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds /** * Playback stats metrics @@ -51,19 +47,12 @@ class MetricsCollector @VisibleForTesting private constructor( fun onMetricSessionReady(metrics: PlaybackMetrics) = Unit } - private val totalPlaytimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) - private val totalStallTimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) - private val totalBufferingTimeCounter: TotalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) - private var stallCount = 0 - private var bandwidth = 0L - private var bufferDuration = Duration.ZERO - private var audioFormat: Format? = null - private var videoFormat: Format? = null private val window = Window() - private val loadingTimes = mutableMapOf() private var currentSession: PlaybackSessionManager.Session? = null private val listeners = mutableSetOf() private lateinit var player: PillarboxExoPlayer + private val metricsSessions = mutableMapOf() + private var surfaceSize: Size = Size.UNKNOWN constructor() : this({ System.currentTimeMillis() }) @@ -94,181 +83,151 @@ class MetricsCollector @VisibleForTesting private constructor( listeners.remove(listener) } - private fun notifyMetricsFinished(metrics: PlaybackMetrics) { + private fun notifyMetricsFinished(playbackMetrics: PlaybackMetrics) { + DebugLogger.debug(TAG, "notifyMetricsFinished $playbackMetrics") listeners.toList().forEach { - it.onMetricSessionFinished(metrics) + it.onMetricSessionFinished(playbackMetrics) } } - private fun notifyMetricsReady(metrics: PlaybackMetrics) { - if (currentSession?.sessionId != metrics.sessionId) return - DebugLogger.debug(TAG, "notifyMetricsReady $metrics") + private fun notifyMetricsReady(playbackMetrics: PlaybackMetrics) { + if (currentSession?.sessionId != playbackMetrics.sessionId) return + DebugLogger.debug(TAG, "notifyMetricsReady $playbackMetrics") listeners.toList().forEach { - it.onMetricSessionReady(metrics) + it.onMetricSessionReady(metrics = playbackMetrics) } } override fun onSessionCreated(session: PlaybackSessionManager.Session) { - getOrCreateLoadingTimes(session.periodUid) + getOrCreateSessionMetrics(session.periodUid) } override fun onSessionFinished(session: PlaybackSessionManager.Session) { - getMetricsForSession(session)?.let { - DebugLogger.debug(TAG, "onSessionFinished: $it") - notifyMetricsFinished(it) + metricsSessions.remove(session.periodUid)?.let { + notifyMetricsFinished(createPlaybackMetrics(session = session, metrics = it)) + } + if (currentSession == session) { + currentSession = null } - loadingTimes.remove(session.periodUid) - reset() } override fun onCurrentSession(session: PlaybackSessionManager.Session) { currentSession = session - val loadingTimes = loadingTimes[session.periodUid] - if (loadingTimes?.state == Player.STATE_READY) { - getCurrentMetrics()?.let(this::notifyMetricsReady) + getOrCreateSessionMetrics(session.periodUid).apply { + setIsPlaying(player.isPlaying) + setPlaybackState(player.playbackState) } } - private fun getOrCreateLoadingTimes(periodUid: Any): LoadingTimes { - return loadingTimes.getOrPut(periodUid) { - LoadingTimes(timeProvider = timeProvider, onLoadingReady = { + /** + * Get session metrics + * + * @param eventTime + * @return `null` if there is no item in the timeline + */ + private fun getSessionMetrics(eventTime: EventTime): SessionMetrics? { + if (eventTime.timeline.isEmpty) return null + return getOrCreateSessionMetrics(eventTime.getUidOfPeriod(window)) + } + + private fun getOrCreateSessionMetrics(periodUid: Any): SessionMetrics { + return metricsSessions.getOrPut(periodUid) { + SessionMetrics(timeProvider) { sessionMetrics -> player.sessionManager.getSessionFromPeriodUid(periodUid)?.let { - getMetricsForSession(it)?.let(this::notifyMetricsReady) + notifyMetricsReady(createPlaybackMetrics(session = it, metrics = sessionMetrics)) } - }) + } } } override fun onStallChanged(eventTime: EventTime, isStall: Boolean) { - if (isStall) { - totalStallTimeCounter.play() - stallCount++ - } else { - totalStallTimeCounter.pause() - } + getSessionMetrics(eventTime)?.setIsStall(isStall) } override fun onIsPlayingChanged(eventTime: EventTime, isPlaying: Boolean) { - if (isPlaying) { - totalPlaytimeCounter.play() - } else { - totalPlaytimeCounter.pause() - } + getSessionMetrics(eventTime)?.setIsPlaying(isPlaying) } override fun onBandwidthEstimate(eventTime: EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { - bandwidth = bitrateEstimate + getSessionMetrics(eventTime)?.setBandwidthEstimate(totalLoadTimeMs, totalBytesLoaded, bitrateEstimate) } override fun onVideoInputFormatChanged(eventTime: EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { - videoFormat = format + getSessionMetrics(eventTime)?.videoFormat = format } + /** + * On video disabled is called when releasing the player + * + * @param eventTime + * @param decoderCounters + */ override fun onVideoDisabled(eventTime: EventTime, decoderCounters: DecoderCounters) { - videoFormat = null + if (player.playbackState == Player.STATE_IDLE || eventTime.timeline.isEmpty) return + getSessionMetrics(eventTime)?.videoFormat = null } override fun onAudioInputFormatChanged(eventTime: EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { - audioFormat = format + getSessionMetrics(eventTime)?.audioFormat = format } override fun onAudioDisabled(eventTime: EventTime, decoderCounters: DecoderCounters) { - audioFormat = null - } - - private fun updateStartupTimeWithState(eventTime: EventTime, state: Int) { - if (eventTime.timeline.isEmpty) return - val periodUid = eventTime.getUidOfPeriod(window) - val startupTimes = getOrCreateLoadingTimes(periodUid) - startupTimes.state = state + if (player.playbackState == Player.STATE_IDLE || eventTime.timeline.isEmpty) return + getSessionMetrics(eventTime)?.audioFormat = null } override fun onPlaybackStateChanged(eventTime: EventTime, state: Int) { - updateStartupTimeWithState(eventTime, state) - when (state) { - Player.STATE_BUFFERING -> { - totalBufferingTimeCounter.play() - } - - Player.STATE_READY -> { - totalBufferingTimeCounter.pause() - } - } + getSessionMetrics(eventTime)?.setPlaybackState(state) } override fun onRenderedFirstFrame(eventTime: EventTime, output: Any, renderTimeMs: Long) { - updateStartupTimeWithState(eventTime, player.playbackState) + getSessionMetrics(eventTime)?.setRenderFirstFrameOrAudioPositionAdvancing() } override fun onAudioPositionAdvancing(eventTime: EventTime, playoutStartSystemTimeMs: Long) { - updateStartupTimeWithState(eventTime, player.playbackState) - } - - override fun onEvents(player: Player, events: AnalyticsListener.Events) { - bufferDuration = player.totalBufferedDuration.milliseconds + getSessionMetrics(eventTime)?.setRenderFirstFrameOrAudioPositionAdvancing() } override fun onLoadCompleted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { - if (eventTime.timeline.isEmpty) return - val periodUid = eventTime.getUidOfPeriod(window) - val loadingTimes = getOrCreateLoadingTimes(periodUid) - val loadDuration = loadEventInfo.loadDurationMs.milliseconds - when (mediaLoadData.dataType) { - C.DATA_TYPE_DRM -> { - if (loadingTimes.drm == null) { - loadingTimes.drm = loadDuration - } - } - - C.DATA_TYPE_MANIFEST -> { - if (loadingTimes.manifest == null) { - loadingTimes.manifest = loadDuration - } - } - - C.DATA_TYPE_MEDIA -> { - if (loadingTimes.source == null) { - loadingTimes.source = loadDuration - } - } + getSessionMetrics(eventTime)?.setLoadCompleted(loadEventInfo, mediaLoadData) + } - PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> { - if (loadingTimes.asset == null) { - loadingTimes.asset = loadDuration - } - } + override fun onLoadStarted(eventTime: EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { + getSessionMetrics(eventTime)?.setLoadStarted(loadEventInfo) + } - else -> { - } + override fun onDrmSessionAcquired(eventTime: EventTime, state: Int) { + DebugLogger.debug(TAG, "onDrmSessionAcquired $state") + if (state == DrmSession.STATE_OPENED) { + getSessionMetrics(eventTime)?.setDrmSessionAcquired() } } - override fun onPlayerReleased(eventTime: EventTime) { - listeners.clear() + override fun onDrmSessionReleased(eventTime: EventTime) { + DebugLogger.debug(TAG, "onDrmSessionReleased") } - private fun computeBitrate(): Int { - val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE - val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE - var bitrate = 0 - if (videoBitrate > 0) bitrate += videoBitrate - if (audioBitrate > 0) bitrate += audioBitrate - return bitrate + override fun onDrmKeysLoaded(eventTime: EventTime) { + DebugLogger.debug(TAG, "onDrmKeysLoaded") + getSessionMetrics(eventTime)?.setDrmKeyLoaded() } - private fun reset() { - stallCount = 0 - totalStallTimeCounter.reset() - totalPlaytimeCounter.reset() - totalBufferingTimeCounter.reset() + override fun onDrmKeysRestored(eventTime: EventTime) { + DebugLogger.debug(TAG, "onDrmKeysRestored") + getSessionMetrics(eventTime)?.setDrmKeyLoaded() + } - bufferDuration = Duration.ZERO + override fun onDrmKeysRemoved(eventTime: EventTime) { + DebugLogger.debug(TAG, "onDrmKeysRemoved") + getSessionMetrics(eventTime)?.setDrmKeyLoaded() + } - audioFormat = player.audioFormat - videoFormat = player.videoFormat - if (player.isPlaying) { - totalPlaytimeCounter.play() - } + override fun onPlayerReleased(eventTime: EventTime) { + listeners.clear() + } + + override fun onSurfaceSizeChanged(eventTime: EventTime, width: Int, height: Int) { + surfaceSize = Size(width, height) } /** @@ -282,6 +241,31 @@ class MetricsCollector @VisibleForTesting private constructor( } } + private fun createPlaybackMetrics(session: PlaybackSessionManager.Session, metrics: SessionMetrics): PlaybackMetrics { + return PlaybackMetrics( + sessionId = session.sessionId, + bandwidth = metrics.estimateBitrate, + indicatedBitrate = metrics.getTotalBitrate(), + playbackDuration = metrics.totalPlayingDuration, + stallCount = metrics.stallCount, + stallDuration = metrics.totalStallDuration, + bufferingDuration = metrics.totalBufferingDuration, + loadDuration = PlaybackMetrics.LoadDuration( + drm = metrics.totalDrmLoadingDuration, + asset = metrics.asset, + source = metrics.source, + manifest = metrics.manifest, + timeToReady = metrics.timeToReady, + ), + videoFormat = metrics.videoFormat, + audioFormat = metrics.audioFormat, + totalLoadTime = metrics.totalLoadTime, + totalBytesLoaded = metrics.totalBytesLoaded, + url = metrics.url, + surfaceSize = surfaceSize, + ) + } + /** * Get metrics for session * @@ -289,30 +273,12 @@ class MetricsCollector @VisibleForTesting private constructor( * @return */ fun getMetricsForSession(session: PlaybackSessionManager.Session): PlaybackMetrics? { - val loadingTimes = getOrCreateLoadingTimes(session.periodUid) - return PlaybackMetrics( - sessionId = session.sessionId, - bandwidth = bandwidth, - bitrate = computeBitrate(), - bufferDuration = bufferDuration, - playbackDuration = totalPlaytimeCounter.getTotalPlayTime(), - stallCount = stallCount, - stallDuration = totalStallTimeCounter.getTotalPlayTime(), - loadDuration = loadingTimes.toLoadDuration() - ) + return metricsSessions[session.periodUid]?.let { + createPlaybackMetrics(session, it) + } } private companion object { const val TAG = "MetricsCollector" - - private fun LoadingTimes.toLoadDuration(): PlaybackMetrics.LoadDuration { - return PlaybackMetrics.LoadDuration( - source = source, - manifest = manifest, - drm = drm, - asset = asset, - timeToReady = timeToReady, - ) - } } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt index 558ca4a1c..caa526ac1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt @@ -4,6 +4,11 @@ */ package ch.srgssr.pillarbox.player.analytics.metrics +import android.net.Uri +import androidx.media3.common.Format +import androidx.media3.common.VideoSize +import androidx.media3.common.util.Size +import ch.srgssr.pillarbox.player.extension.videoSize import kotlin.time.Duration /** @@ -11,24 +16,41 @@ import kotlin.time.Duration * * @property sessionId The session ID. * @property bandwidth The device-measured network bandwidth, in bytes per second. - * @property bitrate The bitrate of the current stream, in bytes per second. - * @property bufferDuration The forward duration of the buffer. - * @property playbackDuration The duration of the playback. + * @property indicatedBitrate The bitrate of the video and audio format. + * @property playbackDuration The duration the session spent playing. + * @property bufferingDuration The duration the session spent in buffering. * @property stallCount The number of stalls that have occurred, not as a result of a seek. * @property stallDuration The total duration of the stalls. * @property loadDuration The load duration that could be computed. + * @property totalLoadTime The load time to compute [bandwidth]. + * @property totalBytesLoaded The total bytes loaded to compute [bandwidth]. + * @property url The last url loaded by the player. + * @property videoFormat The current video format selected by the player. + * @property audioFormat The current audio format selected by the player. + * @property surfaceSize The size of the surface connected to the player. [Size.ZERO] if not connected. */ data class PlaybackMetrics( val sessionId: String, - val bandwidth: Long = 0, - val bitrate: Int = 0, - val bufferDuration: Duration = Duration.ZERO, - val playbackDuration: Duration = Duration.ZERO, - val stallCount: Int = 0, - val stallDuration: Duration = Duration.ZERO, - val loadDuration: LoadDuration = LoadDuration() + val bandwidth: Long, + val indicatedBitrate: Long, + val playbackDuration: Duration, + val bufferingDuration: Duration, + val stallCount: Int, + val stallDuration: Duration, + val loadDuration: LoadDuration, + val totalLoadTime: Duration, + val totalBytesLoaded: Long, + val url: Uri?, + val videoFormat: Format?, + val audioFormat: Format?, + val surfaceSize: Size, ) { + /** + * Video size of [videoFormat] if applicable. + */ + val videoSize: VideoSize = videoFormat?.videoSize ?: VideoSize.UNKNOWN + /** * Load duration * Represents the timings until the current media started to play. diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt new file mode 100644 index 000000000..ae6c4a2c3 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/SessionMetrics.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics.metrics + +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Player +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter +import ch.srgssr.pillarbox.player.source.PillarboxMediaSource +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class SessionMetrics internal constructor( + timeProvider: () -> Long = { System.currentTimeMillis() }, + initialPlaybackState: @Player.State Int = Player.STATE_IDLE, + sessionMetricsReady: (SessionMetrics) -> Unit, +) { + private var drmSessionStartedCounter = 0 + private val totalPlaytimeCounter = TotalPlaytimeCounter(timeProvider) + private val totalStallTimeCounter = TotalPlaytimeCounter(timeProvider) + private val totalBufferingTimeCounter = TotalPlaytimeCounter(timeProvider) + private val totalDrmLoadingCounter = TotalPlaytimeCounter(timeProvider) + private val loadingTimes: LoadingTimes = LoadingTimes(timeProvider = timeProvider, onLoadingReady = { + sessionMetricsReady(this) + }) + private var currentPlaybackState: @Player.State Int = initialPlaybackState + var videoFormat: Format? = null + var audioFormat: Format? = null + var stallCount: Int = 0 + var estimateBitrate: Long = 0 + var totalLoadTime: Duration = Duration.ZERO + var totalBytesLoaded: Long = 0L + val source: Duration? + get() { + return loadingTimes.source + } + val manifest: Duration? + get() { + return loadingTimes.manifest + } + val asset: Duration? + get() { + return loadingTimes.asset + } + val drm: Duration? + get() { + return loadingTimes.drm + } + val timeToReady: Duration? + get() { + return loadingTimes.timeToReady + } + val totalPlayingDuration: Duration + get() { + return totalPlaytimeCounter.getTotalPlayTime() + } + val totalBufferingDuration: Duration + get() { + return totalBufferingTimeCounter.getTotalPlayTime() + } + val totalStallDuration: Duration + get() { + return totalStallTimeCounter.getTotalPlayTime() + } + val totalDrmLoadingDuration: Duration? + get() { + val duration = totalDrmLoadingCounter.getTotalPlayTime() + return if (duration == Duration.ZERO) null else duration + } + var url: Uri? = null + + fun setDrmSessionAcquired() { + if (drmSessionStartedCounter == 0) { + totalDrmLoadingCounter.play() + } + drmSessionStartedCounter++ + } + + fun setDrmKeyLoaded() { + drmSessionStartedCounter-- + if (drmSessionStartedCounter <= 0) { + totalDrmLoadingCounter.pause() + loadingTimes.state = currentPlaybackState + drmSessionStartedCounter = 0 + } + } + + fun getTotalBitrate(): Long { + val videoBitrate = videoFormat?.bitrate ?: Format.NO_VALUE + val audioBitrate = audioFormat?.bitrate ?: Format.NO_VALUE + var bitrate = 0L + if (videoBitrate > 0) bitrate += videoBitrate + if (audioBitrate > 0) bitrate += audioBitrate + return bitrate + } + + fun setBandwidthEstimate(totalLoadTimeMs: Int, totalBytesLoaded: Long, estimateBitrate: Long) { + this.estimateBitrate = estimateBitrate + this.totalLoadTime += totalLoadTimeMs.milliseconds + this.totalBytesLoaded += totalBytesLoaded + } + + fun setIsStall(isStall: Boolean) { + if (isStall) { + stallCount++ + totalStallTimeCounter.play() + } else { + totalStallTimeCounter.pause() + } + } + + fun setIsPlaying(playing: Boolean) { + if (playing) { + totalPlaytimeCounter.play() + } else { + totalPlaytimeCounter.pause() + } + } + + fun setRenderFirstFrameOrAudioPositionAdvancing() { + loadingTimes.state = currentPlaybackState + } + + fun setPlaybackState(state: Int) { + currentPlaybackState = state + if (drmSessionStartedCounter == 0) { + loadingTimes.state = state + } + when (state) { + Player.STATE_BUFFERING -> { + totalBufferingTimeCounter.play() + } + + Player.STATE_READY -> { + totalBufferingTimeCounter.pause() + } + } + } + + fun setLoadStarted(loadEventInfo: LoadEventInfo) { + this.url = loadEventInfo.uri + } + + /** + * Should be called when [AnalyticsListener.onLoadCompleted] is called + * + * @param loadEventInfo The [LoadEventInfo]. + * @param mediaLoadData The [MediaLoadData]. + */ + fun setLoadCompleted(loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { + val loadDuration = loadEventInfo.loadDurationMs.milliseconds + this.url = loadEventInfo.uri + when (mediaLoadData.dataType) { + C.DATA_TYPE_DRM -> { + // FIXME Never called! + if (loadingTimes.drm == null) { + loadingTimes.drm = loadDuration + } + } + + C.DATA_TYPE_MANIFEST -> { + if (loadingTimes.manifest == null) { + loadingTimes.manifest = loadDuration + } + } + + C.DATA_TYPE_MEDIA -> { + if (loadingTimes.source == null) { + loadingTimes.source = loadDuration + } + } + + PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> { + if (loadingTimes.asset == null) { + loadingTimes.asset = loadDuration + } + } + + else -> { + } + } + } +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt index 6889a92eb..e08c08cda 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSCoordinator.kt @@ -73,7 +73,7 @@ internal class QoSCoordinator( override fun onMetricSessionFinished(metrics: PlaybackMetrics) { heartbeat.stop() sessionManager.getSessionById(metrics.sessionId)?.let { - sendEvent("END", it) + sendEndEvent(it, metrics) } ?: Log.wtf(TAG, "Should have a session!") } @@ -90,6 +90,16 @@ internal class QoSCoordinator( url = loadEventInfo.uri.toString() } + private fun sendEndEvent(session: PlaybackSessionManager.Session, playbackMetrics: PlaybackMetrics) { + val dataToSend = playbackMetrics.toQoSEvent() + val message = QoSMessage( + data = dataToSend, + eventName = "END", + sessionId = session.sessionId, + ) + messageHandler.sendEvent(message) + } + private fun sendEvent( eventName: String, session: PlaybackSessionManager.Session, @@ -105,17 +115,17 @@ internal class QoSCoordinator( } private fun PlaybackMetrics.toQoSEvent(): QoSEvent { - val bitrateBytes = bitrate / BITS - val bandwidthBytes = bandwidth / BITS + val bitrateBytes = indicatedBitrate / Byte.SIZE_BYTES + val bandwidthBytes = bandwidth / Byte.SIZE_BYTES return QoSEvent( bandwidth = bandwidthBytes, - bitrate = bitrateBytes, - bufferDuration = bufferDuration.inWholeMilliseconds, + bitrate = bitrateBytes.toInt(), + bufferDuration = player.totalBufferedDuration, playbackDuration = playbackDuration.inWholeMilliseconds, playerPosition = player.currentPosition, stallCount = stallCount, stallDuration = stallDuration.inWholeSeconds, - url = url, + url = url.toString(), ) } @@ -193,7 +203,6 @@ internal class QoSCoordinator( } private companion object { - private const val BITS = 8 private val HEARTBEAT_PERIOD = 10.seconds private const val TAG = "QoSCoordinator" } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/SessionMetricsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/SessionMetricsTest.kt new file mode 100644 index 000000000..002c6ba1e --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/SessionMetricsTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import androidx.media3.common.Player +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.player.analytics.metrics.SessionMetrics +import io.mockk.clearAllMocks +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SessionMetricsTest { + private lateinit var callback: (SessionMetrics) -> Unit + + @BeforeTest + fun setUp() { + callback = mockk<(SessionMetrics) -> Unit>(relaxed = true) + } + + @AfterTest + fun tearDown() { + clearAllMocks() + } + + @Test + fun `test loading time ready with DRM with buffering`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setPlaybackState(Player.STATE_BUFFERING) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(false) + advanceTimeBy(1.seconds) + metricSession.setDrmSessionAcquired() + advanceTimeBy(1.seconds) + metricSession.setPlaybackState(Player.STATE_READY) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(true) + advanceTimeBy(1.seconds) + metricSession.setDrmKeyLoaded() + advanceTimeBy(10.seconds) + + verify(exactly = 1) { + callback(metricSession) + } + confirmVerified(callback) + + assertEquals(5.seconds, metricSession.timeToReady) + assertEquals(11.seconds, metricSession.totalPlayingDuration) + assertEquals(3.seconds, metricSession.totalBufferingDuration) + } + + @Test + fun `test loading time ready with multi DRM key`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setPlaybackState(Player.STATE_BUFFERING) + advanceTimeBy(1.seconds) + metricSession.setDrmSessionAcquired() + metricSession.setDrmSessionAcquired() + advanceTimeBy(1.seconds) + metricSession.setDrmKeyLoaded() + advanceTimeBy(1.seconds) + metricSession.setPlaybackState(Player.STATE_READY) + advanceTimeBy(1.seconds) + metricSession.setDrmKeyLoaded() + advanceTimeBy(10.seconds) + + verify(exactly = 1) { + callback(metricSession) + } + confirmVerified(callback) + + assertEquals(4.seconds, metricSession.timeToReady) + } + + @Test + fun `test loading time ready with DRM without buffering`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setPlaybackState(Player.STATE_READY) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(true) + advanceTimeBy(1.seconds) + metricSession.setDrmSessionAcquired() + advanceTimeBy(1.seconds) + metricSession.setDrmKeyLoaded() + advanceTimeBy(10.seconds) + + verify(exactly = 1) { + callback(metricSession) + } + confirmVerified(callback) + + assertNull(metricSession.timeToReady) + assertEquals(12.seconds, metricSession.totalPlayingDuration) + assertEquals(0.seconds, metricSession.totalBufferingDuration) + } + + @Test + fun `test loading time ready without DRM with buffering`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setPlaybackState(Player.STATE_BUFFERING) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(false) + advanceTimeBy(1.seconds) + metricSession.setPlaybackState(Player.STATE_READY) + advanceTimeBy(1.seconds) + metricSession.setIsPlaying(true) + advanceTimeBy(1.seconds) + + verify(exactly = 1) { + callback(metricSession) + } + confirmVerified(callback) + + assertEquals(2.seconds, metricSession.timeToReady) + assertEquals(1.seconds, metricSession.totalPlayingDuration) + assertEquals(2.seconds, metricSession.totalBufferingDuration) + } + + @Test + fun `test stall count and duration`() = runTest { + val metricSession = SessionMetrics(timeProvider = { currentTime }, sessionMetricsReady = callback) + advanceTimeBy(100.seconds) + metricSession.setIsStall(true) + advanceTimeBy(1.seconds) + metricSession.setIsStall(false) + advanceTimeBy(10.seconds) + metricSession.setIsStall(true) + advanceTimeBy(1.seconds) + metricSession.setIsStall(false) + advanceTimeBy(10.seconds) + + assertEquals(2, metricSession.stallCount) + assertEquals(2.seconds, metricSession.totalStallDuration) + } +}