diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 4289b972e9..acb85e4a18 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -12,6 +12,8 @@ * Add additional fields to Common Media Client Data (CMCD) logging: next object request (`nor`) and next range request (`nrr`) ([#8699](https://github.com/google/ExoPlayer/issues/8699)). + * Add functionality to transmit Common Media Client Data (CMCD) data using + query parameters ([#553](https://github.com/androidx/media/issues/553)). * Transformer: * Changed `frameRate` and `durationUs` parameters of `SampleConsumer.queueInputBitmap` to `TimestampIterator`. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdConfiguration.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdConfiguration.java index e73adcec96..b302f0b60a 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdConfiguration.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdConfiguration.java @@ -19,6 +19,7 @@ import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotNull; import static java.lang.annotation.ElementType.TYPE_USE; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.StringDef; import androidx.media3.common.C; @@ -79,6 +80,13 @@ public final class CmcdConfiguration { @Target(TYPE_USE) public @interface CmcdKey {} + /** Indicates the mode used for data transmission. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({MODE_REQUEST_HEADER, MODE_QUERY_PARAMETER}) + @Documented + @Target(TYPE_USE) + public @interface DataTransmissionMode {} + /** Maximum length for ID fields. */ public static final int MAX_ID_LENGTH = 64; @@ -86,6 +94,7 @@ public final class CmcdConfiguration { public static final String KEY_CMCD_REQUEST = "CMCD-Request"; public static final String KEY_CMCD_SESSION = "CMCD-Session"; public static final String KEY_CMCD_STATUS = "CMCD-Status"; + public static final String CMCD_QUERY_PARAMETER_KEY = "CMCD"; public static final String KEY_BITRATE = "br"; public static final String KEY_BUFFER_LENGTH = "bl"; public static final String KEY_CONTENT_ID = "cid"; @@ -104,6 +113,8 @@ public final class CmcdConfiguration { public static final String KEY_STARTUP = "su"; public static final String KEY_NEXT_OBJECT_REQUEST = "nor"; public static final String KEY_NEXT_RANGE_REQUEST = "nrr"; + public static final int MODE_REQUEST_HEADER = 0; + public static final int MODE_QUERY_PARAMETER = 1; /** * Factory for {@link CmcdConfiguration} instances. @@ -230,15 +241,28 @@ public final class CmcdConfiguration { /** Dynamic request specific configuration. */ public final RequestConfig requestConfig; - /** Creates an instance. */ + /** Mode used for data transmission. */ + public final @DataTransmissionMode int dataTransmissionMode; + + /** Creates an instance with {@link #dataTransmissionMode} set to {@link #MODE_REQUEST_HEADER}. */ public CmcdConfiguration( @Nullable String sessionId, @Nullable String contentId, RequestConfig requestConfig) { + this(sessionId, contentId, requestConfig, MODE_REQUEST_HEADER); + } + + /** Creates an instance. */ + public CmcdConfiguration( + @Nullable String sessionId, + @Nullable String contentId, + RequestConfig requestConfig, + @DataTransmissionMode int dataTransmissionMode) { checkArgument(sessionId == null || sessionId.length() <= MAX_ID_LENGTH); checkArgument(contentId == null || contentId.length() <= MAX_ID_LENGTH); checkNotNull(requestConfig); this.sessionId = sessionId; this.contentId = contentId; this.requestConfig = requestConfig; + this.dataTransmissionMode = dataTransmissionMode; } /** diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdHeadersFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdData.java similarity index 59% rename from libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdHeadersFactory.java rename to libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdData.java index f76807725e..35f3a1b53a 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdHeadersFactory.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdData.java @@ -30,8 +30,10 @@ import androidx.media3.common.MimeTypes; import androidx.media3.common.TrackGroup; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import androidx.media3.datasource.DataSpec; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import com.google.common.base.Joiner; +import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; @@ -41,62 +43,308 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.regex.Pattern; /** - * This class serves as a factory for generating Common Media Client Data (CMCD) HTTP request - * headers in adaptive streaming formats, DASH, HLS, and SmoothStreaming. + * This class provides functionality for generating and adding Common Media Client Data (CMCD) data + * to adaptive streaming formats, DASH, HLS, and SmoothStreaming. * *

It encapsulates the necessary attributes and information relevant to media content playback, * following the guidelines specified in the CMCD standard document CTA-5004. */ @UnstableApi -public final class CmcdHeadersFactory { +public final class CmcdData { - private static final Joiner COMMA_JOINER = Joiner.on(","); - private static final Pattern CUSTOM_KEY_NAME_PATTERN = - Pattern.compile("[a-zA-Z0-9]+(-[a-zA-Z0-9]+)+"); + /** {@link CmcdData.Factory} for {@link CmcdData} instances. */ + public static final class Factory { - /** - * Retrieves the object type value from the given {@link ExoTrackSelection}. - * - * @param trackSelection The {@link ExoTrackSelection} from which to retrieve the object type. - * @return The object type value as a String if {@link TrackType} can be mapped to one of the - * object types specified by {@link ObjectType} annotation, or {@code null}. - * @throws IllegalArgumentException if the provided {@link ExoTrackSelection} is {@code null}. - */ - @Nullable - public static @ObjectType String getObjectType(ExoTrackSelection trackSelection) { - checkArgument(trackSelection != null); - @C.TrackType - int trackType = MimeTypes.getTrackType(trackSelection.getSelectedFormat().sampleMimeType); - if (trackType == C.TRACK_TYPE_UNKNOWN) { - trackType = MimeTypes.getTrackType(trackSelection.getSelectedFormat().containerMimeType); + /** Represents the Dynamic Adaptive Streaming over HTTP (DASH) format. */ + public static final String STREAMING_FORMAT_DASH = "d"; + + /** Represents the HTTP Live Streaming (HLS) format. */ + public static final String STREAMING_FORMAT_HLS = "h"; + + /** Represents the Smooth Streaming (SS) format. */ + public static final String STREAMING_FORMAT_SS = "s"; + + /** Represents the Video on Demand (VOD) stream type. */ + public static final String STREAM_TYPE_VOD = "v"; + + /** Represents the Live Streaming stream type. */ + public static final String STREAM_TYPE_LIVE = "l"; + + /** Represents the object type for an initialization segment in a media container. */ + public static final String OBJECT_TYPE_INIT_SEGMENT = "i"; + + /** Represents the object type for audio-only content in a media container. */ + public static final String OBJECT_TYPE_AUDIO_ONLY = "a"; + + /** Represents the object type for video-only content in a media container. */ + public static final String OBJECT_TYPE_VIDEO_ONLY = "v"; + + /** Represents the object type for muxed audio and video content in a media container. */ + public static final String OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO = "av"; + + private static final Pattern CUSTOM_KEY_NAME_PATTERN = + Pattern.compile("[a-zA-Z0-9]+(-[a-zA-Z0-9]+)+"); + + private final CmcdConfiguration cmcdConfiguration; + private final ExoTrackSelection trackSelection; + private final long bufferedDurationUs; + private final float playbackRate; + private final @CmcdData.StreamingFormat String streamingFormat; + private final boolean isLive; + private final boolean didRebuffer; + private final boolean isBufferEmpty; + private long chunkDurationUs; + @Nullable private @CmcdData.ObjectType String objectType; + @Nullable private String nextObjectRequest; + @Nullable private String nextRangeRequest; + + /** + * Creates an instance. + * + * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source. + * @param trackSelection The {@linkplain ExoTrackSelection track selection}. + * @param bufferedDurationUs The duration of media currently buffered from the current playback + * position, in microseconds. + * @param playbackRate The playback rate indicating the current speed of playback. + * @param streamingFormat The streaming format of the media content. Must be one of the allowed + * streaming formats specified by the {@link CmcdData.StreamingFormat} annotation. + * @param isLive {@code true} if the media content is being streamed live, {@code false} + * otherwise. + * @param didRebuffer {@code true} if a rebuffering event happened between the previous request + * and this one, {@code false} otherwise. + * @param isBufferEmpty {@code true} if the queue of buffered chunks is empty, {@code false} + * otherwise. + * @throws IllegalArgumentException If {@code bufferedDurationUs} is negative. + */ + public Factory( + CmcdConfiguration cmcdConfiguration, + ExoTrackSelection trackSelection, + long bufferedDurationUs, + float playbackRate, + @CmcdData.StreamingFormat String streamingFormat, + boolean isLive, + boolean didRebuffer, + boolean isBufferEmpty) { + checkArgument(bufferedDurationUs >= 0); + checkArgument(playbackRate > 0); + this.cmcdConfiguration = cmcdConfiguration; + this.trackSelection = trackSelection; + this.bufferedDurationUs = bufferedDurationUs; + this.playbackRate = playbackRate; + this.streamingFormat = streamingFormat; + this.isLive = isLive; + this.didRebuffer = didRebuffer; + this.isBufferEmpty = isBufferEmpty; + this.chunkDurationUs = C.TIME_UNSET; } - if (trackType == C.TRACK_TYPE_AUDIO) { - return OBJECT_TYPE_AUDIO_ONLY; - } else if (trackType == C.TRACK_TYPE_VIDEO) { - return OBJECT_TYPE_VIDEO_ONLY; - } else { - // Track type cannot be mapped to a known object type. - return null; + /** + * Retrieves the object type value from the given {@link ExoTrackSelection}. + * + * @param trackSelection The {@link ExoTrackSelection} from which to retrieve the object type. + * @return The object type value as a String if {@link TrackType} can be mapped to one of the + * object types specified by {@link CmcdData.ObjectType} annotation, or {@code null}. + * @throws IllegalArgumentException if the provided {@link ExoTrackSelection} is {@code null}. + */ + @Nullable + public static @CmcdData.ObjectType String getObjectType(ExoTrackSelection trackSelection) { + checkArgument(trackSelection != null); + @TrackType + int trackType = MimeTypes.getTrackType(trackSelection.getSelectedFormat().sampleMimeType); + if (trackType == C.TRACK_TYPE_UNKNOWN) { + trackType = MimeTypes.getTrackType(trackSelection.getSelectedFormat().containerMimeType); + } + + if (trackType == C.TRACK_TYPE_AUDIO) { + return OBJECT_TYPE_AUDIO_ONLY; + } else if (trackType == C.TRACK_TYPE_VIDEO) { + return OBJECT_TYPE_VIDEO_ONLY; + } else { + // Track type cannot be mapped to a known object type. + return null; + } + } + + /** + * Sets the duration of current media chunk being requested, in microseconds. The default value + * is {@link C#TIME_UNSET}. + * + * @throws IllegalArgumentException If {@code chunkDurationUs} is negative. + */ + @CanIgnoreReturnValue + public Factory setChunkDurationUs(long chunkDurationUs) { + checkArgument(chunkDurationUs >= 0); + this.chunkDurationUs = chunkDurationUs; + return this; + } + + /** + * Sets the object type of the current object being requested. Must be one of the allowed object + * types specified by the {@link CmcdData.ObjectType} annotation. + * + *

Default is {@code null}. + */ + @CanIgnoreReturnValue + public Factory setObjectType(@Nullable @CmcdData.ObjectType String objectType) { + this.objectType = objectType; + return this; + } + + /** + * Sets the relative path of the next object to be requested. This can be used to trigger + * pre-fetching by the CDN. + * + *

Default is {@code null}. + */ + @CanIgnoreReturnValue + public Factory setNextObjectRequest(@Nullable String nextObjectRequest) { + this.nextObjectRequest = nextObjectRequest; + return this; + } + + /** + * Sets the byte range representing the partial object request. This can be used to trigger + * pre-fetching by the CDN. + * + *

Default is {@code null}. + */ + @CanIgnoreReturnValue + public Factory setNextRangeRequest(@Nullable String nextRangeRequest) { + this.nextRangeRequest = nextRangeRequest; + return this; + } + + public CmcdData createCmcdData() { + ImmutableListMultimap<@CmcdConfiguration.HeaderKey String, String> customData = + cmcdConfiguration.requestConfig.getCustomData(); + for (String headerKey : customData.keySet()) { + validateCustomDataListFormat(customData.get(headerKey)); + } + + int bitrateKbps = Util.ceilDivide(trackSelection.getSelectedFormat().bitrate, 1000); + + CmcdObject.Builder cmcdObject = new CmcdObject.Builder(); + if (!getIsInitSegment()) { + if (cmcdConfiguration.isBitrateLoggingAllowed()) { + cmcdObject.setBitrateKbps(bitrateKbps); + } + if (cmcdConfiguration.isTopBitrateLoggingAllowed()) { + TrackGroup trackGroup = trackSelection.getTrackGroup(); + int topBitrate = trackSelection.getSelectedFormat().bitrate; + for (int i = 0; i < trackGroup.length; i++) { + topBitrate = max(topBitrate, trackGroup.getFormat(i).bitrate); + } + cmcdObject.setTopBitrateKbps(Util.ceilDivide(topBitrate, 1000)); + } + if (cmcdConfiguration.isObjectDurationLoggingAllowed()) { + cmcdObject.setObjectDurationMs(Util.usToMs(chunkDurationUs)); + } + } + if (cmcdConfiguration.isObjectTypeLoggingAllowed()) { + cmcdObject.setObjectType(objectType); + } + if (customData.containsKey(CmcdConfiguration.KEY_CMCD_OBJECT)) { + cmcdObject.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_OBJECT)); + } + + CmcdRequest.Builder cmcdRequest = new CmcdRequest.Builder(); + if (!getIsInitSegment() && cmcdConfiguration.isBufferLengthLoggingAllowed()) { + cmcdRequest.setBufferLengthMs(Util.usToMs(bufferedDurationUs)); + } + if (cmcdConfiguration.isMeasuredThroughputLoggingAllowed() + && trackSelection.getLatestBitrateEstimate() != C.RATE_UNSET_INT) { + cmcdRequest.setMeasuredThroughputInKbps( + Util.ceilDivide(trackSelection.getLatestBitrateEstimate(), 1000)); + } + if (cmcdConfiguration.isDeadlineLoggingAllowed()) { + cmcdRequest.setDeadlineMs(Util.usToMs((long) (bufferedDurationUs / playbackRate))); + } + if (cmcdConfiguration.isStartupLoggingAllowed()) { + cmcdRequest.setStartup(didRebuffer || isBufferEmpty); + } + if (cmcdConfiguration.isNextObjectRequestLoggingAllowed()) { + cmcdRequest.setNextObjectRequest(nextObjectRequest); + } + if (cmcdConfiguration.isNextRangeRequestLoggingAllowed()) { + cmcdRequest.setNextRangeRequest(nextRangeRequest); + } + if (customData.containsKey(CmcdConfiguration.KEY_CMCD_REQUEST)) { + cmcdRequest.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_REQUEST)); + } + + CmcdSession.Builder cmcdSession = new CmcdSession.Builder(); + if (cmcdConfiguration.isContentIdLoggingAllowed()) { + cmcdSession.setContentId(cmcdConfiguration.contentId); + } + if (cmcdConfiguration.isSessionIdLoggingAllowed()) { + cmcdSession.setSessionId(cmcdConfiguration.sessionId); + } + if (cmcdConfiguration.isStreamingFormatLoggingAllowed()) { + cmcdSession.setStreamingFormat(streamingFormat); + } + if (cmcdConfiguration.isStreamTypeLoggingAllowed()) { + cmcdSession.setStreamType(isLive ? STREAM_TYPE_LIVE : STREAM_TYPE_VOD); + } + if (cmcdConfiguration.isPlaybackRateLoggingAllowed()) { + cmcdSession.setPlaybackRate(playbackRate); + } + if (customData.containsKey(CmcdConfiguration.KEY_CMCD_SESSION)) { + cmcdSession.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_SESSION)); + } + + CmcdStatus.Builder cmcdStatus = new CmcdStatus.Builder(); + if (cmcdConfiguration.isMaximumRequestThroughputLoggingAllowed()) { + cmcdStatus.setMaximumRequestedThroughputKbps( + cmcdConfiguration.requestConfig.getRequestedMaximumThroughputKbps(bitrateKbps)); + } + if (cmcdConfiguration.isBufferStarvationLoggingAllowed()) { + cmcdStatus.setBufferStarvation(didRebuffer); + } + if (customData.containsKey(CmcdConfiguration.KEY_CMCD_STATUS)) { + cmcdStatus.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_STATUS)); + } + + return new CmcdData( + cmcdObject.build(), + cmcdRequest.build(), + cmcdSession.build(), + cmcdStatus.build(), + cmcdConfiguration.dataTransmissionMode); + } + + private boolean getIsInitSegment() { + return objectType != null && objectType.equals(OBJECT_TYPE_INIT_SEGMENT); + } + + private void validateCustomDataListFormat(List customDataList) { + for (String customData : customDataList) { + String key = Util.split(customData, "=")[0]; + checkState(CUSTOM_KEY_NAME_PATTERN.matcher(key).matches()); + } } } /** Indicates the streaming format used for media content. */ @Retention(RetentionPolicy.SOURCE) - @StringDef({STREAMING_FORMAT_DASH, STREAMING_FORMAT_HLS, STREAMING_FORMAT_SS}) + @StringDef({ + Factory.STREAMING_FORMAT_DASH, + Factory.STREAMING_FORMAT_HLS, + Factory.STREAMING_FORMAT_SS + }) @Documented @Target(TYPE_USE) public @interface StreamingFormat {} /** Indicates the type of streaming for media content. */ @Retention(RetentionPolicy.SOURCE) - @StringDef({STREAM_TYPE_VOD, STREAM_TYPE_LIVE}) + @StringDef({Factory.STREAM_TYPE_VOD, Factory.STREAM_TYPE_LIVE}) @Documented @Target(TYPE_USE) public @interface StreamType {} @@ -104,251 +352,69 @@ public final class CmcdHeadersFactory { /** Indicates the media type of current object being requested. */ @Retention(RetentionPolicy.SOURCE) @StringDef({ - OBJECT_TYPE_INIT_SEGMENT, - OBJECT_TYPE_AUDIO_ONLY, - OBJECT_TYPE_VIDEO_ONLY, - OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO + Factory.OBJECT_TYPE_INIT_SEGMENT, + Factory.OBJECT_TYPE_AUDIO_ONLY, + Factory.OBJECT_TYPE_VIDEO_ONLY, + Factory.OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO }) @Documented @Target(TYPE_USE) public @interface ObjectType {} - /** Represents the Dynamic Adaptive Streaming over HTTP (DASH) format. */ - public static final String STREAMING_FORMAT_DASH = "d"; + private static final Joiner COMMA_JOINER = Joiner.on(","); - /** Represents the HTTP Live Streaming (HLS) format. */ - public static final String STREAMING_FORMAT_HLS = "h"; + private final CmcdObject cmcdObject; + private final CmcdRequest cmcdRequest; + private final CmcdSession cmcdSession; + private final CmcdStatus cmcdStatus; + private final @CmcdConfiguration.DataTransmissionMode int dataTransmissionMode; - /** Represents the Smooth Streaming (SS) format. */ - public static final String STREAMING_FORMAT_SS = "s"; - - /** Represents the Video on Demand (VOD) stream type. */ - public static final String STREAM_TYPE_VOD = "v"; - - /** Represents the Live Streaming stream type. */ - public static final String STREAM_TYPE_LIVE = "l"; - - /** Represents the object type for an initialization segment in a media container. */ - public static final String OBJECT_TYPE_INIT_SEGMENT = "i"; - - /** Represents the object type for audio-only content in a media container. */ - public static final String OBJECT_TYPE_AUDIO_ONLY = "a"; - - /** Represents the object type for video-only content in a media container. */ - public static final String OBJECT_TYPE_VIDEO_ONLY = "v"; - - /** Represents the object type for muxed audio and video content in a media container. */ - public static final String OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO = "av"; - - private final CmcdConfiguration cmcdConfiguration; - private final ExoTrackSelection trackSelection; - private final long bufferedDurationUs; - private final float playbackRate; - private final @StreamingFormat String streamingFormat; - private final boolean isLive; - private final boolean didRebuffer; - private final boolean isBufferEmpty; - private long chunkDurationUs; - private @Nullable @ObjectType String objectType; - @Nullable private String nextObjectRequest; - @Nullable private String nextRangeRequest; - - /** - * Creates an instance. - * - * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source. - * @param trackSelection The {@linkplain ExoTrackSelection track selection}. - * @param bufferedDurationUs The duration of media currently buffered from the current playback - * position, in microseconds. - * @param playbackRate The playback rate indicating the current speed of playback. - * @param streamingFormat The streaming format of the media content. Must be one of the allowed - * streaming formats specified by the {@link StreamingFormat} annotation. - * @param isLive {@code true} if the media content is being streamed live, {@code false} - * otherwise. - * @param didRebuffer {@code true} if a rebuffering event happened between the previous request - * and this one, {@code false} otherwise. - * @param isBufferEmpty {@code true} if the queue of buffered chunks is empty, {@code false} - * otherwise. - * @throws IllegalArgumentException If {@code bufferedDurationUs} is negative. - */ - public CmcdHeadersFactory( - CmcdConfiguration cmcdConfiguration, - ExoTrackSelection trackSelection, - long bufferedDurationUs, - float playbackRate, - @StreamingFormat String streamingFormat, - boolean isLive, - boolean didRebuffer, - boolean isBufferEmpty) { - checkArgument(bufferedDurationUs >= 0); - checkArgument(playbackRate > 0); - this.cmcdConfiguration = cmcdConfiguration; - this.trackSelection = trackSelection; - this.bufferedDurationUs = bufferedDurationUs; - this.playbackRate = playbackRate; - this.streamingFormat = streamingFormat; - this.isLive = isLive; - this.didRebuffer = didRebuffer; - this.isBufferEmpty = isBufferEmpty; - this.chunkDurationUs = C.TIME_UNSET; + private CmcdData( + CmcdObject cmcdObject, + CmcdRequest cmcdRequest, + CmcdSession cmcdSession, + CmcdStatus cmcdStatus, + @CmcdConfiguration.DataTransmissionMode int datatTransmissionMode) { + this.cmcdObject = cmcdObject; + this.cmcdRequest = cmcdRequest; + this.cmcdSession = cmcdSession; + this.cmcdStatus = cmcdStatus; + this.dataTransmissionMode = datatTransmissionMode; } /** - * Sets the duration of current media chunk being requested, in microseconds. The default value is - * {@link C#TIME_UNSET}. - * - * @throws IllegalArgumentException If {@code chunkDurationUs} is negative. + * Adds Common Media Client Data (CMCD) related information to the provided {@link DataSpec} + * object. */ - @CanIgnoreReturnValue - public CmcdHeadersFactory setChunkDurationUs(long chunkDurationUs) { - checkArgument(chunkDurationUs >= 0); - this.chunkDurationUs = chunkDurationUs; - return this; - } + public DataSpec addToDataSpec(DataSpec dataSpec) { + ArrayListMultimap cmcdDataMap = ArrayListMultimap.create(); + cmcdObject.populateCmcdDataMap(cmcdDataMap); + cmcdRequest.populateCmcdDataMap(cmcdDataMap); + cmcdSession.populateCmcdDataMap(cmcdDataMap); + cmcdStatus.populateCmcdDataMap(cmcdDataMap); - /** - * Sets the object type of the current object being requested. Must be one of the allowed object - * types specified by the {@link ObjectType} annotation. - * - *

Default is {@code null}. - */ - @CanIgnoreReturnValue - public CmcdHeadersFactory setObjectType(@Nullable @ObjectType String objectType) { - this.objectType = objectType; - return this; - } - - /** - * Sets the relative path of the next object to be requested. This can be used to trigger - * pre-fetching by the CDN. - * - *

Default is {@code null}. - */ - @CanIgnoreReturnValue - public CmcdHeadersFactory setNextObjectRequest(@Nullable String nextObjectRequest) { - this.nextObjectRequest = nextObjectRequest; - return this; - } - - /** - * Sets the byte range representing the partial object request. This can be used to trigger - * pre-fetching by the CDN. - * - *

Default is {@code null}. - */ - @CanIgnoreReturnValue - public CmcdHeadersFactory setNextRangeRequest(@Nullable String nextRangeRequest) { - this.nextRangeRequest = nextRangeRequest; - return this; - } - - /** Creates and returns a new {@link ImmutableMap} containing the CMCD HTTP request headers. */ - public ImmutableMap<@CmcdConfiguration.HeaderKey String, String> createHttpRequestHeaders() { - ImmutableListMultimap<@CmcdConfiguration.HeaderKey String, String> customData = - cmcdConfiguration.requestConfig.getCustomData(); - for (String headerKey : customData.keySet()) { - validateCustomDataListFormat(customData.get(headerKey)); - } - - int bitrateKbps = Util.ceilDivide(trackSelection.getSelectedFormat().bitrate, 1000); - - CmcdObject.Builder cmcdObject = new CmcdObject.Builder(); - if (!getIsInitSegment()) { - if (cmcdConfiguration.isBitrateLoggingAllowed()) { - cmcdObject.setBitrateKbps(bitrateKbps); + if (dataTransmissionMode == CmcdConfiguration.MODE_REQUEST_HEADER) { + ImmutableMap.Builder httpRequestHeaders = ImmutableMap.builder(); + for (String headerKey : cmcdDataMap.keySet()) { + List headerValues = cmcdDataMap.get(headerKey); + Collections.sort(headerValues); + httpRequestHeaders.put(headerKey, COMMA_JOINER.join(headerValues)); } - if (cmcdConfiguration.isTopBitrateLoggingAllowed()) { - TrackGroup trackGroup = trackSelection.getTrackGroup(); - int topBitrate = trackSelection.getSelectedFormat().bitrate; - for (int i = 0; i < trackGroup.length; i++) { - topBitrate = max(topBitrate, trackGroup.getFormat(i).bitrate); - } - cmcdObject.setTopBitrateKbps(Util.ceilDivide(topBitrate, 1000)); + return dataSpec.withAdditionalHeaders(httpRequestHeaders.buildOrThrow()); + } else { + List keyValuePairs = new ArrayList<>(); + for (Collection values : cmcdDataMap.asMap().values()) { + keyValuePairs.addAll(values); } - if (cmcdConfiguration.isObjectDurationLoggingAllowed()) { - cmcdObject.setObjectDurationMs(Util.usToMs(chunkDurationUs)); - } - } - if (cmcdConfiguration.isObjectTypeLoggingAllowed()) { - cmcdObject.setObjectType(objectType); - } - if (customData.containsKey(CmcdConfiguration.KEY_CMCD_OBJECT)) { - cmcdObject.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_OBJECT)); - } - - CmcdRequest.Builder cmcdRequest = new CmcdRequest.Builder(); - if (!getIsInitSegment() && cmcdConfiguration.isBufferLengthLoggingAllowed()) { - cmcdRequest.setBufferLengthMs(Util.usToMs(bufferedDurationUs)); - } - if (cmcdConfiguration.isMeasuredThroughputLoggingAllowed() - && trackSelection.getLatestBitrateEstimate() != C.RATE_UNSET_INT) { - cmcdRequest.setMeasuredThroughputInKbps( - Util.ceilDivide(trackSelection.getLatestBitrateEstimate(), 1000)); - } - if (cmcdConfiguration.isDeadlineLoggingAllowed()) { - cmcdRequest.setDeadlineMs(Util.usToMs((long) (bufferedDurationUs / playbackRate))); - } - if (cmcdConfiguration.isStartupLoggingAllowed()) { - cmcdRequest.setStartup(didRebuffer || isBufferEmpty); - } - if (cmcdConfiguration.isNextObjectRequestLoggingAllowed()) { - cmcdRequest.setNextObjectRequest(nextObjectRequest); - } - if (cmcdConfiguration.isNextRangeRequestLoggingAllowed()) { - cmcdRequest.setNextRangeRequest(nextRangeRequest); - } - if (customData.containsKey(CmcdConfiguration.KEY_CMCD_REQUEST)) { - cmcdRequest.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_REQUEST)); - } - - CmcdSession.Builder cmcdSession = new CmcdSession.Builder(); - if (cmcdConfiguration.isContentIdLoggingAllowed()) { - cmcdSession.setContentId(cmcdConfiguration.contentId); - } - if (cmcdConfiguration.isSessionIdLoggingAllowed()) { - cmcdSession.setSessionId(cmcdConfiguration.sessionId); - } - if (cmcdConfiguration.isStreamingFormatLoggingAllowed()) { - cmcdSession.setStreamingFormat(streamingFormat); - } - if (cmcdConfiguration.isStreamTypeLoggingAllowed()) { - cmcdSession.setStreamType(isLive ? STREAM_TYPE_LIVE : STREAM_TYPE_VOD); - } - if (cmcdConfiguration.isPlaybackRateLoggingAllowed()) { - cmcdSession.setPlaybackRate(playbackRate); - } - if (customData.containsKey(CmcdConfiguration.KEY_CMCD_SESSION)) { - cmcdSession.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_SESSION)); - } - - CmcdStatus.Builder cmcdStatus = new CmcdStatus.Builder(); - if (cmcdConfiguration.isMaximumRequestThroughputLoggingAllowed()) { - cmcdStatus.setMaximumRequestedThroughputKbps( - cmcdConfiguration.requestConfig.getRequestedMaximumThroughputKbps(bitrateKbps)); - } - if (cmcdConfiguration.isBufferStarvationLoggingAllowed()) { - cmcdStatus.setBufferStarvation(didRebuffer); - } - if (customData.containsKey(CmcdConfiguration.KEY_CMCD_STATUS)) { - cmcdStatus.setCustomDataList(customData.get(CmcdConfiguration.KEY_CMCD_STATUS)); - } - - ImmutableMap.Builder httpRequestHeaders = ImmutableMap.builder(); - cmcdObject.build().populateHttpRequestHeaders(httpRequestHeaders); - cmcdRequest.build().populateHttpRequestHeaders(httpRequestHeaders); - cmcdSession.build().populateHttpRequestHeaders(httpRequestHeaders); - cmcdStatus.build().populateHttpRequestHeaders(httpRequestHeaders); - return httpRequestHeaders.buildOrThrow(); - } - - private boolean getIsInitSegment() { - return objectType != null && objectType.equals(OBJECT_TYPE_INIT_SEGMENT); - } - - private void validateCustomDataListFormat(List customDataList) { - for (String customData : customDataList) { - String key = Util.split(customData, "=")[0]; - checkState(CUSTOM_KEY_NAME_PATTERN.matcher(key).matches()); + Collections.sort(keyValuePairs); + Uri.Builder uriBuilder = + dataSpec + .uri + .buildUpon() + .appendQueryParameter( + CmcdConfiguration.CMCD_QUERY_PARAMETER_KEY, + Uri.encode(COMMA_JOINER.join(keyValuePairs))); + return dataSpec.buildUpon().setUri(uriBuilder.build()).build(); } } @@ -477,32 +543,29 @@ public final class CmcdHeadersFactory { } /** - * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_OBJECT} values. + * Populates the {@code cmcdDataMap} with {@link CmcdConfiguration#KEY_CMCD_OBJECT} values. * - * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request - * headers. + * @param cmcdDataMap An {@link ArrayListMultimap} to which CMCD data will be added. */ - public void populateHttpRequestHeaders( - ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) { - ArrayList headerValueList = new ArrayList<>(); + public void populateCmcdDataMap( + ArrayListMultimap<@CmcdConfiguration.HeaderKey String, String> cmcdDataMap) { + ArrayList keyValuePairs = new ArrayList<>(); if (bitrateKbps != C.RATE_UNSET_INT) { - headerValueList.add(CmcdConfiguration.KEY_BITRATE + "=" + bitrateKbps); + keyValuePairs.add(CmcdConfiguration.KEY_BITRATE + "=" + bitrateKbps); } if (topBitrateKbps != C.RATE_UNSET_INT) { - headerValueList.add(CmcdConfiguration.KEY_TOP_BITRATE + "=" + topBitrateKbps); + keyValuePairs.add(CmcdConfiguration.KEY_TOP_BITRATE + "=" + topBitrateKbps); } if (objectDurationMs != C.TIME_UNSET) { - headerValueList.add(CmcdConfiguration.KEY_OBJECT_DURATION + "=" + objectDurationMs); + keyValuePairs.add(CmcdConfiguration.KEY_OBJECT_DURATION + "=" + objectDurationMs); } if (!TextUtils.isEmpty(objectType)) { - headerValueList.add(CmcdConfiguration.KEY_OBJECT_TYPE + "=" + objectType); + keyValuePairs.add(CmcdConfiguration.KEY_OBJECT_TYPE + "=" + objectType); } - headerValueList.addAll(customDataList); + keyValuePairs.addAll(customDataList); - if (!headerValueList.isEmpty()) { - Collections.sort(headerValueList); - httpRequestHeaders.put( - CmcdConfiguration.KEY_CMCD_OBJECT, COMMA_JOINER.join(headerValueList)); + if (!keyValuePairs.isEmpty()) { + cmcdDataMap.putAll(CmcdConfiguration.KEY_CMCD_OBJECT, keyValuePairs); } } } @@ -616,8 +679,8 @@ public final class CmcdHeadersFactory { * C#TIME_UNSET} if unset. * *

This key SHOULD only be sent with an {@link CmcdObject#objectType} of {@link - * #OBJECT_TYPE_AUDIO_ONLY}, {@link #OBJECT_TYPE_VIDEO_ONLY} or {@link - * #OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO}. + * Factory#OBJECT_TYPE_AUDIO_ONLY}, {@link Factory#OBJECT_TYPE_VIDEO_ONLY} or {@link + * Factory#OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO}. * *

This value MUST be rounded to the nearest 100 ms. */ @@ -688,43 +751,40 @@ public final class CmcdHeadersFactory { } /** - * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_REQUEST} values. + * Populates the {@code cmcdDataMap} with {@link CmcdConfiguration#KEY_CMCD_REQUEST} values. * - * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request - * headers. + * @param cmcdDataMap An {@link ArrayListMultimap} to which CMCD data will be added. */ - public void populateHttpRequestHeaders( - ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) { - ArrayList headerValueList = new ArrayList<>(); + public void populateCmcdDataMap( + ArrayListMultimap<@CmcdConfiguration.HeaderKey String, String> cmcdDataMap) { + ArrayList keyValuePairs = new ArrayList<>(); if (bufferLengthMs != C.TIME_UNSET) { - headerValueList.add(CmcdConfiguration.KEY_BUFFER_LENGTH + "=" + bufferLengthMs); + keyValuePairs.add(CmcdConfiguration.KEY_BUFFER_LENGTH + "=" + bufferLengthMs); } if (measuredThroughputInKbps != C.RATE_UNSET_INT) { - headerValueList.add( + keyValuePairs.add( CmcdConfiguration.KEY_MEASURED_THROUGHPUT + "=" + measuredThroughputInKbps); } if (deadlineMs != C.TIME_UNSET) { - headerValueList.add(CmcdConfiguration.KEY_DEADLINE + "=" + deadlineMs); + keyValuePairs.add(CmcdConfiguration.KEY_DEADLINE + "=" + deadlineMs); } if (startup) { - headerValueList.add(CmcdConfiguration.KEY_STARTUP); + keyValuePairs.add(CmcdConfiguration.KEY_STARTUP); } if (!TextUtils.isEmpty(nextObjectRequest)) { - headerValueList.add( + keyValuePairs.add( Util.formatInvariant( "%s=\"%s\"", CmcdConfiguration.KEY_NEXT_OBJECT_REQUEST, nextObjectRequest)); } if (!TextUtils.isEmpty(nextRangeRequest)) { - headerValueList.add( + keyValuePairs.add( Util.formatInvariant( "%s=\"%s\"", CmcdConfiguration.KEY_NEXT_RANGE_REQUEST, nextRangeRequest)); } - headerValueList.addAll(customDataList); + keyValuePairs.addAll(customDataList); - if (!headerValueList.isEmpty()) { - Collections.sort(headerValueList); - httpRequestHeaders.put( - CmcdConfiguration.KEY_CMCD_REQUEST, COMMA_JOINER.join(headerValueList)); + if (!keyValuePairs.isEmpty()) { + cmcdDataMap.putAll(CmcdConfiguration.KEY_CMCD_REQUEST, keyValuePairs); } } } @@ -870,41 +930,38 @@ public final class CmcdHeadersFactory { } /** - * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_SESSION} values. + * Populates the {@code cmcdDataMap} with {@link CmcdConfiguration#KEY_CMCD_SESSION} values. * - * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request - * headers. + * @param cmcdDataMap An {@link ArrayListMultimap} to which CMCD data will be added. */ - public void populateHttpRequestHeaders( - ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) { - ArrayList headerValueList = new ArrayList<>(); + public void populateCmcdDataMap( + ArrayListMultimap<@CmcdConfiguration.HeaderKey String, String> cmcdDataMap) { + ArrayList keyValuePairs = new ArrayList<>(); if (!TextUtils.isEmpty(this.contentId)) { - headerValueList.add( + keyValuePairs.add( Util.formatInvariant("%s=\"%s\"", CmcdConfiguration.KEY_CONTENT_ID, contentId)); } if (!TextUtils.isEmpty(this.sessionId)) { - headerValueList.add( + keyValuePairs.add( Util.formatInvariant("%s=\"%s\"", CmcdConfiguration.KEY_SESSION_ID, sessionId)); } if (!TextUtils.isEmpty(this.streamingFormat)) { - headerValueList.add(CmcdConfiguration.KEY_STREAMING_FORMAT + "=" + streamingFormat); + keyValuePairs.add(CmcdConfiguration.KEY_STREAMING_FORMAT + "=" + streamingFormat); } if (!TextUtils.isEmpty(this.streamType)) { - headerValueList.add(CmcdConfiguration.KEY_STREAM_TYPE + "=" + streamType); + keyValuePairs.add(CmcdConfiguration.KEY_STREAM_TYPE + "=" + streamType); } if (playbackRate != C.RATE_UNSET && playbackRate != 1.0f) { - headerValueList.add( + keyValuePairs.add( Util.formatInvariant("%s=%.2f", CmcdConfiguration.KEY_PLAYBACK_RATE, playbackRate)); } if (VERSION != 1) { - headerValueList.add(CmcdConfiguration.KEY_VERSION + "=" + VERSION); + keyValuePairs.add(CmcdConfiguration.KEY_VERSION + "=" + VERSION); } - headerValueList.addAll(customDataList); + keyValuePairs.addAll(customDataList); - if (!headerValueList.isEmpty()) { - Collections.sort(headerValueList); - httpRequestHeaders.put( - CmcdConfiguration.KEY_CMCD_SESSION, COMMA_JOINER.join(headerValueList)); + if (!keyValuePairs.isEmpty()) { + cmcdDataMap.putAll(CmcdConfiguration.KEY_CMCD_SESSION, keyValuePairs); } } } @@ -991,27 +1048,24 @@ public final class CmcdHeadersFactory { } /** - * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_STATUS} values. + * Populates the {@code cmcdDataMap} with {@link CmcdConfiguration#KEY_CMCD_STATUS} values. * - * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request - * headers. + * @param cmcdDataMap An {@link ArrayListMultimap} to which CMCD data will be added. */ - public void populateHttpRequestHeaders( - ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) { - ArrayList headerValueList = new ArrayList<>(); + public void populateCmcdDataMap( + ArrayListMultimap<@CmcdConfiguration.HeaderKey String, String> cmcdDataMap) { + ArrayList keyValuePairs = new ArrayList<>(); if (maximumRequestedThroughputKbps != C.RATE_UNSET_INT) { - headerValueList.add( + keyValuePairs.add( CmcdConfiguration.KEY_MAXIMUM_REQUESTED_BITRATE + "=" + maximumRequestedThroughputKbps); } if (bufferStarvation) { - headerValueList.add(CmcdConfiguration.KEY_BUFFER_STARVATION); + keyValuePairs.add(CmcdConfiguration.KEY_BUFFER_STARVATION); } - headerValueList.addAll(customDataList); + keyValuePairs.addAll(customDataList); - if (!headerValueList.isEmpty()) { - Collections.sort(headerValueList); - httpRequestHeaders.put( - CmcdConfiguration.KEY_CMCD_STATUS, COMMA_JOINER.join(headerValueList)); + if (!keyValuePairs.isEmpty()) { + cmcdDataMap.putAll(CmcdConfiguration.KEY_CMCD_STATUS, keyValuePairs); } } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdHeadersFactoryTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdDataTest.java similarity index 60% rename from libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdHeadersFactoryTest.java rename to libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdDataTest.java index ddd7503f4f..efd07f8ca3 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdHeadersFactoryTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdDataTest.java @@ -20,22 +20,23 @@ import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import android.net.Uri; import androidx.media3.common.Format; import androidx.media3.common.MediaItem; import androidx.media3.common.TrackGroup; +import androidx.media3.datasource.DataSpec; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableListMultimap; -import com.google.common.collect.ImmutableMap; import org.junit.Test; import org.junit.runner.RunWith; -/** Tests for {@link CmcdHeadersFactory}. */ +/** Tests for {@link CmcdData}. */ @RunWith(AndroidJUnit4.class) -public class CmcdHeadersFactoryTest { +public class CmcdDataTest { @Test - public void createInstance_populatesCmcdHeaders() { + public void createInstance_populatesCmcdHttRequestHeaders() { CmcdConfiguration.Factory cmcdConfigurationFactory = mediaItem -> new CmcdConfiguration( @@ -66,21 +67,23 @@ public class CmcdHeadersFactoryTest { when(trackSelection.getTrackGroup()) .thenReturn(new TrackGroup(format, new Format.Builder().setPeakBitrate(1_000_000).build())); when(trackSelection.getLatestBitrateEstimate()).thenReturn(500_000L); - - ImmutableMap<@CmcdConfiguration.HeaderKey String, String> requestHeaders = - new CmcdHeadersFactory( + DataSpec dataSpec = new DataSpec.Builder().setUri(Uri.EMPTY).build(); + CmcdData cmcdData = + new CmcdData.Factory( cmcdConfiguration, trackSelection, /* bufferedDurationUs= */ 1_760_000, /* playbackRate= */ 2.0f, - /* streamingFormat= */ CmcdHeadersFactory.STREAMING_FORMAT_DASH, + /* streamingFormat= */ CmcdData.Factory.STREAMING_FORMAT_DASH, /* isLive= */ true, /* didRebuffer= */ true, /* isBufferEmpty= */ false) .setChunkDurationUs(3_000_000) - .createHttpRequestHeaders(); + .createCmcdData(); - assertThat(requestHeaders) + dataSpec = cmcdData.addToDataSpec(dataSpec); + + assertThat(dataSpec.httpRequestHeaders) .containsExactly( "CMCD-Object", "br=840,d=3000,key-1=1,key-2-separated-by-multiple-hyphens=2,tb=1000", @@ -92,6 +95,62 @@ public class CmcdHeadersFactoryTest { "bs,key-4=\"stringValue3=stringValue4\",rtp=1700"); } + @Test + public void createInstance_populatesCmcdHttpQueryParameters() { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> + new CmcdConfiguration( + "sessionId", + mediaItem.mediaId, + new CmcdConfiguration.RequestConfig() { + @Override + public ImmutableListMultimap<@CmcdConfiguration.HeaderKey String, String> + getCustomData() { + return new ImmutableListMultimap.Builder() + .put("CMCD-Object", "key-1=1") + .put("CMCD-Request", "key-2=\"stringValue1,stringValue2\"") + .build(); + } + + @Override + public int getRequestedMaximumThroughputKbps(int throughputKbps) { + return 2 * throughputKbps; + } + }, + CmcdConfiguration.MODE_QUERY_PARAMETER); + MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); + ExoTrackSelection trackSelection = mock(ExoTrackSelection.class); + Format format = new Format.Builder().setPeakBitrate(840_000).build(); + when(trackSelection.getSelectedFormat()).thenReturn(format); + when(trackSelection.getTrackGroup()) + .thenReturn(new TrackGroup(format, new Format.Builder().setPeakBitrate(1_000_000).build())); + when(trackSelection.getLatestBitrateEstimate()).thenReturn(500_000L); + DataSpec dataSpec = new DataSpec.Builder().setUri(Uri.EMPTY).build(); + CmcdData cmcdData = + new CmcdData.Factory( + cmcdConfiguration, + trackSelection, + /* bufferedDurationUs= */ 1_760_000, + /* playbackRate= */ 2.0f, + /* streamingFormat= */ CmcdData.Factory.STREAMING_FORMAT_DASH, + /* isLive= */ true, + /* didRebuffer= */ true, + /* isBufferEmpty= */ false) + .setChunkDurationUs(3_000_000) + .createCmcdData(); + + dataSpec = cmcdData.addToDataSpec(dataSpec); + + assertThat( + Uri.decode(dataSpec.uri.getQueryParameter(CmcdConfiguration.CMCD_QUERY_PARAMETER_KEY))) + .isEqualTo( + "bl=1800,br=840,bs,cid=\"mediaId\",d=3000,dl=900,key-1=1," + + "key-2=\"stringValue1,stringValue2\",mtp=500,pr=2.00,rtp=1700,sf=d," + + "sid=\"sessionId\",st=l,su,tb=1000"); + } + @Test public void createInstance_withInvalidNonHyphenatedCustomKey_throwsIllegalStateException() { CmcdConfiguration.Factory cmcdConfigurationFactory = @@ -115,15 +174,15 @@ public class CmcdHeadersFactoryTest { assertThrows( IllegalStateException.class, () -> - new CmcdHeadersFactory( + new CmcdData.Factory( cmcdConfiguration, trackSelection, /* bufferedDurationUs= */ 0, /* playbackRate= */ 1.0f, - /* streamingFormat= */ CmcdHeadersFactory.STREAMING_FORMAT_DASH, + /* streamingFormat= */ CmcdData.Factory.STREAMING_FORMAT_DASH, /* isLive= */ true, /* didRebuffer= */ true, /* isBufferEmpty= */ false) - .createHttpRequestHeaders()); + .createCmcdData()); } } diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DefaultDashChunkSource.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DefaultDashChunkSource.java index 2e53d44aa2..7a0cd41f67 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DefaultDashChunkSource.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DefaultDashChunkSource.java @@ -56,7 +56,7 @@ import androidx.media3.exoplayer.source.chunk.MediaChunkIterator; import androidx.media3.exoplayer.source.chunk.SingleSampleMediaChunk; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.CmcdConfiguration; -import androidx.media3.exoplayer.upstream.CmcdHeadersFactory; +import androidx.media3.exoplayer.upstream.CmcdData; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoaderErrorThrower; import androidx.media3.extractor.ChunkIndex; @@ -387,15 +387,15 @@ public class DefaultDashChunkSource implements DashChunkSource { int selectedTrackIndex = trackSelection.getSelectedIndex(); @Nullable - CmcdHeadersFactory cmcdHeadersFactory = + CmcdData.Factory cmcdDataFactory = cmcdConfiguration == null ? null - : new CmcdHeadersFactory( + : new CmcdData.Factory( cmcdConfiguration, trackSelection, bufferedDurationUs, /* playbackRate= */ loadingInfo.playbackSpeed, - /* streamingFormat= */ CmcdHeadersFactory.STREAMING_FORMAT_DASH, + /* streamingFormat= */ CmcdData.Factory.STREAMING_FORMAT_DASH, /* isLive= */ manifest.dynamic, /* didRebuffer= */ loadingInfo.rebufferedSince(lastChunkRequestRealtimeMs), /* isBufferEmpty= */ queue.isEmpty()); @@ -423,7 +423,7 @@ public class DefaultDashChunkSource implements DashChunkSource { trackSelection.getSelectionData(), pendingInitializationUri, pendingIndexUri, - cmcdHeadersFactory); + cmcdDataFactory); return; } } @@ -501,7 +501,7 @@ public class DefaultDashChunkSource implements DashChunkSource { maxSegmentCount, seekTimeUs, nowPeriodTimeUs, - cmcdHeadersFactory); + cmcdDataFactory); } @Override @@ -684,7 +684,7 @@ public class DefaultDashChunkSource implements DashChunkSource { * indexUri} is not {@code null}. * @param indexUri The URI pointing to index data. Can be {@code null} if {@code * initializationUri} is not {@code null}. - * @param cmcdHeadersFactory The {@link CmcdHeadersFactory} for generating CMCD data. + * @param cmcdDataFactory The {@link CmcdData.Factory} for generating CMCD data. */ @RequiresNonNull("#1.chunkExtractor") protected Chunk newInitializationChunk( @@ -695,7 +695,7 @@ public class DefaultDashChunkSource implements DashChunkSource { @Nullable Object trackSelectionData, @Nullable RangedUri initializationUri, @Nullable RangedUri indexUri, - @Nullable CmcdHeadersFactory cmcdHeadersFactory) { + @Nullable CmcdData.Factory cmcdDataFactory) { Representation representation = representationHolder.representation; RangedUri requestUri; if (initializationUri != null) { @@ -709,19 +709,19 @@ public class DefaultDashChunkSource implements DashChunkSource { } else { requestUri = checkNotNull(indexUri); } - ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = - cmcdHeadersFactory == null - ? ImmutableMap.of() - : cmcdHeadersFactory - .setObjectType(CmcdHeadersFactory.OBJECT_TYPE_INIT_SEGMENT) - .createHttpRequestHeaders(); DataSpec dataSpec = DashUtil.buildDataSpec( representation, representationHolder.selectedBaseUrl.url, requestUri, /* flags= */ 0, - httpRequestHeaders); + /* httpRequestHeaders= */ ImmutableMap.of()); + if (cmcdDataFactory != null) { + CmcdData cmcdData = + cmcdDataFactory.setObjectType(CmcdData.Factory.OBJECT_TYPE_INIT_SEGMENT).createCmcdData(); + dataSpec = cmcdData.addToDataSpec(dataSpec); + } + return new InitializationChunk( dataSource, dataSpec, @@ -742,7 +742,7 @@ public class DefaultDashChunkSource implements DashChunkSource { int maxSegmentCount, long seekTimeUs, long nowPeriodTimeUs, - @Nullable CmcdHeadersFactory cmcdHeadersFactory) { + @Nullable CmcdData.Factory cmcdDataFactory) { Representation representation = representationHolder.representation; long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum); RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum); @@ -753,29 +753,29 @@ public class DefaultDashChunkSource implements DashChunkSource { firstSegmentNum, nowPeriodTimeUs) ? 0 : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; - ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = - ImmutableMap.of(); - if (cmcdHeadersFactory != null) { - cmcdHeadersFactory - .setChunkDurationUs(endTimeUs - startTimeUs) - .setObjectType(CmcdHeadersFactory.getObjectType(trackSelection)); - @Nullable - Pair nextObjectAndRangeRequest = - getNextObjectAndRangeRequest(firstSegmentNum, segmentUri, representationHolder); - if (nextObjectAndRangeRequest != null) { - cmcdHeadersFactory - .setNextObjectRequest(nextObjectAndRangeRequest.first) - .setNextRangeRequest(nextObjectAndRangeRequest.second); - } - httpRequestHeaders = cmcdHeadersFactory.createHttpRequestHeaders(); - } DataSpec dataSpec = DashUtil.buildDataSpec( representation, representationHolder.selectedBaseUrl.url, segmentUri, flags, - httpRequestHeaders); + /* httpRequestHeaders= */ ImmutableMap.of()); + if (cmcdDataFactory != null) { + cmcdDataFactory + .setChunkDurationUs(endTimeUs - startTimeUs) + .setObjectType(CmcdData.Factory.getObjectType(trackSelection)); + @Nullable + Pair nextObjectAndRangeRequest = + getNextObjectAndRangeRequest(firstSegmentNum, segmentUri, representationHolder); + if (nextObjectAndRangeRequest != null) { + cmcdDataFactory + .setNextObjectRequest(nextObjectAndRangeRequest.first) + .setNextRangeRequest(nextObjectAndRangeRequest.second); + } + CmcdData cmcdData = cmcdDataFactory.createCmcdData(); + dataSpec = cmcdData.addToDataSpec(dataSpec); + } + return new SingleSampleMediaChunk( dataSource, dataSpec, @@ -812,29 +812,28 @@ public class DefaultDashChunkSource implements DashChunkSource { representationHolder.isSegmentAvailableAtFullNetworkSpeed(segmentNum, nowPeriodTimeUs) ? 0 : DataSpec.FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED; - ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = - ImmutableMap.of(); - if (cmcdHeadersFactory != null) { - cmcdHeadersFactory - .setChunkDurationUs(endTimeUs - startTimeUs) - .setObjectType(CmcdHeadersFactory.getObjectType(trackSelection)); - @Nullable - Pair nextObjectAndRangeRequest = - getNextObjectAndRangeRequest(firstSegmentNum, segmentUri, representationHolder); - if (nextObjectAndRangeRequest != null) { - cmcdHeadersFactory - .setNextObjectRequest(nextObjectAndRangeRequest.first) - .setNextRangeRequest(nextObjectAndRangeRequest.second); - } - httpRequestHeaders = cmcdHeadersFactory.createHttpRequestHeaders(); - } DataSpec dataSpec = DashUtil.buildDataSpec( representation, representationHolder.selectedBaseUrl.url, segmentUri, flags, - httpRequestHeaders); + /* httpRequestHeaders= */ ImmutableMap.of()); + if (cmcdDataFactory != null) { + cmcdDataFactory + .setChunkDurationUs(endTimeUs - startTimeUs) + .setObjectType(CmcdData.Factory.getObjectType(trackSelection)); + @Nullable + Pair nextObjectAndRangeRequest = + getNextObjectAndRangeRequest(firstSegmentNum, segmentUri, representationHolder); + if (nextObjectAndRangeRequest != null) { + cmcdDataFactory + .setNextObjectRequest(nextObjectAndRangeRequest.first) + .setNextRangeRequest(nextObjectAndRangeRequest.second); + } + CmcdData cmcdData = cmcdDataFactory.createCmcdData(); + dataSpec = cmcdData.addToDataSpec(dataSpec); + } long sampleOffsetUs = -representation.presentationTimeOffsetUs; return new ContainerMediaChunk( dataSource, diff --git a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DefaultDashChunkSourceTest.java b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DefaultDashChunkSourceTest.java index 1d9895fa2e..46dcd4891f 100644 --- a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DefaultDashChunkSourceTest.java +++ b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DefaultDashChunkSourceTest.java @@ -301,7 +301,7 @@ public class DefaultDashChunkSourceTest { } @Test - public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCmcdLoggingHeaders() + public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCmcdHttpRequestHeaders() throws Exception { CmcdConfiguration.Factory cmcdConfigurationFactory = CmcdConfiguration.Factory.DEFAULT; MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); @@ -381,7 +381,7 @@ public class DefaultDashChunkSourceTest { } @Test - public void getNextChunk_chunkSourceWithCustomCmcdConfiguration_setsCmcdLoggingHeaders() + public void getNextChunk_chunkSourceWithCustomCmcdConfiguration_setsCmcdHttpRequestHeaders() throws Exception { CmcdConfiguration.Factory cmcdConfigurationFactory = mediaItem -> { @@ -429,7 +429,7 @@ public class DefaultDashChunkSourceTest { @Test public void - getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdLoggingHeaders() + getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdHttpRequestHeaders() throws Exception { CmcdConfiguration.Factory cmcdConfigurationFactory = mediaItem -> { @@ -475,6 +475,53 @@ public class DefaultDashChunkSourceTest { "key-4=5.0"); } + @Test + public void + getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdHttpQueryParameters() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> { + CmcdConfiguration.RequestConfig cmcdRequestConfig = + new CmcdConfiguration.RequestConfig() { + @Override + public ImmutableListMultimap<@CmcdConfiguration.HeaderKey String, String> + getCustomData() { + return new ImmutableListMultimap.Builder< + @CmcdConfiguration.HeaderKey String, String>() + .put(CmcdConfiguration.KEY_CMCD_OBJECT, "key-1=1") + .put(CmcdConfiguration.KEY_CMCD_REQUEST, "key-2=\"stringValue\"") + .build(); + } + }; + + return new CmcdConfiguration( + /* sessionId= */ "sessionId", + /* contentId= */ mediaItem.mediaId, + cmcdRequestConfig, + CmcdConfiguration.MODE_QUERY_PARAMETER); + }; + MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); + DashChunkSource chunkSource = createDashChunkSource(/* numberOfTracks= */ 2, cmcdConfiguration); + ChunkHolder output = new ChunkHolder(); + + chunkSource.getNextChunk( + new LoadingInfo.Builder().setPlaybackPositionUs(0).setPlaybackSpeed(1.0f).build(), + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + output); + + assertThat( + Uri.decode( + output.chunk.dataSpec.uri.getQueryParameter( + CmcdConfiguration.CMCD_QUERY_PARAMETER_KEY))) + .isEqualTo( + "bl=0,br=700,cid=\"mediaId\",d=4000,dl=0,key-1=1,key-2=\"stringValue\"," + + "mtp=1000,nor=\"..%2Fvideo_4000_700000.m4s\",nrr=\"0-\",ot=v,sf=d," + + "sid=\"sessionId\",st=v,su,tb=1300"); + } + @Test public void getNextChunk_afterLastAvailableButBeforeEndOfLiveManifestWithKnownDuration_doesNotReturnEndOfStream() diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsChunkSource.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsChunkSource.java index 908a848c1e..56ead292c1 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsChunkSource.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsChunkSource.java @@ -50,9 +50,8 @@ import androidx.media3.exoplayer.source.chunk.MediaChunkIterator; import androidx.media3.exoplayer.trackselection.BaseTrackSelection; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.CmcdConfiguration; -import androidx.media3.exoplayer.upstream.CmcdHeadersFactory; +import androidx.media3.exoplayer.upstream.CmcdData; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; import com.google.common.primitives.Ints; import java.io.IOException; @@ -178,6 +177,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * an infinite timeout. * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption * information is available in the multivariant playlist. + * @param playerId The {@link PlayerId} of the player using this chunk source. + * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source. */ public HlsChunkSource( HlsExtractorFactory extractorFactory, @@ -488,22 +489,22 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; seenExpectedPlaylistError = false; expectedPlaylistUrl = null; - @Nullable CmcdHeadersFactory cmcdHeadersFactory = null; + @Nullable CmcdData.Factory cmcdDataFactory = null; if (cmcdConfiguration != null) { - cmcdHeadersFactory = - new CmcdHeadersFactory( + cmcdDataFactory = + new CmcdData.Factory( cmcdConfiguration, trackSelection, bufferedDurationUs, /* playbackRate= */ loadingInfo.playbackSpeed, - /* streamingFormat= */ CmcdHeadersFactory.STREAMING_FORMAT_HLS, + /* streamingFormat= */ CmcdData.Factory.STREAMING_FORMAT_HLS, /* isLive= */ !playlist.hasEndTag, /* didRebuffer= */ loadingInfo.rebufferedSince(lastChunkRequestRealtimeMs), /* isBufferEmpty= */ queue.isEmpty()) .setObjectType( getIsMuxedAudioAndVideo() - ? CmcdHeadersFactory.OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO - : CmcdHeadersFactory.getObjectType(trackSelection)); + ? CmcdData.Factory.OBJECT_TYPE_MUXED_AUDIO_AND_VIDEO + : CmcdData.Factory.getObjectType(trackSelection)); long nextChunkMediaSequence = partIndex == C.LENGTH_UNSET @@ -515,7 +516,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; if (nextSegmentBaseHolder != null) { Uri uri = UriUtil.resolveToUri(playlist.baseUri, segmentBaseHolder.segmentBase.url); Uri nextUri = UriUtil.resolveToUri(playlist.baseUri, nextSegmentBaseHolder.segmentBase.url); - cmcdHeadersFactory.setNextObjectRequest(UriUtil.getRelativePath(uri, nextUri)); + cmcdDataFactory.setNextObjectRequest(UriUtil.getRelativePath(uri, nextUri)); String nextRangeRequest = nextSegmentBaseHolder.segmentBase.byteRangeOffset + "-"; if (nextSegmentBaseHolder.segmentBase.byteRangeLength != C.LENGTH_UNSET) { @@ -523,7 +524,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; (nextSegmentBaseHolder.segmentBase.byteRangeOffset + nextSegmentBaseHolder.segmentBase.byteRangeLength); } - cmcdHeadersFactory.setNextRangeRequest(nextRangeRequest); + cmcdDataFactory.setNextRangeRequest(nextRangeRequest); } } lastChunkRequestRealtimeMs = SystemClock.elapsedRealtime(); @@ -534,7 +535,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; getFullEncryptionKeyUri(playlist, segmentBaseHolder.segmentBase.initializationSegment); out.chunk = maybeCreateEncryptionChunkFor( - initSegmentKeyUri, selectedTrackIndex, /* isInitSegment= */ true, cmcdHeadersFactory); + initSegmentKeyUri, selectedTrackIndex, /* isInitSegment= */ true, cmcdDataFactory); if (out.chunk != null) { return; } @@ -542,7 +543,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; Uri mediaSegmentKeyUri = getFullEncryptionKeyUri(playlist, segmentBaseHolder.segmentBase); out.chunk = maybeCreateEncryptionChunkFor( - mediaSegmentKeyUri, selectedTrackIndex, /* isInitSegment= */ false, cmcdHeadersFactory); + mediaSegmentKeyUri, selectedTrackIndex, /* isInitSegment= */ false, cmcdDataFactory); if (out.chunk != null) { return; } @@ -578,7 +579,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /* initSegmentKey= */ keyCache.get(initSegmentKeyUri), shouldSpliceIn, playerId, - cmcdHeadersFactory); + cmcdDataFactory); } private boolean getIsMuxedAudioAndVideo() { @@ -896,7 +897,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Nullable Uri keyUri, int selectedTrackIndex, boolean isInitSegment, - @Nullable CmcdHeadersFactory cmcdHeadersFactory) { + @Nullable CmcdData.Factory cmcdDataFactory) { if (keyUri == null) { return null; } @@ -910,20 +911,16 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return null; } - ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = - ImmutableMap.of(); - if (cmcdHeadersFactory != null) { - if (isInitSegment) { - cmcdHeadersFactory.setObjectType(CmcdHeadersFactory.OBJECT_TYPE_INIT_SEGMENT); - } - httpRequestHeaders = cmcdHeadersFactory.createHttpRequestHeaders(); - } DataSpec dataSpec = - new DataSpec.Builder() - .setUri(keyUri) - .setFlags(DataSpec.FLAG_ALLOW_GZIP) - .setHttpRequestHeaders(httpRequestHeaders) - .build(); + new DataSpec.Builder().setUri(keyUri).setFlags(DataSpec.FLAG_ALLOW_GZIP).build(); + if (cmcdDataFactory != null) { + if (isInitSegment) { + cmcdDataFactory.setObjectType(CmcdData.Factory.OBJECT_TYPE_INIT_SEGMENT); + } + CmcdData cmcdData = cmcdDataFactory.createCmcdData(); + dataSpec = cmcdData.addToDataSpec(dataSpec); + } + return new EncryptionKeyChunk( encryptionDataSource, dataSpec, diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaChunk.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaChunk.java index 182b684c99..8b293dbc6e 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaChunk.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaChunk.java @@ -33,15 +33,13 @@ import androidx.media3.datasource.DataSpec; import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; import androidx.media3.exoplayer.source.chunk.MediaChunk; -import androidx.media3.exoplayer.upstream.CmcdConfiguration; -import androidx.media3.exoplayer.upstream.CmcdHeadersFactory; +import androidx.media3.exoplayer.upstream.CmcdData; import androidx.media3.extractor.DefaultExtractorInput; import androidx.media3.extractor.ExtractorInput; import androidx.media3.extractor.metadata.id3.Id3Decoder; import androidx.media3.extractor.metadata.id3.PrivFrame; import com.google.common.base.Ascii; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; import java.io.EOFException; import java.io.IOException; import java.io.InterruptedIOException; @@ -82,7 +80,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; * @param initSegmentKey The initialization segment decryption key, if fully encrypted. Null * otherwise. * @param shouldSpliceIn Whether samples for this chunk should be spliced into existing samples. - * @param cmcdHeadersFactory The {@link CmcdHeadersFactory} for generating CMCD request headers. + * @param cmcdDataFactory The {@link CmcdData.Factory} for generating {@link CmcdData}. */ public static HlsMediaChunk createInstance( HlsExtractorFactory extractorFactory, @@ -103,23 +101,22 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Nullable byte[] initSegmentKey, boolean shouldSpliceIn, PlayerId playerId, - @Nullable CmcdHeadersFactory cmcdHeadersFactory) { + @Nullable CmcdData.Factory cmcdDataFactory) { // Media segment. HlsMediaPlaylist.SegmentBase mediaSegment = segmentBaseHolder.segmentBase; - ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = - cmcdHeadersFactory == null - ? ImmutableMap.of() - : cmcdHeadersFactory - .setChunkDurationUs(mediaSegment.durationUs) - .createHttpRequestHeaders(); DataSpec dataSpec = new DataSpec.Builder() .setUri(UriUtil.resolveToUri(mediaPlaylist.baseUri, mediaSegment.url)) .setPosition(mediaSegment.byteRangeOffset) .setLength(mediaSegment.byteRangeLength) .setFlags(segmentBaseHolder.isPreload ? FLAG_MIGHT_NOT_USE_FULL_NETWORK_SPEED : 0) - .setHttpRequestHeaders(httpRequestHeaders) .build(); + if (cmcdDataFactory != null) { + CmcdData cmcdData = + cmcdDataFactory.setChunkDurationUs(mediaSegment.durationUs).createCmcdData(); + dataSpec = cmcdData.addToDataSpec(dataSpec); + } + boolean mediaSegmentEncrypted = mediaSegmentKey != null; @Nullable byte[] mediaSegmentIv = @@ -141,19 +138,20 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; ? getEncryptionIvArray(Assertions.checkNotNull(initSegment.encryptionIV)) : null; Uri initSegmentUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, initSegment.url); - ImmutableMap<@CmcdConfiguration.HeaderKey String, String> initHttpRequestHeaders = - cmcdHeadersFactory == null - ? ImmutableMap.of() - : cmcdHeadersFactory - .setObjectType(CmcdHeadersFactory.OBJECT_TYPE_INIT_SEGMENT) - .createHttpRequestHeaders(); initDataSpec = new DataSpec.Builder() .setUri(initSegmentUri) .setPosition(initSegment.byteRangeOffset) .setLength(initSegment.byteRangeLength) - .setHttpRequestHeaders(initHttpRequestHeaders) .build(); + if (cmcdDataFactory != null) { + CmcdData cmcdData = + cmcdDataFactory + .setObjectType(CmcdData.Factory.OBJECT_TYPE_INIT_SEGMENT) + .createCmcdData(); + initDataSpec = cmcdData.addToDataSpec(dataSpec); + } + initDataSource = buildDataSource(dataSource, initSegmentKey, initSegmentIv); } diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsChunkSourceTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsChunkSourceTest.java index 87d4aec93e..9d866b0ec4 100644 --- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsChunkSourceTest.java +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsChunkSourceTest.java @@ -195,8 +195,7 @@ public class HlsChunkSourceTest { } @Test - public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCmcdLoggingHeaders() - throws Exception { + public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCmcdHttpRequestHeaders() { CmcdConfiguration.Factory cmcdConfigurationFactory = CmcdConfiguration.Factory.DEFAULT; MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); CmcdConfiguration cmcdConfiguration = @@ -238,8 +237,8 @@ public class HlsChunkSourceTest { } @Test - public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCorrectBufferStarvationKey() - throws Exception { + public void + getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCorrectBufferStarvationKey() { CmcdConfiguration.Factory cmcdConfigurationFactory = CmcdConfiguration.Factory.DEFAULT; MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); CmcdConfiguration cmcdConfiguration = @@ -289,8 +288,7 @@ public class HlsChunkSourceTest { } @Test - public void getNextChunk_chunkSourceWithCustomCmcdConfiguration_setsCmcdLoggingHeaders() - throws Exception { + public void getNextChunk_chunkSourceWithCustomCmcdConfiguration_setsCmcdHttpRequestHeaders() { CmcdConfiguration.Factory cmcdConfigurationFactory = mediaItem -> { CmcdConfiguration.RequestConfig cmcdRequestConfig = @@ -338,7 +336,7 @@ public class HlsChunkSourceTest { @Test public void - getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdLoggingHeaders() + getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdHttpRequestHeaders() throws Exception { CmcdConfiguration.Factory cmcdConfigurationFactory = mediaItem -> { @@ -385,6 +383,53 @@ public class HlsChunkSourceTest { "key-4=5.0"); } + @Test + public void + getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdHttpQueryParameters() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> { + CmcdConfiguration.RequestConfig cmcdRequestConfig = + new CmcdConfiguration.RequestConfig() { + @Override + public ImmutableListMultimap<@CmcdConfiguration.HeaderKey String, String> + getCustomData() { + return new ImmutableListMultimap.Builder< + @CmcdConfiguration.HeaderKey String, String>() + .put(CmcdConfiguration.KEY_CMCD_OBJECT, "key-1=1") + .put(CmcdConfiguration.KEY_CMCD_REQUEST, "key-2=\"stringValue\"") + .build(); + } + }; + + return new CmcdConfiguration( + /* sessionId= */ "sessionId", + /* contentId= */ mediaItem.mediaId, + cmcdRequestConfig, + CmcdConfiguration.MODE_QUERY_PARAMETER); + }; + MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); + HlsChunkSource testChunkSource = createHlsChunkSource(cmcdConfiguration); + HlsChunkSource.HlsChunkHolder output = new HlsChunkSource.HlsChunkHolder(); + + testChunkSource.getNextChunk( + new LoadingInfo.Builder().setPlaybackPositionUs(0).setPlaybackSpeed(1.0f).build(), + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + /* allowEndOfStream= */ true, + output); + + assertThat( + Uri.decode( + output.chunk.dataSpec.uri.getQueryParameter( + CmcdConfiguration.CMCD_QUERY_PARAMETER_KEY))) + .isEqualTo( + "bl=0,br=800,cid=\"mediaId\",d=4000,dl=0,key-1=1,key-2=\"stringValue\"," + + "nor=\"..%2F3.mp4\",nrr=\"0-\",ot=v,sf=h,sid=\"sessionId\",st=v,su,tb=800"); + } + private HlsChunkSource createHlsChunkSource(@Nullable CmcdConfiguration cmcdConfiguration) { return new HlsChunkSource( HlsExtractorFactory.DEFAULT, diff --git a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/DefaultSsChunkSource.java b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/DefaultSsChunkSource.java index 7e4e51dae2..1ae37c5495 100644 --- a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/DefaultSsChunkSource.java +++ b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/DefaultSsChunkSource.java @@ -43,14 +43,13 @@ import androidx.media3.exoplayer.source.chunk.MediaChunk; import androidx.media3.exoplayer.source.chunk.MediaChunkIterator; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.CmcdConfiguration; -import androidx.media3.exoplayer.upstream.CmcdHeadersFactory; +import androidx.media3.exoplayer.upstream.CmcdData; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.FallbackSelection; import androidx.media3.exoplayer.upstream.LoaderErrorThrower; import androidx.media3.extractor.mp4.FragmentedMp4Extractor; import androidx.media3.extractor.mp4.Track; import androidx.media3.extractor.mp4.TrackEncryptionBox; -import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.util.List; @@ -291,24 +290,24 @@ public class DefaultSsChunkSource implements SsChunkSource { int manifestTrackIndex = trackSelection.getIndexInTrackGroup(trackSelectionIndex); Uri uri = streamElement.buildRequestUri(manifestTrackIndex, chunkIndex); - @Nullable CmcdHeadersFactory cmcdHeadersFactory = null; + @Nullable CmcdData.Factory cmcdDataFactory = null; if (cmcdConfiguration != null) { - cmcdHeadersFactory = - new CmcdHeadersFactory( + cmcdDataFactory = + new CmcdData.Factory( cmcdConfiguration, trackSelection, bufferedDurationUs, /* playbackRate= */ loadingInfo.playbackSpeed, - /* streamingFormat= */ CmcdHeadersFactory.STREAMING_FORMAT_SS, + /* streamingFormat= */ CmcdData.Factory.STREAMING_FORMAT_SS, /* isLive= */ manifest.isLive, /* didRebuffer= */ loadingInfo.rebufferedSince(lastChunkRequestRealtimeMs), /* isBufferEmpty= */ queue.isEmpty()) .setChunkDurationUs(chunkEndTimeUs - chunkStartTimeUs) - .setObjectType(CmcdHeadersFactory.getObjectType(trackSelection)); + .setObjectType(CmcdData.Factory.getObjectType(trackSelection)); if (chunkIndex + 1 < streamElement.chunkCount) { Uri nextUri = streamElement.buildRequestUri(manifestTrackIndex, chunkIndex + 1); - cmcdHeadersFactory.setNextObjectRequest(UriUtil.getRelativePath(uri, nextUri)); + cmcdDataFactory.setNextObjectRequest(UriUtil.getRelativePath(uri, nextUri)); } } lastChunkRequestRealtimeMs = SystemClock.elapsedRealtime(); @@ -325,7 +324,7 @@ public class DefaultSsChunkSource implements SsChunkSource { trackSelection.getSelectionReason(), trackSelection.getSelectionData(), chunkExtractor, - cmcdHeadersFactory); + cmcdDataFactory); } @Override @@ -370,13 +369,13 @@ public class DefaultSsChunkSource implements SsChunkSource { @C.SelectionReason int trackSelectionReason, @Nullable Object trackSelectionData, ChunkExtractor chunkExtractor, - @Nullable CmcdHeadersFactory cmcdHeadersFactory) { - ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = - cmcdHeadersFactory == null - ? ImmutableMap.of() - : cmcdHeadersFactory.createHttpRequestHeaders(); - DataSpec dataSpec = - new DataSpec.Builder().setUri(uri).setHttpRequestHeaders(httpRequestHeaders).build(); + @Nullable CmcdData.Factory cmcdDataFactory) { + DataSpec dataSpec = new DataSpec.Builder().setUri(uri).build(); + if (cmcdDataFactory != null) { + CmcdData cmcdData = cmcdDataFactory.createCmcdData(); + dataSpec = cmcdData.addToDataSpec(dataSpec); + } + // In SmoothStreaming each chunk contains sample timestamps relative to the start of the chunk. // To convert them the absolute timestamps, we need to set sampleOffsetUs to chunkStartTimeUs. long sampleOffsetUs = chunkStartTimeUs; diff --git a/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/DefaultSsChunkSourceTest.java b/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/DefaultSsChunkSourceTest.java index 075ab15309..70ada1a5d2 100644 --- a/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/DefaultSsChunkSourceTest.java +++ b/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/DefaultSsChunkSourceTest.java @@ -51,7 +51,7 @@ public class DefaultSsChunkSourceTest { private static final String SAMPLE_ISMC_1 = "media/smooth-streaming/sample_ismc_1"; @Test - public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCmcdLoggingHeaders() + public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCmcdHttpRequestHeaders() throws Exception { CmcdConfiguration.Factory cmcdConfigurationFactory = CmcdConfiguration.Factory.DEFAULT; MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); @@ -131,7 +131,7 @@ public class DefaultSsChunkSourceTest { } @Test - public void getNextChunk_chunkSourceWithCustomCmcdConfiguration_setsCmcdLoggingHeaders() + public void getNextChunk_chunkSourceWithCustomCmcdConfiguration_setsCmcdHttpRequestHeaders() throws Exception { CmcdConfiguration.Factory cmcdConfigurationFactory = mediaItem -> { @@ -179,7 +179,7 @@ public class DefaultSsChunkSourceTest { @Test public void - getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdLoggingHeaders() + getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdHttpRequestHeaders() throws Exception { CmcdConfiguration.Factory cmcdConfigurationFactory = mediaItem -> { @@ -225,6 +225,53 @@ public class DefaultSsChunkSourceTest { "key-4=5.0"); } + @Test + public void + getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdHttpQueryParameters() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> { + CmcdConfiguration.RequestConfig cmcdRequestConfig = + new CmcdConfiguration.RequestConfig() { + @Override + public ImmutableListMultimap<@CmcdConfiguration.HeaderKey String, String> + getCustomData() { + return new ImmutableListMultimap.Builder< + @CmcdConfiguration.HeaderKey String, String>() + .put(CmcdConfiguration.KEY_CMCD_OBJECT, "key-1=1") + .put(CmcdConfiguration.KEY_CMCD_REQUEST, "key-2=\"stringValue\"") + .build(); + } + }; + + return new CmcdConfiguration( + /* sessionId= */ "sessionId", + /* contentId= */ mediaItem.mediaId, + cmcdRequestConfig, + CmcdConfiguration.MODE_QUERY_PARAMETER); + }; + MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); + SsChunkSource chunkSource = createSsChunkSource(/* numberOfTracks= */ 2, cmcdConfiguration); + ChunkHolder output = new ChunkHolder(); + + chunkSource.getNextChunk( + new LoadingInfo.Builder().setPlaybackPositionUs(0).setPlaybackSpeed(1.0f).build(), + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + output); + + assertThat( + Uri.decode( + output.chunk.dataSpec.uri.getQueryParameter( + CmcdConfiguration.CMCD_QUERY_PARAMETER_KEY))) + .isEqualTo( + "bl=0,br=308,cid=\"mediaId\",d=1968,dl=0,key-1=1,key-2=\"stringValue\"," + + "mtp=1000,nor=\"..%2FFragments(video%3D19680000)\",ot=v,sf=s,sid=\"sessionId\"," + + "st=v,su,tb=1536"); + } + private SsChunkSource createSsChunkSource( int numberOfTracks, @Nullable CmcdConfiguration cmcdConfiguration) throws IOException { Assertions.checkArgument(numberOfTracks < 6);