From 62b0ec7bb3c9db9ec9fbf7c228f8f2c79bf6f8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaquim=20St=C3=A4hli?= Date: Thu, 18 Jul 2024 08:37:08 +0200 Subject: [PATCH] 631 metrics collector (#638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gaëtan Muller Co-authored-by: Gaëtan Muller --- .../commandersact/CommandersActStreaming.kt | 2 +- .../CommandersActStreamingTest.kt | 1 + .../CommandersActTrackerIntegrationTest.kt | 8 +- .../pillarbox/player/PillarboxExoPlayer.kt | 52 ++- .../player/analytics/MetricsCollector.kt | 135 -------- .../analytics/PlaybackSessionManager.kt | 30 +- .../player/analytics/PlaybackStats.kt | 26 -- .../player/analytics/StallTracker.kt | 130 ------- .../player/analytics}/TotalPlaytimeCounter.kt | 13 +- .../player/analytics/extension/EventTime.kt | 13 + .../player/analytics/metrics/LoadingTimes.kt | 44 +++ .../analytics/metrics/MetricsCollector.kt | 318 ++++++++++++++++++ .../analytics/metrics/PlaybackMetrics.kt | 49 +++ .../player/qos/PillarboxEventsDispatcher.kt | 6 +- .../pillarbox/player/qos/QoSCoordinator.kt | 92 ++--- .../srgssr/pillarbox/player/qos/QoSSession.kt | 2 +- .../pillarbox/player/qos/QoSSessionTimings.kt | 20 +- .../player/qos/StartupTimesTracker.kt | 85 ----- .../player}/TotalPlaytimeCounterTest.kt | 3 +- .../player/analytics/LoadingTimesTest.kt | 125 +++++++ .../player/analytics/MetricsCollectorTest.kt | 148 ++++++++ .../analytics/PlaybackSessionManagerTest.kt | 4 +- .../player/qos/QoSEventsDispatcherTest.kt | 2 +- .../pillarbox/player/qos/QoSSessionTest.kt | 18 +- .../player/qos/QoSSessionTimingsTest.kt | 13 +- .../player/qos/StartupTimesTrackerTest.kt | 122 ------- 26 files changed, 842 insertions(+), 619 deletions(-) delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt rename {pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker => pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics}/TotalPlaytimeCounter.kt (82%) create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/extension/EventTime.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/LoadingTimes.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt create mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt delete mode 100644 pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt rename {pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker => pillarbox-player/src/test/java/ch/srgssr/pillarbox/player}/TotalPlaytimeCounterTest.kt (92%) create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/LoadingTimesTest.kt create mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/MetricsCollectorTest.kt delete mode 100644 pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt index 77745dbe6..4ab786c0e 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt +++ b/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreaming.kt @@ -12,7 +12,7 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener import ch.srgssr.pillarbox.analytics.commandersact.CommandersAct import ch.srgssr.pillarbox.analytics.commandersact.MediaEventType import ch.srgssr.pillarbox.analytics.commandersact.TCMediaEvent -import ch.srgssr.pillarbox.core.business.tracker.TotalPlaytimeCounter +import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter import ch.srgssr.pillarbox.player.extension.hasAccessibilityRoles import ch.srgssr.pillarbox.player.extension.isForced import ch.srgssr.pillarbox.player.tracks.audioTracks diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt index a27181415..5542a8bfe 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActStreamingTest.kt @@ -263,6 +263,7 @@ class CommandersActStreamingTest { return mockk { val player = this + every { player.playWhenReady } returns true every { player.isPlaying } returns isPlaying every { player.currentPosition } returns currentPosition every { player.isCurrentMediaItemLive } returns isCurrentMediaItemLive diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt index 8b33f69e8..1ce392351 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt +++ b/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/commandersact/CommandersActTrackerIntegrationTest.kt @@ -30,6 +30,7 @@ import ch.srgssr.pillarbox.core.business.utils.LocalMediaCompositionWithFallback import ch.srgssr.pillarbox.player.test.utils.TestPillarboxRunHelper import ch.srgssr.pillarbox.player.tracker.MediaItemTrackerRepository import io.mockk.Called +import io.mockk.clearAllMocks import io.mockk.confirmVerified import io.mockk.mockk import io.mockk.slot @@ -97,10 +98,9 @@ class CommandersActTrackerIntegrationTest { @AfterTest @OptIn(ExperimentalCoroutinesApi::class) fun tearDown() { + clearAllMocks() player.release() - shadowOf(Looper.getMainLooper()).idle() - Dispatchers.resetMain() } @@ -591,8 +591,7 @@ class CommandersActTrackerIntegrationTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) - fun `player pause, seeking and pause`() = runTest(testDispatcher) { + fun `player pause, seeking and pause`() { player.setMediaItem(SRGMediaItemBuilder(URN_NOT_LIVE_VIDEO).build()) player.prepare() player.playWhenReady = false @@ -600,7 +599,6 @@ class CommandersActTrackerIntegrationTest { TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) clock.advanceTime(2.seconds.inWholeMilliseconds) - advanceTimeBy(2.seconds) TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt index 8d0f153e6..d5fe10ad1 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/PillarboxExoPlayer.kt @@ -19,10 +19,10 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.LoadControl import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter -import ch.srgssr.pillarbox.player.analytics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsCollector import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.analytics.StallTracker +import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics import ch.srgssr.pillarbox.player.asset.timeRange.BlockedTimeRange import ch.srgssr.pillarbox.player.asset.timeRange.Chapter import ch.srgssr.pillarbox.player.asset.timeRange.Credit @@ -32,7 +32,6 @@ import ch.srgssr.pillarbox.player.extension.setSeekIncrements import ch.srgssr.pillarbox.player.qos.DummyQoSHandler import ch.srgssr.pillarbox.player.qos.PillarboxEventsDispatcher import ch.srgssr.pillarbox.player.qos.QoSCoordinator -import ch.srgssr.pillarbox.player.qos.StartupTimesTracker import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory import ch.srgssr.pillarbox.player.tracker.AnalyticsMediaItemTracker import ch.srgssr.pillarbox.player.tracker.CurrentMediaItemPillarboxDataTracker @@ -46,13 +45,12 @@ import kotlin.coroutines.CoroutineContext /** * Pillarbox player * - * @param context - * @param coroutineContext - * @param exoPlayer - * @param mediaItemTrackerProvider - * @param analyticsCollector - * - * @constructor + * @param context The context. + * @param coroutineContext The [CoroutineContext]. + * @param exoPlayer The underlying player. + * @param mediaItemTrackerProvider The [MediaItemTrackerProvider]. + * @param analyticsCollector The [PillarboxAnalyticsCollector]. + * @param metricsCollector The [MetricsCollector]. */ class PillarboxExoPlayer internal constructor( context: Context, @@ -60,13 +58,14 @@ class PillarboxExoPlayer internal constructor( private val exoPlayer: ExoPlayer, mediaItemTrackerProvider: MediaItemTrackerProvider, analyticsCollector: PillarboxAnalyticsCollector, + private val metricsCollector: MetricsCollector = MetricsCollector(), ) : PillarboxPlayer, ExoPlayer by exoPlayer { private val listeners = ListenerSet(applicationLooper, clock) { listener, flags -> listener.onEvents(this, Player.Events(flags)) } private val itemPillarboxDataTracker = CurrentMediaItemPillarboxDataTracker(this) private val analyticsTracker = AnalyticsMediaItemTracker(this, mediaItemTrackerProvider) - private val sessionManager = PlaybackSessionManager() + internal val sessionManager = PlaybackSessionManager() private val window = Window() override var smoothSeekingEnabled: Boolean = false set(value) { @@ -123,12 +122,13 @@ class PillarboxExoPlayer internal constructor( ) init { + sessionManager.setPlayer(this) + metricsCollector.setPlayer(this) QoSCoordinator( context = context, player = this, eventsDispatcher = PillarboxEventsDispatcher(sessionManager), - startupTimesTracker = StartupTimesTracker(), - metricsCollector = MetricsCollector(this), + metricsCollector = metricsCollector, messageHandler = DummyQoSHandler, sessionManager = sessionManager, coroutineContext = coroutineContext, @@ -141,7 +141,6 @@ class PillarboxExoPlayer internal constructor( if (BuildConfig.DEBUG) { addAnalyticsListener(PillarboxEventLogger()) } - addAnalyticsListener(StallTracker()) } constructor( @@ -170,6 +169,7 @@ class PillarboxExoPlayer internal constructor( clock: Clock, coroutineContext: CoroutineContext, analyticsCollector: PillarboxAnalyticsCollector = PillarboxAnalyticsCollector(clock), + metricsCollector: MetricsCollector = MetricsCollector() ) : this( context, coroutineContext, @@ -197,9 +197,31 @@ class PillarboxExoPlayer internal constructor( .setDeviceVolumeControlEnabled(true) // allow player to control device volume .build(), mediaItemTrackerProvider = mediaItemTrackerProvider, - analyticsCollector = analyticsCollector + analyticsCollector = analyticsCollector, + metricsCollector = metricsCollector, ) + /** + * Get current metrics + * @return `null` if there is no current metrics. + */ + fun getCurrentMetrics(): PlaybackMetrics? { + return metricsCollector.getCurrentMetrics() + } + + /** + * Get metrics for item [index] + * + * @param index The index in the timeline. + * @return `null` if there are no metrics. + */ + fun getMetricsFor(index: Int): PlaybackMetrics? { + if (currentTimeline.isEmpty) return null + currentTimeline.getWindow(index, window) + val periodUid = currentTimeline.getUidOfPeriod(window.firstPeriodIndex) + return sessionManager.getSessionFromPeriodUid(periodUid)?.let { metricsCollector.getMetricsForSession(it) } + } + override fun addListener(listener: Player.Listener) { exoPlayer.addListener(listener) if (listener is PillarboxPlayer.Listener) { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt deleted file mode 100644 index 0c271984a..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/MetricsCollector.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * 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.Format -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.exoplayer.DecoderCounters -import androidx.media3.exoplayer.DecoderReuseEvaluation -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.analytics.AnalyticsListener -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * Playback stats metrics - * Compute playback stats metrics likes stalls, playtime, bitrate, etc.. - */ -class MetricsCollector(private val player: ExoPlayer) : PillarboxAnalyticsListener { - - private var stallCount = 0 - private var lastStallTime = 0L - private var totalStallDuration = Duration.ZERO - private var lastIsPlayingTime = if (player.isPlaying) System.currentTimeMillis() else 0L - private var totalPlaytimeDuration = Duration.ZERO - - private var bandwidth = 0L - private var bufferDuration = Duration.ZERO - - private var audioFormat: Format? = player.audioFormat - private var videoFormat: Format? = player.videoFormat - - override fun onStallChanged(eventTime: AnalyticsListener.EventTime, isStall: Boolean) { - if (isStall) { - lastStallTime = System.currentTimeMillis() - stallCount++ - } else { - totalStallDuration += computeStallDuration() - lastStallTime = 0 - } - } - - override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { - if (isPlaying) { - lastIsPlayingTime = System.currentTimeMillis() - } else { - totalPlaytimeDuration += computePlaybackDuration() - lastIsPlayingTime = 0 - } - } - - override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { - if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { - reset() - } - } - - override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { - bandwidth = bitrateEstimate - } - - override fun onVideoInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { - videoFormat = format - } - - override fun onVideoDisabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { - videoFormat = null - } - - override fun onAudioInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { - audioFormat = format - } - - override fun onAudioDisabled(eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters) { - audioFormat = null - } - - override fun onEvents(player: Player, events: AnalyticsListener.Events) { - bufferDuration = player.totalBufferedDuration.milliseconds - } - - private fun computePlaybackDuration(): Duration { - return if (lastIsPlayingTime > 0) (System.currentTimeMillis() - lastIsPlayingTime).milliseconds - else Duration.ZERO - } - - private fun computeStallDuration(): Duration { - return if (lastStallTime > 0) (System.currentTimeMillis() - lastStallTime).milliseconds - else Duration.ZERO - } - - 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 - } - - private fun reset() { - stallCount = 0 - totalStallDuration = Duration.ZERO - lastStallTime = 0 - - lastIsPlayingTime = 0 - totalPlaytimeDuration = Duration.ZERO - - bufferDuration = Duration.ZERO - - audioFormat = player.audioFormat - videoFormat = player.videoFormat - if (player.isPlaying) { - lastIsPlayingTime = System.currentTimeMillis() - } - } - - /** - * Get current metrics - * - * @return metrics to the current time - */ - fun getCurrentMetrics(): PlaybackStats { - return PlaybackStats( - bandwidth = bandwidth, - bitrate = computeBitrate(), - bufferDuration = bufferDuration, - playbackDuration = totalPlaytimeDuration + computePlaybackDuration(), - stallCount = stallCount, - stallDuration = totalStallDuration + computeStallDuration() - ) - } -} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt index cdf56ec30..3328206b7 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManager.kt @@ -91,23 +91,14 @@ class PlaybackSessionManager { } /** - * Register player + * Set the player * * @param player */ - fun registerPlayer(player: ExoPlayer) { + fun setPlayer(player: ExoPlayer) { player.addAnalyticsListener(analyticsListener) } - /** - * Unregister player - * - * @param player - */ - fun unregisterPlayer(player: ExoPlayer) { - player.removeAnalyticsListener(analyticsListener) - } - /** * Add listener * @@ -148,8 +139,7 @@ class PlaybackSessionManager { /** * Get session from event time * - * @param eventTime - * @return + * @param eventTime The [AnalyticsListener.EventTime]. */ fun getSessionFromEventTime(eventTime: AnalyticsListener.EventTime): Session? { if (eventTime.timeline.isEmpty) { @@ -157,9 +147,16 @@ class PlaybackSessionManager { } eventTime.timeline.getWindow(eventTime.windowIndex, window) - val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) + return getSessionFromPeriodUid(periodUid) + } + /** + * Get session from a period uid + * + * @param periodUid The period uid. + */ + fun getSessionFromPeriodUid(periodUid: Any): Session? { return sessions[periodUid] } @@ -281,17 +278,16 @@ class PlaybackSessionManager { override fun onPlayerReleased(eventTime: AnalyticsListener.EventTime) { DebugLogger.debug(TAG, "onPlayerReleased") finishAllSessions() + listeners.clear() } private fun getOrCreateSession(eventTime: AnalyticsListener.EventTime): Session? { if (eventTime.timeline.isEmpty) { return null } - eventTime.timeline.getWindow(eventTime.windowIndex, window) - val periodUid = eventTime.timeline.getUidOfPeriod(window.firstPeriodIndex) - var session = sessions[periodUid] + var session = getSessionFromPeriodUid(periodUid) if (session == null) { val newSession = Session(periodUid, window.mediaItem) sessions[periodUid] = newSession diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt deleted file mode 100644 index 432a0e9bb..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/PlaybackStats.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.analytics - -import kotlin.time.Duration - -/** - * Represents a generic event, which contains metrics about the current media stream. - * - * @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, in milliseconds. - * @property playbackDuration The duration of the playback, in milliseconds. - * @property stallCount The number of stalls that have occurred, not as a result of a seek. - * @property stallDuration The total duration of the stalls, in milliseconds. - */ -data class PlaybackStats( - val bandwidth: Long, - val bitrate: Int, - val bufferDuration: Duration, - val playbackDuration: Duration, - val stallCount: Int, - val stallDuration: Duration, -) diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt deleted file mode 100644 index e83c0af01..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/StallTracker.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.analytics - -import android.util.Log -import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.source.LoadEventInfo -import androidx.media3.exoplayer.source.MediaLoadData -import java.io.IOException -import kotlin.time.Duration.Companion.milliseconds - -/** - * Stall tracker - * # Definition of a Stall - * A Stall occurs when the player is buffering during playback without user interaction. - */ -class StallTracker : AnalyticsListener { - private var stallCount = 0 - private var lastStallTime = 0L - private var stallDuration = 0L - private var lastIsPlaying = 0L - private var totalPlaytimeDuration = 0L - - private enum class State { - IDLE, - READY, - STALLED, - SEEKING, - } - - private var state: State = State.IDLE - set(value) { - if (value == field) return - if (value == State.STALLED) { - lastStallTime = System.currentTimeMillis() - stallCount++ - } - if (field == State.STALLED) { - stallDuration += System.currentTimeMillis() - lastStallTime - } - field = value - } - - private fun reset() { - state = State.IDLE - - Log.d(TAG, "Metrics: #Stalls = $stallCount duration = ${stallDuration.milliseconds} totalPlayTime = ${totalPlaytimeDuration.milliseconds}") - stallCount = 0 - lastStallTime = 0L - stallDuration = 0 - totalPlaytimeDuration = 0 - } - - override fun onMediaItemTransition(eventTime: AnalyticsListener.EventTime, mediaItem: MediaItem?, reason: Int) { - if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { - reset() - } - } - - override fun onPlayerError(eventTime: AnalyticsListener.EventTime, error: PlaybackException) { - reset() - } - - override fun onPlayerReleased(eventTime: AnalyticsListener.EventTime) { - reset() - } - - override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { - if (isPlaying) { - lastIsPlaying = System.currentTimeMillis() - } else { - totalPlaytimeDuration += System.currentTimeMillis() - lastIsPlaying - } - } - - @Suppress("ComplexCondition") - override fun onPositionDiscontinuity( - eventTime: AnalyticsListener.EventTime, - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - val isNotStalled = state != State.STALLED - val isSameMediaItem = oldPosition.mediaItemIndex == newPosition.mediaItemIndex - val isSeekDiscontinuity = reason == Player.DISCONTINUITY_REASON_SEEK || reason == Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT - - if (isNotStalled && isSameMediaItem && isSeekDiscontinuity) { - state = State.SEEKING - } - } - - override fun onLoadError( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData, - error: IOException, - wasCanceled: Boolean - ) { - if (state == State.READY || state == State.SEEKING) { - state = State.STALLED - } - } - - override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, playbackState: Int) { - when (playbackState) { - Player.STATE_READY -> { - state = State.READY - } - - Player.STATE_BUFFERING -> { - if (state == State.READY) { - state = State.STALLED - } - } - - else -> { - reset() - } - } - } - - companion object { - private const val TAG = "Stalls" - } -} diff --git a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounter.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/TotalPlaytimeCounter.kt similarity index 82% rename from pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounter.kt rename to pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/TotalPlaytimeCounter.kt index 47d4f741d..57c2faf04 100644 --- a/pillarbox-core-business/src/main/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounter.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/TotalPlaytimeCounter.kt @@ -2,9 +2,10 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.core.business.tracker +package ch.srgssr.pillarbox.player.analytics import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds /** @@ -15,13 +16,21 @@ import kotlin.time.Duration.Companion.milliseconds class TotalPlaytimeCounter internal constructor( private val timeProvider: () -> Long, ) { - private var totalPlayTime: Duration = Duration.ZERO + private var totalPlayTime: Duration = ZERO private var lastPlayTime = 0L constructor() : this( timeProvider = { System.currentTimeMillis() }, ) + /** + * Reset total playtime to zero + */ + fun reset() { + totalPlayTime = ZERO + lastPlayTime = 0L + } + /** * Play * Calling twice play after sometime will compute totalPlaytime diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/extension/EventTime.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/extension/EventTime.kt new file mode 100644 index 000000000..69e5d09ab --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/extension/EventTime.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics.extension + +import androidx.media3.common.Timeline.Window +import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime + +internal fun EventTime.getUidOfPeriod(window: Window): Any { + timeline.getWindow(windowIndex, window) + return timeline.getUidOfPeriod(window.firstPeriodIndex) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/LoadingTimes.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/LoadingTimes.kt new file mode 100644 index 000000000..f3f8b31aa --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/LoadingTimes.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics.metrics + +import androidx.media3.common.Player +import ch.srgssr.pillarbox.player.utils.StringUtil +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class LoadingTimes( + private val onLoadingReady: () -> Unit, + private val timeProvider: () -> Long = { System.currentTimeMillis() }, + var source: Duration? = null, + var manifest: Duration? = null, + var asset: Duration? = null, + var drm: Duration? = null, +) { + private var bufferingStartTime: Long = 0L + var timeToReady: Duration? = null + private set + + var state: @Player.State Int = Player.STATE_IDLE + set(value) { + if (field == value) return + if (field == Player.STATE_READY && value == Player.STATE_BUFFERING) return + + if ((field == Player.STATE_IDLE || field == Player.STATE_ENDED) && value == Player.STATE_BUFFERING) { + bufferingStartTime = timeProvider() + } + if (field == Player.STATE_BUFFERING && value == Player.STATE_READY) { + timeToReady = (timeProvider() - bufferingStartTime).milliseconds + } + field = value + if (field == Player.STATE_READY) { + onLoadingReady() + } + } + + override fun toString(): String { + return "LoadingTimes(bufferingStartTime=$bufferingStartTime, timeToReady=$timeToReady, state=${StringUtil.playerStateString(state)})" + } +} 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 new file mode 100644 index 000000000..954be5a35 --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/MetricsCollector.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +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.exoplayer.DecoderCounters +import androidx.media3.exoplayer.DecoderReuseEvaluation +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime +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 + * Compute playback stats metrics likes stalls, playtime, bitrate, etc... + */ +class MetricsCollector @VisibleForTesting private constructor( + private val timeProvider: () -> Long, +) : PillarboxAnalyticsListener, PlaybackSessionManager.Listener { + /** + * Listener + */ + interface Listener { + /** + * On metric session finished + * + * @param metrics The [PlaybackMetrics] that belong to te finished session. + */ + fun onMetricSessionFinished(metrics: PlaybackMetrics) = Unit + + /** + * On metric session ready + * + * @param metrics + */ + 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 + + constructor() : this({ System.currentTimeMillis() }) + + /** + * Set player at [PillarboxExoPlayer] creation. + */ + fun setPlayer(player: PillarboxExoPlayer) { + player.sessionManager.addListener(this) + player.addAnalyticsListener(this) + this.player = player + } + + /** + * Add listener + * + * @param listener + */ + fun addListener(listener: Listener) { + listeners.add(listener) + } + + /** + * Remove listener + * + * @param listener + */ + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + private fun notifyMetricsFinished(metrics: PlaybackMetrics) { + listeners.toList().forEach { + it.onMetricSessionFinished(metrics) + } + } + + private fun notifyMetricsReady(metrics: PlaybackMetrics) { + if (currentSession?.sessionId != metrics.sessionId) return + DebugLogger.debug(TAG, "notifyMetricsReady $metrics") + listeners.toList().forEach { + it.onMetricSessionReady(metrics) + } + } + + override fun onSessionCreated(session: PlaybackSessionManager.Session) { + getOrCreateLoadingTimes(session.periodUid) + } + + override fun onSessionFinished(session: PlaybackSessionManager.Session) { + getMetricsForSession(session)?.let { + DebugLogger.debug(TAG, "onSessionFinished: $it") + notifyMetricsFinished(it) + } + 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) + } + } + + private fun getOrCreateLoadingTimes(periodUid: Any): LoadingTimes { + return loadingTimes.getOrPut(periodUid) { + LoadingTimes(timeProvider = timeProvider, onLoadingReady = { + player.sessionManager.getSessionFromPeriodUid(periodUid)?.let { + getMetricsForSession(it)?.let(this::notifyMetricsReady) + } + }) + } + } + + override fun onStallChanged(eventTime: EventTime, isStall: Boolean) { + if (isStall) { + totalStallTimeCounter.play() + stallCount++ + } else { + totalStallTimeCounter.pause() + } + } + + override fun onIsPlayingChanged(eventTime: EventTime, isPlaying: Boolean) { + if (isPlaying) { + totalPlaytimeCounter.play() + } else { + totalPlaytimeCounter.pause() + } + } + + override fun onBandwidthEstimate(eventTime: EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { + bandwidth = bitrateEstimate + } + + override fun onVideoInputFormatChanged(eventTime: EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { + videoFormat = format + } + + override fun onVideoDisabled(eventTime: EventTime, decoderCounters: DecoderCounters) { + videoFormat = null + } + + override fun onAudioInputFormatChanged(eventTime: EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) { + 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 + } + + override fun onPlaybackStateChanged(eventTime: EventTime, state: Int) { + updateStartupTimeWithState(eventTime, state) + when (state) { + Player.STATE_BUFFERING -> { + totalBufferingTimeCounter.play() + } + + Player.STATE_READY -> { + totalBufferingTimeCounter.pause() + } + } + } + + override fun onRenderedFirstFrame(eventTime: EventTime, output: Any, renderTimeMs: Long) { + updateStartupTimeWithState(eventTime, player.playbackState) + } + + override fun onAudioPositionAdvancing(eventTime: EventTime, playoutStartSystemTimeMs: Long) { + updateStartupTimeWithState(eventTime, player.playbackState) + } + + override fun onEvents(player: Player, events: AnalyticsListener.Events) { + bufferDuration = player.totalBufferedDuration.milliseconds + } + + 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 + } + } + + PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> { + if (loadingTimes.asset == null) { + loadingTimes.asset = loadDuration + } + } + + else -> { + } + } + } + + override fun onPlayerReleased(eventTime: EventTime) { + listeners.clear() + } + + 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 + } + + private fun reset() { + stallCount = 0 + totalStallTimeCounter.reset() + totalPlaytimeCounter.reset() + totalBufferingTimeCounter.reset() + + bufferDuration = Duration.ZERO + + audioFormat = player.audioFormat + videoFormat = player.videoFormat + if (player.isPlaying) { + totalPlaytimeCounter.play() + } + } + + /** + * Get current metrics + * + * @return metrics to the current time + */ + fun getCurrentMetrics(): PlaybackMetrics? { + return currentSession?.let { + getMetricsForSession(it) + } + } + + /** + * Get metrics for session + * + * @param session + * @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() + ) + } + + 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 new file mode 100644 index 000000000..558ca4a1c --- /dev/null +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/analytics/metrics/PlaybackMetrics.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics.metrics + +import kotlin.time.Duration + +/** + * Represents a generic event, which contains metrics about the current media stream. + * + * @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 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. + */ +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() +) { + + /** + * Load duration + * Represents the timings until the current media started to play. + * @property source The time spent to load the media source. + * @property manifest The time spent to load the main manifest if applicable. + * @property asset The time spent to load the asset. + * @property drm The time spent to load the DRM. + * @property timeToReady The time spent to load from the moment the [MediaItem][androidx.media3.common.MediaItem] became the current item until + * it started to play. + */ + data class LoadDuration( + val source: Duration? = null, + val manifest: Duration? = null, + val asset: Duration? = null, + val drm: Duration? = null, + val timeToReady: Duration? = null + ) +} diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt index a29a8e6e9..24c48eac0 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/PillarboxEventsDispatcher.kt @@ -7,7 +7,6 @@ package ch.srgssr.pillarbox.player.qos import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK -import androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT import androidx.media3.common.Player.DiscontinuityReason import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime @@ -15,7 +14,6 @@ import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager import ch.srgssr.pillarbox.player.qos.QoSEventsDispatcher.Listener import ch.srgssr.pillarbox.player.utils.DebugLogger -import ch.srgssr.pillarbox.player.utils.StringUtil /** * Pillarbox provided implementation of [QoSEventsDispatcher]. @@ -59,9 +57,7 @@ class PillarboxEventsDispatcher( val oldItemIndex = oldPosition.mediaItemIndex val newItemIndex = newPosition.mediaItemIndex - DebugLogger.debug(TAG, "onPositionDiscontinuity reason = ${StringUtil.discontinuityReasonString(reason)}") - - if (oldItemIndex == newItemIndex && reason == DISCONTINUITY_REASON_SEEK || reason == DISCONTINUITY_REASON_SEEK_ADJUSTMENT) { + if (oldItemIndex == newItemIndex && reason == DISCONTINUITY_REASON_SEEK) { val session = sessionManager.getCurrentSession() ?: return notifyListeners { onSeek(session) } 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 65702fcda..6889a92eb 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 @@ -5,15 +5,16 @@ package ch.srgssr.pillarbox.player.qos import android.content.Context +import android.util.Log import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.source.LoadEventInfo import androidx.media3.exoplayer.source.MediaLoadData -import ch.srgssr.pillarbox.player.analytics.MetricsCollector import ch.srgssr.pillarbox.player.analytics.PillarboxAnalyticsListener import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.analytics.PlaybackStats +import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics import ch.srgssr.pillarbox.player.utils.DebugLogger import ch.srgssr.pillarbox.player.utils.Heartbeat import kotlin.coroutines.CoroutineContext @@ -23,12 +24,11 @@ internal class QoSCoordinator( private val context: Context, private val player: ExoPlayer, private val eventsDispatcher: QoSEventsDispatcher, - private val startupTimesTracker: StartupTimesTracker, private val metricsCollector: MetricsCollector, private val messageHandler: QoSMessageHandler, private val sessionManager: PlaybackSessionManager, coroutineContext: CoroutineContext, -) : PillarboxAnalyticsListener { +) : PillarboxAnalyticsListener, MetricsCollector.Listener { private val heartbeat = Heartbeat( period = HEARTBEAT_PERIOD, coroutineContext = coroutineContext, @@ -47,15 +47,34 @@ internal class QoSCoordinator( eventsDispatcher.registerPlayer(player) eventsDispatcher.addListener(eventsDispatcherListener) - eventsDispatcher.addListener(startupTimesTracker) - sessionManager.registerPlayer(player) sessionManager.addListener(eventsDispatcherListener) - sessionManager.addListener(startupTimesTracker) - - player.addAnalyticsListener(startupTimesTracker) - player.addAnalyticsListener(metricsCollector) player.addAnalyticsListener(this) + metricsCollector.addListener(this) + } + + override fun onMetricSessionReady(metrics: PlaybackMetrics) { + DebugLogger.info(TAG, "onMetricSessionReady $metrics") + + heartbeat.start(restart = false) + sessionManager.getSessionById(metrics.sessionId)?.let { + sendStartEvent( + session = it, + timings = QoSSessionTimings( + asset = metrics.loadDuration.asset, + mediaSource = metrics.loadDuration.source, + currentToStart = metrics.loadDuration.timeToReady, + drm = metrics.loadDuration.drm + ) + ) + } + } + + override fun onMetricSessionFinished(metrics: PlaybackMetrics) { + heartbeat.stop() + sessionManager.getSessionById(metrics.sessionId)?.let { + sendEvent("END", it) + } ?: Log.wtf(TAG, "Should have a session!") } override fun onEvents(player: Player, events: AnalyticsListener.Events) { @@ -76,16 +95,16 @@ internal class QoSCoordinator( session: PlaybackSessionManager.Session, data: Any? = null, ) { + val dataToSend = data ?: metricsCollector.getCurrentMetrics()?.toQoSEvent() ?: return val message = QoSMessage( - data = data ?: metricsCollector.getCurrentMetrics().toQoSEvent(), + data = dataToSend, eventName = eventName, sessionId = session.sessionId, ) - messageHandler.sendEvent(message) } - private fun PlaybackStats.toQoSEvent(): QoSEvent { + private fun PlaybackMetrics.toQoSEvent(): QoSEvent { val bitrateBytes = bitrate / BITS val bandwidthBytes = bandwidth / BITS return QoSEvent( @@ -107,17 +126,10 @@ internal class QoSCoordinator( } override fun onSessionFinished(session: PlaybackSessionManager.Session) { - heartbeat.stop() - sendEvent("END", session) currentSession = null } override fun onMediaStart(session: PlaybackSessionManager.Session) { - val startupTimes = startupTimesTracker.consumeStartupTimes(session.sessionId) ?: return - - heartbeat.start(restart = false) - - sendStartEvent(session, startupTimes) } override fun onIsPlaying( @@ -141,7 +153,7 @@ internal class QoSCoordinator( override fun onError(session: PlaybackSessionManager.Session) { if (sessionManager.getSessionById(session.sessionId) == null) { - sendStartEvent(session, QoSSessionTimings.Zero) + sendStartEvent(session, QoSSessionTimings.Empty) } player.playerError?.let { @@ -160,32 +172,24 @@ internal class QoSCoordinator( override fun onPlayerReleased() { eventsDispatcher.unregisterPlayer(player) eventsDispatcher.removeListener(this) - eventsDispatcher.removeListener(startupTimesTracker) - - sessionManager.unregisterPlayer(player) sessionManager.removeListener(this) - sessionManager.removeListener(startupTimesTracker) - - player.removeAnalyticsListener(startupTimesTracker) - player.removeAnalyticsListener(metricsCollector) - player.removeAnalyticsListener(this@QoSCoordinator) } + } - private fun sendStartEvent( - session: PlaybackSessionManager.Session, - timings: QoSSessionTimings, - ) { - sendEvent( - eventName = "START", - session = session, - data = QoSSession( - context = context, - mediaId = session.mediaItem.mediaId, - mediaSource = session.mediaItem.localConfiguration?.uri.toString(), - timings = timings, - ), - ) - } + private fun sendStartEvent( + session: PlaybackSessionManager.Session, + timings: QoSSessionTimings, + ) { + sendEvent( + eventName = "START", + session = session, + data = QoSSession( + context = context, + mediaId = session.mediaItem.mediaId, + mediaSource = session.mediaItem.localConfiguration?.uri.toString(), + timings = timings, + ), + ) } private companion object { diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt index c63c7d1ea..1405c01e6 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSession.kt @@ -43,7 +43,7 @@ data class QoSSession( val playerVersion: String = PLAYER_VERSION, val screenHeight: Int, val screenWidth: Int, - val timings: QoSSessionTimings = QoSSessionTimings.Zero, + val timings: QoSSessionTimings = QoSSessionTimings.Empty, ) { /** * The type of device. diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt index 6db8bf8b1..2128a86e8 100644 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt +++ b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimings.kt @@ -16,20 +16,20 @@ import kotlin.time.Duration * @property mediaSource The time spent to load the media source. */ data class QoSSessionTimings( - val asset: Duration, - val currentToStart: Duration, - val drm: Duration, - val mediaSource: Duration, + val asset: Duration? = null, + val currentToStart: Duration? = null, + val drm: Duration? = null, + val mediaSource: Duration? = null, ) { companion object { /** - * Default [QoSSessionTimings] where all fields are a duration of zero. + * Default [QoSSessionTimings] where all fields are set to `null`. */ - val Zero = QoSSessionTimings( - asset = Duration.ZERO, - currentToStart = Duration.ZERO, - drm = Duration.ZERO, - mediaSource = Duration.ZERO, + val Empty = QoSSessionTimings( + asset = null, + currentToStart = null, + drm = null, + mediaSource = null, ) } } diff --git a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt b/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt deleted file mode 100644 index 3a13480a6..000000000 --- a/pillarbox-player/src/main/java/ch/srgssr/pillarbox/player/qos/StartupTimesTracker.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.qos - -import androidx.media3.common.C -import androidx.media3.common.Timeline -import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.exoplayer.source.LoadEventInfo -import androidx.media3.exoplayer.source.MediaLoadData -import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import ch.srgssr.pillarbox.player.source.PillarboxMediaSource -import kotlin.time.Duration.Companion.milliseconds - -internal class StartupTimesTracker : AnalyticsListener, PlaybackSessionManager.Listener, QoSEventsDispatcher.Listener { - private val loadingSessions = mutableSetOf() - private val periodUidToSessionId = mutableMapOf() - private val currentSessionToMediaStart = mutableMapOf() - private val qosSessionsTimings = mutableMapOf() - private val window = Timeline.Window() - - fun consumeStartupTimes(sessionId: String): QoSSessionTimings? { - if (loadingSessions.remove(sessionId)) { - val sessionTimings = checkNotNull(qosSessionsTimings.remove(sessionId)) - val currentSessionToMediaStart = currentSessionToMediaStart.remove(sessionId) - - return if (currentSessionToMediaStart != null) { - sessionTimings.copy(currentToStart = (System.currentTimeMillis() - currentSessionToMediaStart).milliseconds) - } else { - sessionTimings - } - } - - return null - } - - override fun onSessionCreated(session: PlaybackSessionManager.Session) { - loadingSessions.add(session.sessionId) - periodUidToSessionId[session.periodUid] = session.sessionId - qosSessionsTimings[session.sessionId] = QoSSessionTimings.Zero - } - - override fun onCurrentSession(session: PlaybackSessionManager.Session) { - currentSessionToMediaStart[session.sessionId] = System.currentTimeMillis() - } - - override fun onSessionFinished(session: PlaybackSessionManager.Session) { - loadingSessions.remove(session.sessionId) - periodUidToSessionId.remove(session.periodUid) - qosSessionsTimings.remove(session.sessionId) - } - - override fun onLoadCompleted( - eventTime: AnalyticsListener.EventTime, - loadEventInfo: LoadEventInfo, - mediaLoadData: MediaLoadData, - ) { - val sessionId = getSessionId(eventTime) - if (sessionId == null || sessionId !in loadingSessions || sessionId !in qosSessionsTimings) { - return - } - - val qosSessionTimings = qosSessionsTimings.getValue(sessionId) - val loadDuration = loadEventInfo.loadDurationMs.milliseconds - - qosSessionsTimings[sessionId] = when (mediaLoadData.dataType) { - C.DATA_TYPE_DRM -> qosSessionTimings.copy(drm = qosSessionTimings.drm + loadDuration) - C.DATA_TYPE_MANIFEST, C.DATA_TYPE_MEDIA -> qosSessionTimings.copy(mediaSource = qosSessionTimings.mediaSource + loadDuration) - PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> qosSessionTimings.copy(asset = qosSessionTimings.asset + loadDuration) - else -> qosSessionTimings - } - } - - private fun getSessionId(eventTime: AnalyticsListener.EventTime): String? { - val timeline = eventTime.timeline - if (timeline.isEmpty) { - return null - } - - val firstPeriodIndex = timeline.getWindow(eventTime.windowIndex, window).firstPeriodIndex - val periodUid = timeline.getUidOfPeriod(firstPeriodIndex) - return periodUidToSessionId[periodUid] - } -} diff --git a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounterTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TotalPlaytimeCounterTest.kt similarity index 92% rename from pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounterTest.kt rename to pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TotalPlaytimeCounterTest.kt index 5890bb1bc..7f338c647 100644 --- a/pillarbox-core-business/src/test/java/ch/srgssr/pillarbox/core/business/tracker/TotalPlaytimeCounterTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/TotalPlaytimeCounterTest.kt @@ -2,8 +2,9 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.core.business.tracker +package ch.srgssr.pillarbox.player +import ch.srgssr.pillarbox.player.analytics.TotalPlaytimeCounter import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.currentTime diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/LoadingTimesTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/LoadingTimesTest.kt new file mode 100644 index 000000000..66e2d9f8e --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/LoadingTimesTest.kt @@ -0,0 +1,125 @@ +/* + * 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 ch.srgssr.pillarbox.player.analytics.metrics.LoadingTimes +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 kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class LoadingTimesTest { + @AfterTest + fun tearDown() { + clearAllMocks() + } + + @Test + fun `buffering to ready call onLoadingReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + advanceTimeBy(10.seconds) + + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_READY + + verify(exactly = 1) { + callback() + } + confirmVerified(callback) + val timeToReady = loadingTimes.timeToReady + assertNotNull(timeToReady) + assertEquals(20.seconds, timeToReady) + } + + @Test + fun `not ready don't call onLoadingReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + advanceTimeBy(10.seconds) + + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_ENDED + + verify(exactly = 0) { + callback() + } + confirmVerified(callback) + assertNull(loadingTimes.timeToReady) + } + + @Test + fun `initialization to READY call onLoadingReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_READY + + verify(exactly = 1) { + callback() + } + confirmVerified(callback) + val timeToReady = loadingTimes.timeToReady + assertNull(timeToReady) + } + + @Test + fun `call twice ready call once onLoadingReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_READY + advanceTimeBy(5.seconds) + loadingTimes.state = Player.STATE_READY + + verify(exactly = 1) { + callback() + } + confirmVerified(callback) + val timeToReady = loadingTimes.timeToReady + assertNotNull(timeToReady) + assertEquals(20.seconds, timeToReady) + } + + @Test + fun `twice buffering and ready keep only the first timeToReady`() = runTest { + val callback = mockk<() -> Unit>(relaxed = true) + val loadingTimes = LoadingTimes(onLoadingReady = callback, timeProvider = { currentTime }) + + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(20.seconds) + loadingTimes.state = Player.STATE_READY + + advanceTimeBy(5.seconds) + loadingTimes.state = Player.STATE_BUFFERING + advanceTimeBy(5.seconds) + loadingTimes.state = Player.STATE_READY + + verify(exactly = 1) { + callback() + } + confirmVerified(callback) + val timeToReady = loadingTimes.timeToReady + assertNotNull(timeToReady) + assertEquals(20.seconds, timeToReady) + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/MetricsCollectorTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/MetricsCollectorTest.kt new file mode 100644 index 000000000..20e2c63d6 --- /dev/null +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/MetricsCollectorTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.player.analytics + +import android.content.Context +import android.os.Looper +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.test.utils.FakeClock +import androidx.media3.test.utils.robolectric.TestPlayerRunHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import ch.srgssr.pillarbox.player.PillarboxExoPlayer +import ch.srgssr.pillarbox.player.SeekIncrement +import ch.srgssr.pillarbox.player.analytics.metrics.MetricsCollector +import ch.srgssr.pillarbox.player.analytics.metrics.PlaybackMetrics +import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory +import io.mockk.clearAllMocks +import io.mockk.clearMocks +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.AfterTest +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import kotlin.time.Duration + +@RunWith(AndroidJUnit4::class) +class MetricsCollectorTest { + + private lateinit var player: PillarboxExoPlayer + private lateinit var metricsCollector: MetricsCollector + private lateinit var fakeClock: FakeClock + private lateinit var metricsListener: MetricsCollector.Listener + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + metricsListener = mockk(relaxed = true) + fakeClock = FakeClock(true) + metricsCollector = MetricsCollector() + player = PillarboxExoPlayer( + context = context, + seekIncrement = SeekIncrement(), + loadControl = DefaultLoadControl(), + clock = fakeClock, + coroutineContext = EmptyCoroutineContext, + mediaSourceFactory = PillarboxMediaSourceFactory(context), + metricsCollector = metricsCollector + ) + metricsCollector.addListener(metricsListener) + player.prepare() + + clearMocks(metricsListener) + } + + @AfterTest + fun tearDown() { + clearAllMocks() + player.release() + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `single item playback`() { + player.setMediaItem(VOD1) + player.play() + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // Session is finished when starting another media or when there is no more current item + player.clearMediaItems() + player.stop() + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val slotReady = slot() + val slotFinished = slot() + verify { + metricsListener.onMetricSessionReady(capture(slotReady)) + metricsListener.onMetricSessionFinished(capture(slotFinished)) + } + confirmVerified(metricsListener) + + assertTrue(slotReady.isCaptured) + slotReady.captured.also { + Assert.assertNotNull(it.loadDuration.source) + Assert.assertNotNull(it.loadDuration.manifest) + Assert.assertNotNull(it.loadDuration.timeToReady) + Assert.assertNotNull(it.loadDuration.asset) + Assert.assertNull(it.loadDuration.drm) + assertEquals(Duration.ZERO, it.playbackDuration) + } + + assertTrue(slotFinished.isCaptured) + slotFinished.captured.also { + Assert.assertNotNull(it.loadDuration.source) + Assert.assertNotNull(it.loadDuration.manifest) + Assert.assertNotNull(it.loadDuration.timeToReady) + Assert.assertNotNull(it.loadDuration.asset) + Assert.assertNull(it.loadDuration.drm) + assertNotEquals(Duration.ZERO, it.playbackDuration) + } + } + + @Test + fun `playback item transition`() { + player.setMediaItems(listOf(VOD1, VOD2)) + player.play() + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED) + + // To ensure that the final `onSessionFinished` is triggered. + player.clearMediaItems() + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player) + + val startedMetrics = mutableListOf() + val finishedMetrics = mutableListOf() + verify { + metricsListener.onMetricSessionReady(capture(startedMetrics)) + metricsListener.onMetricSessionFinished(capture(finishedMetrics)) + } + confirmVerified(metricsListener) + + assertEquals(2, finishedMetrics.size) + assertNotEquals(finishedMetrics[0].sessionId, finishedMetrics[1].sessionId) + + assertEquals(2, startedMetrics.size) + assertNotEquals(startedMetrics[0].sessionId, startedMetrics[1].sessionId) + } + + private companion object { + private val VOD1 = MediaItem.fromUri("https://rts-vod-amd.akamaized.net/ww/13444390/f1b478f7-2ae9-3166-94b9-c5d5fe9610df/master.m3u8") + private val VOD2 = MediaItem.fromUri("https://rts-vod-amd.akamaized.net/ww/13444333/feb1d08d-e62c-31ff-bac9-64c0a7081612/master.m3u8") + } +} diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt index 433e63b0c..4b7ceec81 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/analytics/PlaybackSessionManagerTest.kt @@ -51,7 +51,7 @@ class PlaybackSessionManagerTest { } sessionManager = PlaybackSessionManager().apply { - registerPlayer(player) + setPlayer(player) addListener(sessionManagerListener) } @@ -61,8 +61,6 @@ class PlaybackSessionManagerTest { @AfterTest fun tearDown() { clearAllMocks() - sessionManager.unregisterPlayer(player) - sessionManager.removeListener(sessionManagerListener) player.release() shadowOf(Looper.getMainLooper()).idle() } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt index 26bfab447..da1ce7788 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSEventsDispatcherTest.kt @@ -46,7 +46,7 @@ class QoSEventsDispatcherTest { } val sessionManager = PlaybackSessionManager().apply { - registerPlayer(player) + setPlayer(player) } PillarboxEventsDispatcher(sessionManager).apply { diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt index 28fe635e6..966ec0db6 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTest.kt @@ -29,7 +29,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -48,7 +48,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -67,7 +67,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -86,7 +86,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -105,7 +105,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -124,7 +124,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -143,7 +143,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } @Test @@ -162,7 +162,7 @@ class QoSSessionTest { assertEquals("Local", qosSession.playerVersion) assertEquals(470, qosSession.screenHeight) assertEquals(320, qosSession.screenWidth) - assertEquals(QoSSessionTimings.Zero, qosSession.timings) + assertEquals(QoSSessionTimings.Empty, qosSession.timings) } private fun createQoSSession(): QoSSession { @@ -172,7 +172,7 @@ class QoSSessionTest { context = context, mediaId = "urn:rts:video:12345", mediaSource = "https://il-stage.srgssr.ch/integrationlayer/2.1/mediaComposition/byUrn/urn:rts:video:12345?vector=APPPLAY", - timings = QoSSessionTimings.Zero, + timings = QoSSessionTimings.Empty, ) } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt index 4925dbe4c..37e499a39 100644 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt +++ b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/QoSSessionTimingsTest.kt @@ -5,17 +5,16 @@ package ch.srgssr.pillarbox.player.qos import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration +import kotlin.test.assertNull class QoSSessionTimingsTest { @Test fun `zero timings`() { - val timings = QoSSessionTimings.Zero + val timings = QoSSessionTimings.Empty - assertEquals(Duration.ZERO, timings.asset) - assertEquals(Duration.ZERO, timings.currentToStart) - assertEquals(Duration.ZERO, timings.drm) - assertEquals(Duration.ZERO, timings.mediaSource) + assertNull(timings.asset) + assertNull(timings.currentToStart) + assertNull(timings.drm) + assertNull(timings.mediaSource) } } diff --git a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt b/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt deleted file mode 100644 index a12eb39f5..000000000 --- a/pillarbox-player/src/test/java/ch/srgssr/pillarbox/player/qos/StartupTimesTrackerTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) SRG SSR. All rights reserved. - * License information is available from the LICENSE file. - */ -package ch.srgssr.pillarbox.player.qos - -import android.content.Context -import android.os.Looper -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.test.utils.FakeClock -import androidx.media3.test.utils.robolectric.TestPlayerRunHelper -import androidx.test.core.app.ApplicationProvider -import ch.srgssr.pillarbox.player.PillarboxExoPlayer -import ch.srgssr.pillarbox.player.analytics.MetricsCollector -import ch.srgssr.pillarbox.player.analytics.PlaybackSessionManager -import org.junit.runner.RunWith -import org.robolectric.ParameterizedRobolectricTestRunner -import org.robolectric.ParameterizedRobolectricTestRunner.Parameters -import org.robolectric.Shadows.shadowOf -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.time.Duration.Companion.seconds - -@RunWith(ParameterizedRobolectricTestRunner::class) -class StartupTimesTrackerTest( - private val mediaUrls: List, -) { - private lateinit var player: Player - private lateinit var startupTimesTracker: StartupTimesTracker - private lateinit var sessionId: String - - @BeforeTest - fun setUp() { - startupTimesTracker = StartupTimesTracker() - player = createPlayer(mediaUrls) - - TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY) - - mediaUrls.forEachIndexed { index, _ -> - player.seekTo(5.seconds.inWholeMilliseconds) - player.seekToNextMediaItem() - } - } - - @Test - fun `consume startup times`() { - val startupTimes = startupTimesTracker.consumeStartupTimes(sessionId) - - assertNotNull(startupTimes) - assertNull(startupTimesTracker.consumeStartupTimes(sessionId)) - } - - @AfterTest - fun tearDown() { - player.release() - - shadowOf(Looper.getMainLooper()).idle() - } - - private fun createPlayer(mediaUrls: List): Player { - val context = ApplicationProvider.getApplicationContext() - val coroutineContext = EmptyCoroutineContext - - return PillarboxExoPlayer( - context = context, - clock = FakeClock(true), - coroutineContext = coroutineContext, - ).apply { - val mediaItems = mediaUrls.map(MediaItem::fromUri) - val sessionManager = PlaybackSessionManager() - sessionManager.registerPlayer(this) - sessionManager.addListener(object : PlaybackSessionManager.Listener { - override fun onSessionCreated(session: PlaybackSessionManager.Session) { - sessionId = session.sessionId - } - }) - - QoSCoordinator( - context = context, - player = this, - eventsDispatcher = PillarboxEventsDispatcher(sessionManager), - startupTimesTracker = startupTimesTracker, - metricsCollector = MetricsCollector(this), - messageHandler = DummyQoSHandler, - sessionManager = sessionManager, - coroutineContext = coroutineContext, - ) - - addMediaItems(mediaItems) - addAnalyticsListener(startupTimesTracker) - prepare() - play() - } - } - - companion object { - @JvmStatic - @Parameters(name = "{index}: {0}") - fun parameters(): Iterable { - return listOf( - // AOD only - listOf("https://rts-aod-dd.akamaized.net/ww/14965091/0ba47e81-57d5-3bc0-b3e5-18c93cc84da3.mp3"), - // VOD only - listOf("https://rts-vod-amd.akamaized.net/ww/14981648/85b72399-a98c-3455-bdad-70c82cdf0a30/master.m3u8"), - // Live DVR audio only - listOf("https://lsaplus.swisstxt.ch/audio/couleur3_96.stream/playlist.m3u8"), - // Live DVR video only - listOf("https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8"), - // Playlist with mixed content - listOf( - "https://rts-aod-dd.akamaized.net/ww/14967482/effca0a1-d59d-3b64-b7eb-cc58a4ad75d6.mp3", - "https://rts-vod-amd.akamaized.net/ww/14983127/c9a205b7-0b47-35ac-989a-9306883470bf/master.m3u8", - ), - ) - } - } -}