Skip to content

Commit

Permalink
Add models for QoS and an AnalyticsListener to gather session load …
Browse files Browse the repository at this point in the history
…times (#596)

Co-authored-by: Joaquim Stähli <[email protected]>
  • Loading branch information
MGaetan89 and StaehliJ committed Jul 17, 2024
1 parent 9a57d2d commit 0d985f6
Show file tree
Hide file tree
Showing 14 changed files with 793 additions and 3 deletions.
6 changes: 5 additions & 1 deletion pillarbox-player/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ plugins {
}

android {
defaultConfig {
buildConfigField("String", "VERSION_NAME", "\"${version}\"")
}

buildFeatures {
buildConfig = true
}
Expand Down Expand Up @@ -54,7 +58,7 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.mockk.dsl)
testRuntimeOnly(libs.robolectric)
testImplementation(libs.robolectric)
testImplementation(libs.robolectric.annotations)
testImplementation(libs.robolectric.shadows.framework)
testImplementation(libs.turbine)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package ch.srgssr.pillarbox.player

import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
Expand All @@ -26,6 +27,8 @@ import ch.srgssr.pillarbox.player.asset.timeRange.Credit
import ch.srgssr.pillarbox.player.extension.getPlaybackSpeed
import ch.srgssr.pillarbox.player.extension.setPreferredAudioRoleFlagsToAccessibilityManagerSettings
import ch.srgssr.pillarbox.player.extension.setSeekIncrements
import ch.srgssr.pillarbox.player.qos.QoSSession
import ch.srgssr.pillarbox.player.qos.QoSSessionAnalyticsListener
import ch.srgssr.pillarbox.player.source.PillarboxMediaSourceFactory
import ch.srgssr.pillarbox.player.tracker.AnalyticsMediaItemTracker
import ch.srgssr.pillarbox.player.tracker.CurrentMediaItemPillarboxDataTracker
Expand All @@ -37,13 +40,15 @@ import ch.srgssr.pillarbox.player.utils.PillarboxEventLogger
/**
* Pillarbox player
*
* @param context
* @param exoPlayer
* @param mediaItemTrackerProvider
* @param analyticsCollector
*
* @constructor
*/
class PillarboxExoPlayer internal constructor(
context: Context,
private val exoPlayer: ExoPlayer,
mediaItemTrackerProvider: MediaItemTrackerProvider,
analyticsCollector: PillarboxAnalyticsCollector,
Expand Down Expand Up @@ -111,6 +116,7 @@ class PillarboxExoPlayer internal constructor(
init {
addListener(analyticsCollector)
exoPlayer.addListener(ComponentListener())
exoPlayer.addAnalyticsListener(QoSSessionAnalyticsListener(context, ::handleQoSSession))
itemPillarboxDataTracker.addCallback(timeRangeTracker)
itemPillarboxDataTracker.addCallback(analyticsTracker)
if (BuildConfig.DEBUG) {
Expand Down Expand Up @@ -143,6 +149,7 @@ class PillarboxExoPlayer internal constructor(
clock: Clock,
analyticsCollector: PillarboxAnalyticsCollector = PillarboxAnalyticsCollector(clock),
) : this(
context,
ExoPlayer.Builder(context)
.setClock(clock)
.setUsePlatformDiagnostics(false)
Expand Down Expand Up @@ -342,6 +349,11 @@ class PillarboxExoPlayer internal constructor(
playbackParameters = playbackParameters.withSpeed(speed)
}

private fun handleQoSSession(qosSession: QoSSession) {
// TODO Do something with the session
Log.d("PillarboxExoPlayer", "[${qosSession.mediaId}] $qosSession")
}

private fun seekEnd() {
isSeeking = false
pendingSeek?.let { pendingPosition ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright (c) SRG SSR. All rights reserved.
* License information is available from the LICENSE file.
*/
package ch.srgssr.pillarbox.player.qos

/**
* Represents a [Player][androidx.media3.common.Player] error to send to a QoS server.
*
* @property log The log associated with the error.
* @property message The error message.
* @property name The name of the error.
* @property playerPosition The position of the player when the error occurred, in milliseconds, or `null` if not available.
* @property severity The severity of the error, either [FATAL][Severity.FATAL] or [WARNING][Severity.WARNING].
*/
data class QoSError(
val log: String,
val message: String,
val name: String,
val playerPosition: Long?,
val severity: Severity,
) {
/**
* Represents a [Player][androidx.media3.common.Player] error severity.
*/
enum class Severity {
FATAL,
WARNING,
}

constructor(
throwable: Throwable,
playerPosition: Long?,
severity: Severity,
) : this(
log = throwable.stackTraceToString(),
message = throwable.message.orEmpty(),
name = throwable::class.simpleName.orEmpty(),
playerPosition = playerPosition,
severity = severity,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Copyright (c) SRG SSR. All rights reserved.
* License information is available from the LICENSE file.
*/
package ch.srgssr.pillarbox.player.qos

/**
* 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 playerPosition The position of the player, 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.
* @property url The URL of the stream.
*/
data class QoSEvent(
val bandwidth: Long,
val bitrate: Long,
val bufferDuration: Long,
val playbackDuration: Long,
val playerPosition: Long,
val stallCount: Long,
val stallDuration: Long,
val url: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright (c) SRG SSR. All rights reserved.
* License information is available from the LICENSE file.
*/
package ch.srgssr.pillarbox.player.qos

/**
* Represents a QoS message.
*
* @property data The data associated with the message.
* @property eventName The name of the event.
* @property sessionId The session id.
* @property timestamp The current timestamp.
*/
data class QoSMessage(
val data: Any,
val eventName: String,
val sessionId: String,
val timestamp: Long = System.currentTimeMillis(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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.content.res.Configuration
import android.graphics.Rect
import android.os.Build
import android.view.WindowManager
import ch.srgssr.pillarbox.player.BuildConfig

/**
* Represents a QoS session, which contains information about the device, current media, and player.
*
* @property deviceId The unique identifier of the device.
* @property deviceModel The model of the device.
* @property deviceType The type of device.
* @property mediaId The identifier of the media being played.
* @property mediaSource The source URL of the media being played.
* @property operatingSystemName The name of the operating system.
* @property operatingSystemVersion The version of the operating system.
* @property origin The origin of the player.
* @property playerName The name of the player.
* @property playerPlatform The platform of the player.
* @property playerVersion The version of the player.
* @property screenHeight The height of the screen in pixels.
* @property screenWidth The width of the screen in pixels.
* @property timings The timing until the current media started to play.
*/
data class QoSSession(
val deviceId: String,
val deviceModel: String = getDeviceModel(),
val deviceType: DeviceType,
val mediaId: String,
val mediaSource: String,
val operatingSystemName: String = PLATFORM_NAME,
val operatingSystemVersion: String = OPERATING_SYSTEM_VERSION,
val origin: String,
val playerName: String = PLAYER_NAME,
val playerPlatform: String = PLATFORM_NAME,
val playerVersion: String = PLAYER_VERSION,
val screenHeight: Int,
val screenWidth: Int,
val timings: QoSSessionTimings = QoSSessionTimings.Zero,
) {
/**
* The type of device.
*/
enum class DeviceType {
CAR,
PHONE,
TABLET,
TV,
}

constructor(
context: Context,
mediaId: String,
mediaSource: String,
) : this(
deviceId = getDeviceId(),
deviceType = context.getDeviceType(),
mediaId = mediaId,
mediaSource = mediaSource,
origin = context.packageName,
screenHeight = context.getWindowBounds().height(),
screenWidth = context.getWindowBounds().width(),
)

private companion object {
private val OPERATING_SYSTEM_VERSION = Build.VERSION.RELEASE
private const val PHONE_TABLET_WIDTH_THRESHOLD = 600
private const val PLATFORM_NAME = "android"
private const val PLAYER_NAME = "pillarbox"
private const val PLAYER_VERSION = BuildConfig.VERSION_NAME

@Suppress("FunctionOnlyReturningConstant")
private fun getDeviceId(): String {
// TODO Define this somehow (maybe use TCPredefinedVariables.getInstance().uniqueIdentifier)
return ""
}

private fun getDeviceModel(): String {
return Build.MANUFACTURER + " " + Build.MODEL
}

private fun Context.getDeviceType(): DeviceType {
val configuration = resources.configuration
return when (configuration.uiMode and Configuration.UI_MODE_TYPE_MASK) {
Configuration.UI_MODE_TYPE_CAR -> DeviceType.CAR
Configuration.UI_MODE_TYPE_NORMAL -> {
val smallestWidthDp = configuration.smallestScreenWidthDp

if (smallestWidthDp >= PHONE_TABLET_WIDTH_THRESHOLD) {
DeviceType.TABLET
} else {
DeviceType.PHONE
}
}

Configuration.UI_MODE_TYPE_TELEVISION -> DeviceType.TV
else -> DeviceType.PHONE // TODO Do we assume PHONE by default? Or do we throw an exception?
}
}

private fun Context.getWindowBounds(): Rect {
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager

return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
Rect().also {
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRectSize(it)
}
} else {
windowManager.maximumWindowMetrics.bounds
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* 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 androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Timeline
import androidx.media3.common.Tracks
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.LoadEventInfo
import androidx.media3.exoplayer.source.MediaLoadData
import ch.srgssr.pillarbox.player.source.PillarboxMediaSource
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds

internal class QoSSessionAnalyticsListener(
private val context: Context,
private val onQoSSessionReady: (qosSession: QoSSession) -> Unit,
) : AnalyticsListener {
private val loadingSessions = mutableSetOf<String>()
private val qosSessions = mutableMapOf<String, QoSSession>()
private val window = Timeline.Window()

@Suppress("ReturnCount")
override fun onLoadCompleted(
eventTime: AnalyticsListener.EventTime,
loadEventInfo: LoadEventInfo,
mediaLoadData: MediaLoadData,
) {
val mediaItem = getMediaItem(eventTime) ?: return
val sessionId = getSessionId(mediaItem)

if (sessionId !in qosSessions) {
loadingSessions.add(sessionId)
qosSessions[sessionId] = createQoSSession(mediaItem)
} else if (sessionId !in loadingSessions) {
return
}

val qosSession = qosSessions.getValue(sessionId)
val initialTimings = qosSession.timings
val loadDuration = loadEventInfo.loadDurationMs.milliseconds

val timings = when (mediaLoadData.dataType) {
C.DATA_TYPE_DRM -> initialTimings.copy(drm = initialTimings.drm + loadDuration)
C.DATA_TYPE_MEDIA -> initialTimings.copy(mediaSource = initialTimings.mediaSource + loadDuration)
PillarboxMediaSource.DATA_TYPE_CUSTOM_ASSET -> initialTimings.copy(asset = initialTimings.asset + loadDuration)
else -> return
}

qosSessions[sessionId] = qosSession.copy(timings = timings)
}

override fun onTracksChanged(
eventTime: AnalyticsListener.EventTime,
tracks: Tracks,
) {
val mediaItem = getMediaItem(eventTime) ?: return
val sessionId = getSessionId(mediaItem)

if (loadingSessions.remove(sessionId)) {
qosSessions[sessionId]?.let(onQoSSessionReady)
}
}

private fun getSessionId(mediaItem: MediaItem): String {
val mediaId = mediaItem.mediaId
val mediaUrl = mediaItem.localConfiguration?.uri?.toString().orEmpty()
val name = (mediaId + mediaUrl).toByteArray()

return UUID.nameUUIDFromBytes(name).toString()
}

private fun getMediaItem(eventTime: AnalyticsListener.EventTime): MediaItem? {
return if (eventTime.timeline.isEmpty) {
null
} else {
eventTime.timeline.getWindow(eventTime.windowIndex, window).mediaItem
}
}

private fun createQoSSession(mediaItem: MediaItem): QoSSession {
return QoSSession(
context = context,
mediaId = mediaItem.mediaId,
mediaSource = mediaItem.localConfiguration?.uri?.toString().orEmpty(),
)
}
}
Loading

0 comments on commit 0d985f6

Please sign in to comment.