diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ef317fd3e6..9c3f5b4793 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -10,6 +10,20 @@ information if desired (possibly using `Logger.appendThrowableString(String, Throwable)`). * ExoPlayer: + * Add support for including Common Media Client Data (CMCD) in the + outgoing requests of adaptive streaming formats DASH, HLS, and + SmoothStreaming. The following fields, `br`, `bl`, `cid`, `rtp`, and + `sid`, have been incorporated + ([#8699](https://github.com/google/ExoPlayer/issues/8699)). API + structure and API methods: + * CMCD logging is disabled by default, use + `MediaSource.Factory.setCmcdConfigurationFactory(CmcdConfiguration.Factory + cmcdConfigurationFactory)` to enable it. + * All keys are enabled by default, override + `CmcdConfiguration.RequestConfig.isKeyAllowed(String key)` to filter + out which keys are logged. + * Override `CmcdConfiguration.RequestConfig.getCustomData()` to enable + custom key logging. * Add additional action to manifest of main demo for making it easier to start the demo app with a custom `*.exolist.json` file ([#439](https://github.com/androidx/media/pull/439)). diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java index 4fd667eb26..cdfea0ab2e 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java @@ -38,6 +38,7 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; import androidx.media3.exoplayer.source.ads.AdsLoader; import androidx.media3.exoplayer.source.ads.AdsMediaSource; import androidx.media3.exoplayer.text.SubtitleDecoderFactory; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.extractor.DefaultExtractorsFactory; import androidx.media3.extractor.Extractor; @@ -381,6 +382,15 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { return this; } + @CanIgnoreReturnValue + @UnstableApi + @Override + public DefaultMediaSourceFactory setCmcdConfigurationFactory( + CmcdConfiguration.Factory cmcdConfigurationFactory) { + delegateFactoryLoader.setCmcdConfigurationFactory(checkNotNull(cmcdConfigurationFactory)); + return this; + } + @CanIgnoreReturnValue @UnstableApi @Override @@ -566,6 +576,7 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { private final Map mediaSourceFactories; private DataSource.@MonotonicNonNull Factory dataSourceFactory; + @Nullable private CmcdConfiguration.Factory cmcdConfigurationFactory; @Nullable private DrmSessionManagerProvider drmSessionManagerProvider; @Nullable private LoadErrorHandlingPolicy loadErrorHandlingPolicy; @@ -595,6 +606,9 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { } mediaSourceFactory = mediaSourceFactorySupplier.get(); + if (cmcdConfigurationFactory != null) { + mediaSourceFactory.setCmcdConfigurationFactory(cmcdConfigurationFactory); + } if (drmSessionManagerProvider != null) { mediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider); } @@ -615,6 +629,13 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { } } + public void setCmcdConfigurationFactory(CmcdConfiguration.Factory cmcdConfigurationFactory) { + this.cmcdConfigurationFactory = cmcdConfigurationFactory; + for (MediaSource.Factory mediaSourceFactory : mediaSourceFactories.values()) { + mediaSourceFactory.setCmcdConfigurationFactory(cmcdConfigurationFactory); + } + } + public void setDrmSessionManagerProvider(DrmSessionManagerProvider drmSessionManagerProvider) { this.drmSessionManagerProvider = drmSessionManagerProvider; for (MediaSource.Factory mediaSourceFactory : mediaSourceFactories.values()) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaSource.java index d3753bceb9..d7043eb6da 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MediaSource.java @@ -28,6 +28,7 @@ import androidx.media3.exoplayer.drm.DrmSessionEventListener; import androidx.media3.exoplayer.drm.DrmSessionManager; import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import java.io.IOException; @@ -67,6 +68,19 @@ public interface MediaSource { @SuppressWarnings("deprecation") Factory UNSUPPORTED = MediaSourceFactory.UNSUPPORTED; + /** + * Sets the {@link CmcdConfiguration.Factory} used to obtain a {@link CmcdConfiguration} for a + * {@link MediaItem}. + * + * @return This factory, for convenience. + */ + @UnstableApi + default Factory setCmcdConfigurationFactory( + CmcdConfiguration.Factory cmcdConfigurationFactory) { + // do nothing + return this; + } + /** * Sets the {@link DrmSessionManagerProvider} used to obtain a {@link DrmSessionManager} for a * {@link MediaItem}. 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 new file mode 100644 index 0000000000..f1fffdbe30 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdConfiguration.java @@ -0,0 +1,240 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.upstream; + +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.Nullable; +import androidx.annotation.StringDef; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import com.google.common.collect.ImmutableMap; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.UUID; + +/** Represents a configuration for the Common Media Client Data (CMCD) logging. */ +@UnstableApi +public final class CmcdConfiguration { + + /** + * Header keys SHOULD be allocated to one of the four defined header names based upon their + * expected level of variability: + * + * + */ + @Retention(RetentionPolicy.SOURCE) + @StringDef({KEY_CMCD_OBJECT, KEY_CMCD_REQUEST, KEY_CMCD_SESSION, KEY_CMCD_STATUS}) + @Documented + @Target(TYPE_USE) + public @interface HeaderKey {} + + /** Indicates that the annotated element represents a CMCD key. */ + @Retention(RetentionPolicy.SOURCE) + @StringDef({ + KEY_BITRATE, + KEY_BUFFER_LENGTH, + KEY_CONTENT_ID, + KEY_SESSION_ID, + KEY_MAXIMUM_REQUESTED_BITRATE + }) + @Documented + @Target(TYPE_USE) + public @interface CmcdKey {} + + /** Maximum length for ID fields. */ + public static final int MAX_ID_LENGTH = 64; + + public static final String KEY_CMCD_OBJECT = "CMCD-Object"; + 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 KEY_BITRATE = "br"; + public static final String KEY_BUFFER_LENGTH = "bl"; + public static final String KEY_CONTENT_ID = "cid"; + public static final String KEY_SESSION_ID = "sid"; + public static final String KEY_MAXIMUM_REQUESTED_BITRATE = "rtp"; + + /** + * Factory for {@link CmcdConfiguration} instances. + * + *

Implementations must not make assumptions about which thread called their methods; and must + * be thread-safe. + */ + public interface Factory { + /** + * Creates a {@link CmcdConfiguration} based on the provided {@link MediaItem}. + * + * @param mediaItem The {@link MediaItem} from which to create the CMCD configuration. + * @return A {@link CmcdConfiguration} instance. + */ + CmcdConfiguration createCmcdConfiguration(MediaItem mediaItem); + + /** + * The default factory implementation. + * + *

It creates a {@link CmcdConfiguration} by generating a random session ID and using the + * content ID from {@link MediaItem#mediaId} (or {@link MediaItem#DEFAULT_MEDIA_ID} if the media + * item does not have a {@link MediaItem#mediaId} defined). + * + *

It also utilises a default {@link RequestConfig} implementation that enables all available + * keys, provides empty custom data, and sets the maximum requested bitrate to {@link + * C#RATE_UNSET_INT}. + */ + CmcdConfiguration.Factory DEFAULT = + mediaItem -> + new CmcdConfiguration( + /* sessionId= */ UUID.randomUUID().toString(), + /* contentId= */ mediaItem.mediaId != null + ? mediaItem.mediaId + : MediaItem.DEFAULT_MEDIA_ID, + new RequestConfig() {}); + } + + /** + * Represents configuration which can vary on each request. + * + *

Implementations must not make assumptions about which thread called their methods; and must + * be thread-safe. + */ + public interface RequestConfig { + /** + * Checks whether the specified key is allowed in CMCD logging. By default, all keys are + * allowed. + * + * @param key The key to check. + * @return Whether the key is allowed. + */ + default boolean isKeyAllowed(@CmcdKey String key) { + return true; + } + + /** + * Retrieves the custom data associated with CMCD logging. + * + *

By default, no custom data is provided. + * + *

The key should belong to the {@link HeaderKey}. The value should consist of key-value + * pairs separated by commas. If the value contains one of the keys defined in the {@link + * CmcdKey} list, then this key should not be {@linkplain #isKeyAllowed(String) allowed}, + * otherwise the key could be included twice in the produced log. + * + *

Example: + * + *

+ * + * @return An {@link ImmutableMap} containing the custom data. + */ + default ImmutableMap<@HeaderKey String, String> getCustomData() { + return ImmutableMap.of(); + } + + /** + * Returns the maximum throughput requested in kbps, or {@link C#RATE_UNSET_INT} if the maximum + * throughput is unknown in which case the maximum throughput will not be logged upstream. + * + * @param throughputKbps The throughput in kbps of the audio or video object being requested. + * @return The maximum throughput requested in kbps. + */ + default int getRequestedMaximumThroughputKbps(int throughputKbps) { + return C.RATE_UNSET_INT; + } + } + + /** + * A GUID identifying the current playback session, or {@code null} if unset. + * + *

A playback session typically ties together segments belonging to a single media asset. + * Maximum length is 64 characters. + */ + @Nullable public final String sessionId; + /** + * A GUID identifying the current content, or {@code null} if unset. + * + *

This value is consistent across multiple different sessions and devices and is defined and + * updated at the discretion of the service provider. Maximum length is 64 characters. + */ + @Nullable public final String contentId; + /** Dynamic request specific configuration. */ + public final RequestConfig requestConfig; + + /** Creates an instance. */ + public CmcdConfiguration( + @Nullable String sessionId, @Nullable String contentId, RequestConfig requestConfig) { + 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; + } + + /** + * Whether logging bitrate is allowed based on the {@linkplain RequestConfig request + * configuration}. + */ + public boolean isBitrateLoggingAllowed() { + return requestConfig.isKeyAllowed(KEY_BITRATE); + } + + /** + * Whether logging buffer length is allowed based on the {@linkplain RequestConfig request + * configuration}. + */ + public boolean isBufferLengthLoggingAllowed() { + return requestConfig.isKeyAllowed(KEY_BUFFER_LENGTH); + } + + /** + * Whether logging content ID is allowed based on the {@linkplain RequestConfig request + * configuration}. + */ + public boolean isContentIdLoggingAllowed() { + return requestConfig.isKeyAllowed(KEY_CONTENT_ID); + } + + /** + * Whether logging session ID is allowed based on the {@linkplain RequestConfig request + * configuration}. + */ + public boolean isSessionIdLoggingAllowed() { + return requestConfig.isKeyAllowed(KEY_SESSION_ID); + } + + /** + * Whether logging maximum requested throughput is allowed based on the {@linkplain RequestConfig + * request configuration}. + */ + public boolean isMaximumRequestThroughputLoggingAllowed() { + return requestConfig.isKeyAllowed(KEY_MAXIMUM_REQUESTED_BITRATE); + } +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdLog.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdLog.java new file mode 100644 index 0000000000..6849801d0d --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/upstream/CmcdLog.java @@ -0,0 +1,480 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.upstream; + +import static androidx.media3.common.util.Assertions.checkArgument; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import com.google.common.collect.ImmutableMap; +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +/** + * Represents the data for CMCD (Common Media Client Data) in adaptive streaming formats DASH, HLS, + * and SmoothStreaming. + * + *

It holds various attributes related to the playback of media content according to the + * specifications outlined in the CMCD standard document CTA-5004. + */ +@UnstableApi +public final class CmcdLog { + + /** + * Creates a new instance. + * + * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source. + * @param trackSelection The {@linkplain ExoTrackSelection track selection}. + * @param playbackPositionUs The current playback position in microseconds. + * @param loadPositionUs The current load position in microseconds. + */ + public static CmcdLog createInstance( + CmcdConfiguration cmcdConfiguration, + ExoTrackSelection trackSelection, + long playbackPositionUs, + long loadPositionUs) { + ImmutableMap<@CmcdConfiguration.HeaderKey String, String> customData = + cmcdConfiguration.requestConfig.getCustomData(); + int bitrateKbps = trackSelection.getSelectedFormat().bitrate / 1000; + + CmcdLog.CmcdObject.Builder cmcdObject = + new CmcdLog.CmcdObject.Builder() + .setCustomData(customData.get(CmcdConfiguration.KEY_CMCD_OBJECT)); + if (cmcdConfiguration.isBitrateLoggingAllowed()) { + cmcdObject.setBitrateKbps(bitrateKbps); + } + + CmcdLog.CmcdRequest.Builder cmcdRequest = + new CmcdLog.CmcdRequest.Builder() + .setCustomData(customData.get(CmcdConfiguration.KEY_CMCD_REQUEST)); + if (cmcdConfiguration.isBufferLengthLoggingAllowed()) { + cmcdRequest.setBufferLengthMs( + loadPositionUs == C.TIME_UNSET ? 0 : (loadPositionUs - playbackPositionUs) / 1000); + } + + CmcdLog.CmcdSession.Builder cmcdSession = + new CmcdLog.CmcdSession.Builder() + .setCustomData(customData.get(CmcdConfiguration.KEY_CMCD_SESSION)); + if (cmcdConfiguration.isContentIdLoggingAllowed()) { + cmcdSession.setContentId(cmcdConfiguration.contentId); + } + if (cmcdConfiguration.isSessionIdLoggingAllowed()) { + cmcdSession.setSessionId(cmcdConfiguration.sessionId); + } + + CmcdLog.CmcdStatus.Builder cmcdStatus = + new CmcdLog.CmcdStatus.Builder() + .setCustomData(customData.get(CmcdConfiguration.KEY_CMCD_STATUS)); + if (cmcdConfiguration.isMaximumRequestThroughputLoggingAllowed()) { + cmcdStatus.setMaximumRequestedThroughputKbps( + cmcdConfiguration.requestConfig.getRequestedMaximumThroughputKbps(bitrateKbps)); + } + + return new CmcdLog( + cmcdObject.build(), cmcdRequest.build(), cmcdSession.build(), cmcdStatus.build()); + } + + private final CmcdObject cmcdObject; + private final CmcdRequest cmcdRequest; + private final CmcdSession cmcdSession; + private final CmcdStatus cmcdStatus; + + private CmcdLog( + CmcdObject cmcdObject, + CmcdRequest cmcdRequest, + CmcdSession cmcdSession, + CmcdStatus cmcdStatus) { + this.cmcdObject = cmcdObject; + this.cmcdRequest = cmcdRequest; + this.cmcdSession = cmcdSession; + this.cmcdStatus = cmcdStatus; + } + + public ImmutableMap<@CmcdConfiguration.HeaderKey String, String> getHttpRequestHeaders() { + ImmutableMap.Builder httpRequestHeaders = ImmutableMap.builder(); + this.cmcdObject.populateHttpRequestHeaders(httpRequestHeaders); + this.cmcdRequest.populateHttpRequestHeaders(httpRequestHeaders); + this.cmcdSession.populateHttpRequestHeaders(httpRequestHeaders); + this.cmcdStatus.populateHttpRequestHeaders(httpRequestHeaders); + return httpRequestHeaders.buildOrThrow(); + } + + /** Keys whose values vary with the object being requested. Contains CMCD fields: {@code br}. */ + private static final class CmcdObject { + + /** Builder for {@link CmcdObject} instances. */ + public static final class Builder { + private int bitrateKbps; + @Nullable private String customData; + + /** Creates a new instance with default values. */ + public Builder() { + this.bitrateKbps = C.RATE_UNSET_INT; + } + + /** Sets the {@link CmcdObject#bitrateKbps}. The default value is {@link C#RATE_UNSET_INT}. */ + @CanIgnoreReturnValue + public Builder setBitrateKbps(int bitrateKbps) { + this.bitrateKbps = bitrateKbps; + return this; + } + + /** Sets the {@link CmcdObject#customData}. The default value is {@code null}. */ + @CanIgnoreReturnValue + public Builder setCustomData(@Nullable String customData) { + this.customData = customData; + return this; + } + + public CmcdObject build() { + return new CmcdObject(this); + } + } + + /** + * The encoded bitrate in kbps of the audio or video object being requested, or {@link + * C#RATE_UNSET_INT} if unset. + * + *

This may not be known precisely by the player; however, it MAY be estimated based upon + * playlist/manifest declarations. If the playlist declares both peak and average bitrate + * values, the peak value should be transmitted. + */ + public final int bitrateKbps; + /** + * Custom data where the values of the keys vary with the object being requested, or {@code + * null} if unset. + * + *

The String consists of key-value pairs separated by commas.
+ * Example: {@code key1=intValue,key2="stringValue"}. + */ + @Nullable public final String customData; + + private CmcdObject(Builder builder) { + this.bitrateKbps = builder.bitrateKbps; + this.customData = builder.customData; + } + + /** + * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_OBJECT} values. + * + * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request + * headers. + */ + public void populateHttpRequestHeaders( + ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) { + StringBuilder headerValue = new StringBuilder(); + if (bitrateKbps != C.RATE_UNSET_INT) { + headerValue.append( + Util.formatInvariant("%s=%d,", CmcdConfiguration.KEY_BITRATE, bitrateKbps)); + } + if (!TextUtils.isEmpty(customData)) { + headerValue.append(Util.formatInvariant("%s,", customData)); + } + + if (headerValue.length() == 0) { + return; + } + // Remove the trailing comma as headerValue is not empty + headerValue.setLength(headerValue.length() - 1); + httpRequestHeaders.put(CmcdConfiguration.KEY_CMCD_OBJECT, headerValue.toString()); + } + } + + /** Keys whose values vary with each request. Contains CMCD fields: {@code bl}. */ + private static final class CmcdRequest { + + /** Builder for {@link CmcdRequest} instances. */ + public static final class Builder { + private long bufferLengthMs; + @Nullable private String customData; + + /** Creates a new instance with default values. */ + public Builder() { + this.bufferLengthMs = C.TIME_UNSET; + } + + /** + * Sets the {@link CmcdRequest#bufferLengthMs}. Rounded to nearest 100 ms. The default value + * is {@link C#TIME_UNSET}. + */ + @CanIgnoreReturnValue + public Builder setBufferLengthMs(long bufferLengthMs) { + checkArgument(bufferLengthMs == C.TIME_UNSET || bufferLengthMs >= 0); + this.bufferLengthMs = + bufferLengthMs == C.TIME_UNSET ? bufferLengthMs : ((bufferLengthMs + 50) / 100) * 100; + return this; + } + + /** Sets the {@link CmcdRequest#customData}. The default value is {@code null}. */ + @CanIgnoreReturnValue + public CmcdRequest.Builder setCustomData(@Nullable String customData) { + this.customData = customData; + return this; + } + + public CmcdRequest build() { + return new CmcdRequest(this); + } + } + + /** + * The buffer length in milliseconds associated with the media object being requested, or {@link + * C#TIME_UNSET} if unset. + * + *

This value MUST be rounded to the nearest 100 ms. + */ + public final long bufferLengthMs; + /** + * Custom data where the values of the keys vary with each request, or {@code null} if unset. + * + *

The String consists of key-value pairs separated by commas.
+ * Example: {@code key1=intValue, key2="stringValue"}. + */ + @Nullable public final String customData; + + private CmcdRequest(Builder builder) { + this.bufferLengthMs = builder.bufferLengthMs; + this.customData = builder.customData; + } + + /** + * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_REQUEST} values. + * + * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request + * headers. + */ + public void populateHttpRequestHeaders( + ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) { + StringBuilder headerValue = new StringBuilder(); + if (bufferLengthMs != C.TIME_UNSET) { + headerValue.append( + Util.formatInvariant("%s=%d,", CmcdConfiguration.KEY_BUFFER_LENGTH, bufferLengthMs)); + } + if (!TextUtils.isEmpty(customData)) { + headerValue.append(Util.formatInvariant("%s,", customData)); + } + + if (headerValue.length() == 0) { + return; + } + // Remove the trailing comma as headerValue is not empty + headerValue.setLength(headerValue.length() - 1); + httpRequestHeaders.put(CmcdConfiguration.KEY_CMCD_REQUEST, headerValue.toString()); + } + } + + /** + * Keys whose values are expected to be invariant over the life of the session. Contains CMCD + * fields: {@code cid} and {@code sid}. + */ + private static final class CmcdSession { + + /** Builder for {@link CmcdSession} instances. */ + public static final class Builder { + @Nullable private String contentId; + @Nullable private String sessionId; + @Nullable private String customData; + + /** + * Sets the {@link CmcdSession#contentId}. Maximum length allowed is 64 characters. The + * default value is {@code null}. + */ + @CanIgnoreReturnValue + public Builder setContentId(@Nullable String contentId) { + checkArgument(contentId == null || contentId.length() <= CmcdConfiguration.MAX_ID_LENGTH); + this.contentId = contentId; + return this; + } + + /** + * Sets the {@link CmcdSession#sessionId}. Maximum length allowed is 64 characters. The + * default value is {@code null}. + */ + @CanIgnoreReturnValue + public Builder setSessionId(@Nullable String sessionId) { + checkArgument(sessionId == null || sessionId.length() <= CmcdConfiguration.MAX_ID_LENGTH); + this.sessionId = sessionId; + return this; + } + + /** Sets the {@link CmcdSession#customData}. The default value is {@code null}. */ + @CanIgnoreReturnValue + public CmcdSession.Builder setCustomData(@Nullable String customData) { + this.customData = customData; + return this; + } + + public CmcdSession build() { + return new CmcdSession(this); + } + } + + /** + * A GUID identifying the current content, or {@code null} if unset. + * + *

This value is consistent across multiple different sessions and devices and is defined and + * updated at the discretion of the service provider. Maximum length is 64 characters. + */ + @Nullable public final String contentId; + /** + * A GUID identifying the current playback session, or {@code null} if unset. + * + *

A playback session typically ties together segments belonging to a single media asset. + * Maximum length is 64 characters. + */ + @Nullable public final String sessionId; + /** + * Custom data where the values of the keys are expected to be invariant over the life of the + * session, or {@code null} if unset. + * + *

The String consists of key-value pairs separated by commas.
+ * Example: {@code key1=intValue, key2="stringValue"}. + */ + @Nullable public final String customData; + + private CmcdSession(Builder builder) { + this.contentId = builder.contentId; + this.sessionId = builder.sessionId; + this.customData = builder.customData; + } + + /** + * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_SESSION} values. + * + * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request + * headers. + */ + public void populateHttpRequestHeaders( + ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) { + StringBuilder headerValue = new StringBuilder(); + if (!TextUtils.isEmpty(this.contentId)) { + headerValue.append( + Util.formatInvariant("%s=\"%s\",", CmcdConfiguration.KEY_CONTENT_ID, contentId)); + } + if (!TextUtils.isEmpty(this.sessionId)) { + headerValue.append( + Util.formatInvariant("%s=\"%s\",", CmcdConfiguration.KEY_SESSION_ID, sessionId)); + } + if (!TextUtils.isEmpty(customData)) { + headerValue.append(Util.formatInvariant("%s,", customData)); + } + + if (headerValue.length() == 0) { + return; + } + // Remove the trailing comma as headerValue is not empty + headerValue.setLength(headerValue.length() - 1); + httpRequestHeaders.put(CmcdConfiguration.KEY_CMCD_SESSION, headerValue.toString()); + } + } + + /** + * Keys whose values do not vary with every request or object. Contains CMCD fields: {@code rtp}. + */ + private static final class CmcdStatus { + + /** Builder for {@link CmcdStatus} instances. */ + public static final class Builder { + private int maximumRequestedThroughputKbps; + @Nullable private String customData; + + /** Creates a new instance with default values. */ + public Builder() { + this.maximumRequestedThroughputKbps = C.RATE_UNSET_INT; + } + + /** + * Sets the {@link CmcdStatus#maximumRequestedThroughputKbps}. Rounded to nearest 100 kbps. + * The default value is {@link C#RATE_UNSET_INT}. + */ + @CanIgnoreReturnValue + public Builder setMaximumRequestedThroughputKbps(int maximumRequestedThroughputKbps) { + checkArgument( + maximumRequestedThroughputKbps == C.RATE_UNSET_INT + || maximumRequestedThroughputKbps >= 0); + + this.maximumRequestedThroughputKbps = + maximumRequestedThroughputKbps == C.RATE_UNSET_INT + ? maximumRequestedThroughputKbps + : ((maximumRequestedThroughputKbps + 50) / 100) * 100; + + return this; + } + + /** Sets the {@link CmcdStatus#customData}. The default value is {@code null}. */ + @CanIgnoreReturnValue + public CmcdStatus.Builder setCustomData(@Nullable String customData) { + this.customData = customData; + return this; + } + + public CmcdStatus build() { + return new CmcdStatus(this); + } + } + + /** + * The requested maximum throughput in kbps that the client considers sufficient for delivery of + * the asset, or {@link C#RATE_UNSET_INT} if unset. Values MUST be rounded to the nearest + * 100kbps. + */ + public final int maximumRequestedThroughputKbps; + /** + * Custom data where the values of the keys do not vary with every request or object, or {@code + * null} if unset. + * + *

The String consists of key-value pairs separated by commas.
+ * Example: {@code key1=intValue, key2="stringValue"}. + */ + @Nullable public final String customData; + + private CmcdStatus(Builder builder) { + this.maximumRequestedThroughputKbps = builder.maximumRequestedThroughputKbps; + this.customData = builder.customData; + } + + /** + * Populates the HTTP request headers with {@link CmcdConfiguration#KEY_CMCD_STATUS} values. + * + * @param httpRequestHeaders An {@link ImmutableMap.Builder} used to build the HTTP request + * headers. + */ + public void populateHttpRequestHeaders( + ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders) { + StringBuilder headerValue = new StringBuilder(); + if (maximumRequestedThroughputKbps != C.RATE_UNSET_INT) { + headerValue.append( + Util.formatInvariant( + "%s=%d,", + CmcdConfiguration.KEY_MAXIMUM_REQUESTED_BITRATE, maximumRequestedThroughputKbps)); + } + if (!TextUtils.isEmpty(customData)) { + headerValue.append(Util.formatInvariant("%s,", customData)); + } + + if (headerValue.length() == 0) { + return; + } + // Remove the trailing comma as headerValue is not empty + headerValue.setLength(headerValue.length() - 1); + httpRequestHeaders.put(CmcdConfiguration.KEY_CMCD_STATUS, headerValue.toString()); + } + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdConfigurationTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdConfigurationTest.java new file mode 100644 index 0000000000..9187fb6dfd --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdConfigurationTest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.upstream; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableMap; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link CmcdConfiguration}. */ +@RunWith(AndroidJUnit4.class) +public class CmcdConfigurationTest { + + private static final String TEST_CONTENT_ID = "contentId"; + private static final String TEST_MEDIA_ID = "mediaId"; + private static final String TEST_SESSION_ID = "sessionId"; + private static final String LONG_INVALID_ID = + "9haaks0aousdjts41iczi1ilmkzxrbwf7hkuesvzt2ib44s8cmjtzfcmenzy3ozp67890qwertyuiopasd"; + + @Test + public void invalidSessionId_throwsError() { + assertThrows( + IllegalArgumentException.class, + () -> + new CmcdConfiguration( + /* sessionId= */ LONG_INVALID_ID, + /* contentId= */ null, + new CmcdConfiguration.RequestConfig() {})); + } + + @Test + public void invalidContentId_throwsError() { + assertThrows( + IllegalArgumentException.class, + () -> + new CmcdConfiguration( + /* sessionId= */ null, + /* contentId= */ LONG_INVALID_ID, + new CmcdConfiguration.RequestConfig() {})); + } + + @Test + public void nullRequestConfig_throwsError() { + assertThrows( + NullPointerException.class, + () -> + new CmcdConfiguration( + /* sessionId= */ null, /* contentId= */ null, /* requestConfig= */ null)); + } + + @Test + public void defaultFactory_createsInstance() { + CmcdConfiguration.Factory cmcdConfigurationFactory = CmcdConfiguration.Factory.DEFAULT; + MediaItem mediaItem = new MediaItem.Builder().setMediaId(TEST_MEDIA_ID).build(); + + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); + + assertThat(cmcdConfiguration.contentId).isEqualTo(TEST_MEDIA_ID); + assertThat(cmcdConfiguration.isBitrateLoggingAllowed()).isTrue(); + assertThat(cmcdConfiguration.requestConfig.getCustomData()).isEmpty(); + assertThat( + cmcdConfiguration.requestConfig.getRequestedMaximumThroughputKbps( + /* throughputKbps= */ 0)) + .isEqualTo(C.RATE_UNSET_INT); + } + + @Test + public void customFactory_createsInstance() { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> + new CmcdConfiguration( + TEST_SESSION_ID, + TEST_CONTENT_ID, + new CmcdConfiguration.RequestConfig() { + @Override + public boolean isKeyAllowed(@CmcdConfiguration.CmcdKey String key) { + return key.equals("br") || key.equals("rtp"); + } + + @Override + public ImmutableMap<@CmcdConfiguration.HeaderKey String, String> getCustomData() { + return new ImmutableMap.Builder() + .put("CMCD-Object", "key1=value1") + .put("CMCD-Request", "key2=\"stringValue\"") + .buildOrThrow(); + } + + @Override + public int getRequestedMaximumThroughputKbps(int throughputKbps) { + return 2 * throughputKbps; + } + }); + MediaItem mediaItem = new MediaItem.Builder().setMediaId(TEST_MEDIA_ID).build(); + + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); + + assertThat(cmcdConfiguration.sessionId).isEqualTo(TEST_SESSION_ID); + assertThat(cmcdConfiguration.contentId).isEqualTo(TEST_CONTENT_ID); + assertThat(cmcdConfiguration.isBitrateLoggingAllowed()).isTrue(); + assertThat(cmcdConfiguration.isBufferLengthLoggingAllowed()).isFalse(); + assertThat(cmcdConfiguration.isContentIdLoggingAllowed()).isFalse(); + assertThat(cmcdConfiguration.isSessionIdLoggingAllowed()).isFalse(); + assertThat(cmcdConfiguration.isMaximumRequestThroughputLoggingAllowed()).isTrue(); + assertThat(cmcdConfiguration.requestConfig.getCustomData()) + .containsExactly("CMCD-Object", "key1=value1", "CMCD-Request", "key2=\"stringValue\""); + assertThat( + cmcdConfiguration.requestConfig.getRequestedMaximumThroughputKbps( + /* throughputKbps= */ 100)) + .isEqualTo(200); + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdLogTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdLogTest.java new file mode 100644 index 0000000000..d327c2f96f --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/upstream/CmcdLogTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.upstream; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableMap; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Tests for {@link CmcdLog}. */ +@RunWith(AndroidJUnit4.class) +public class CmcdLogTest { + + @Test + public void createInstance_populatesCmcdHeaders() { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> + new CmcdConfiguration( + "sessionId", + mediaItem.mediaId, + new CmcdConfiguration.RequestConfig() { + @Override + public ImmutableMap<@CmcdConfiguration.HeaderKey String, String> getCustomData() { + return new ImmutableMap.Builder() + .put("CMCD-Object", "key1=value1") + .put("CMCD-Request", "key2=\"stringValue\"") + .buildOrThrow(); + } + + @Override + public int getRequestedMaximumThroughputKbps(int throughputKbps) { + return 2 * throughputKbps; + } + }); + MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaId").build(); + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); + ExoTrackSelection trackSelection = mock(ExoTrackSelection.class); + when(trackSelection.getSelectedFormat()) + .thenReturn(new Format.Builder().setPeakBitrate(840_000).build()); + CmcdLog cmcdLog = + CmcdLog.createInstance( + cmcdConfiguration, + trackSelection, + /* playbackPositionUs= */ 1_000_000, + /* loadPositionUs= */ 2_760_000); + + ImmutableMap<@CmcdConfiguration.HeaderKey String, String> requestHeaders = + cmcdLog.getHttpRequestHeaders(); + + assertThat(requestHeaders) + .containsExactly( + "CMCD-Object", + "br=840,key1=value1", + "CMCD-Request", + "bl=1800,key2=\"stringValue\"", + "CMCD-Session", + "cid=\"mediaId\",sid=\"sessionId\"", + "CMCD-Status", + "rtp=1700"); + } +} diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashChunkSource.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashChunkSource.java index f26c8fd734..37fd959526 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashChunkSource.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashChunkSource.java @@ -26,6 +26,7 @@ import androidx.media3.exoplayer.dash.PlayerEmsgHandler.PlayerTrackEmsgHandler; import androidx.media3.exoplayer.dash.manifest.DashManifest; import androidx.media3.exoplayer.source.chunk.ChunkSource; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.LoaderErrorThrower; import java.util.List; @@ -55,6 +56,7 @@ public interface DashChunkSource extends ChunkSource { * @param transferListener The transfer listener which should be informed of any data transfers. * May be null if no listener is available. * @param playerId The {@link PlayerId} of the player using this chunk source. + * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source. * @return The created {@link DashChunkSource}. */ DashChunkSource createDashChunkSource( @@ -70,7 +72,8 @@ public interface DashChunkSource extends ChunkSource { List closedCaptionFormats, @Nullable PlayerTrackEmsgHandler playerEmsgHandler, @Nullable TransferListener transferListener, - PlayerId playerId); + PlayerId playerId, + @Nullable CmcdConfiguration cmcdConfiguration); } /** diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaPeriod.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaPeriod.java index 0021106f10..10dd2a9a17 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaPeriod.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaPeriod.java @@ -54,6 +54,7 @@ import androidx.media3.exoplayer.source.chunk.ChunkSampleStream; import androidx.media3.exoplayer.source.chunk.ChunkSampleStream.EmbeddedSampleStream; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoaderErrorThrower; import com.google.common.primitives.Ints; @@ -85,6 +86,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /* package */ final int id; private final DashChunkSource.Factory chunkSourceFactory; @Nullable private final TransferListener transferListener; + @Nullable private final CmcdConfiguration cmcdConfiguration; private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final BaseUrlExclusionList baseUrlExclusionList; @@ -116,6 +118,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int periodIndex, DashChunkSource.Factory chunkSourceFactory, @Nullable TransferListener transferListener, + @Nullable CmcdConfiguration cmcdConfiguration, DrmSessionManager drmSessionManager, DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, @@ -132,6 +135,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; this.periodIndex = periodIndex; this.chunkSourceFactory = chunkSourceFactory; this.transferListener = transferListener; + this.cmcdConfiguration = cmcdConfiguration; this.drmSessionManager = drmSessionManager; this.drmEventDispatcher = drmEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; @@ -791,7 +795,8 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; embeddedClosedCaptionTrackFormats, trackPlayerEmsgHandler, transferListener, - playerId); + playerId, + cmcdConfiguration); ChunkSampleStream stream = new ChunkSampleStream<>( trackGroupInfo.trackType, diff --git a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java index 5ebcc34690..aa476b7db9 100644 --- a/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java +++ b/libraries/exoplayer_dash/src/main/java/androidx/media3/exoplayer/dash/DashMediaSource.java @@ -67,6 +67,7 @@ import androidx.media3.exoplayer.source.MediaSourceEventListener.EventDispatcher import androidx.media3.exoplayer.source.MediaSourceFactory; import androidx.media3.exoplayer.source.SequenceableLoader; import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; @@ -106,6 +107,7 @@ public final class DashMediaSource extends BaseMediaSource { private final DashChunkSource.Factory chunkSourceFactory; @Nullable private final DataSource.Factory manifestDataSourceFactory; + private CmcdConfiguration.Factory cmcdConfigurationFactory; private DrmSessionManagerProvider drmSessionManagerProvider; private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; @@ -161,6 +163,13 @@ public final class DashMediaSource extends BaseMediaSource { compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); } + @CanIgnoreReturnValue + @Override + public Factory setCmcdConfigurationFactory(CmcdConfiguration.Factory cmcdConfigurationFactory) { + this.cmcdConfigurationFactory = checkNotNull(cmcdConfigurationFactory); + return this; + } + @CanIgnoreReturnValue @Override public Factory setDrmSessionManagerProvider( @@ -290,6 +299,11 @@ public final class DashMediaSource extends BaseMediaSource { mediaItemBuilder.setUri(Uri.EMPTY); } mediaItem = mediaItemBuilder.build(); + @Nullable + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory == null + ? null + : cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); return new DashMediaSource( mediaItem, manifest, @@ -297,6 +311,7 @@ public final class DashMediaSource extends BaseMediaSource { /* manifestParser= */ null, chunkSourceFactory, compositeSequenceableLoaderFactory, + cmcdConfiguration, drmSessionManagerProvider.get(mediaItem), loadErrorHandlingPolicy, fallbackTargetLiveOffsetMs, @@ -321,6 +336,11 @@ public final class DashMediaSource extends BaseMediaSource { if (!streamKeys.isEmpty()) { manifestParser = new FilteringManifestParser<>(manifestParser, streamKeys); } + @Nullable + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory == null + ? null + : cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); return new DashMediaSource( mediaItem, @@ -329,6 +349,7 @@ public final class DashMediaSource extends BaseMediaSource { manifestParser, chunkSourceFactory, compositeSequenceableLoaderFactory, + cmcdConfiguration, drmSessionManagerProvider.get(mediaItem), loadErrorHandlingPolicy, fallbackTargetLiveOffsetMs, @@ -373,6 +394,7 @@ public final class DashMediaSource extends BaseMediaSource { private final DataSource.Factory manifestDataSourceFactory; private final DashChunkSource.Factory chunkSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + @Nullable private final CmcdConfiguration cmcdConfiguration; private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final BaseUrlExclusionList baseUrlExclusionList; @@ -416,6 +438,7 @@ public final class DashMediaSource extends BaseMediaSource { @Nullable ParsingLoadable.Parser manifestParser, DashChunkSource.Factory chunkSourceFactory, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + @Nullable CmcdConfiguration cmcdConfiguration, DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, long fallbackTargetLiveOffsetMs, @@ -428,6 +451,7 @@ public final class DashMediaSource extends BaseMediaSource { this.manifestDataSourceFactory = manifestDataSourceFactory; this.manifestParser = manifestParser; this.chunkSourceFactory = chunkSourceFactory; + this.cmcdConfiguration = cmcdConfiguration; this.drmSessionManager = drmSessionManager; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.fallbackTargetLiveOffsetMs = fallbackTargetLiveOffsetMs; @@ -507,6 +531,7 @@ public final class DashMediaSource extends BaseMediaSource { periodIndex, chunkSourceFactory, mediaTransferListener, + cmcdConfiguration, drmSessionManager, drmEventDispatcher, loadErrorHandlingPolicy, 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 9e82a2c019..1d3e221059 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 @@ -50,9 +50,12 @@ import androidx.media3.exoplayer.source.chunk.MediaChunk; 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.CmcdLog; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoaderErrorThrower; import androidx.media3.extractor.ChunkIndex; +import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -114,7 +117,8 @@ public class DefaultDashChunkSource implements DashChunkSource { List closedCaptionFormats, @Nullable PlayerTrackEmsgHandler playerEmsgHandler, @Nullable TransferListener transferListener, - PlayerId playerId) { + PlayerId playerId, + @Nullable CmcdConfiguration cmcdConfiguration) { DataSource dataSource = dataSourceFactory.createDataSource(); if (transferListener != null) { dataSource.addTransferListener(transferListener); @@ -134,7 +138,8 @@ public class DefaultDashChunkSource implements DashChunkSource { enableEventMessageTrack, closedCaptionFormats, playerEmsgHandler, - playerId); + playerId, + cmcdConfiguration); } } @@ -146,6 +151,7 @@ public class DefaultDashChunkSource implements DashChunkSource { private final long elapsedRealtimeOffsetMs; private final int maxSegmentsPerLoad; @Nullable private final PlayerTrackEmsgHandler playerTrackEmsgHandler; + @Nullable private final CmcdConfiguration cmcdConfiguration; protected final RepresentationHolder[] representationHolders; @@ -177,6 +183,7 @@ public class DefaultDashChunkSource implements DashChunkSource { * @param playerTrackEmsgHandler The {@link PlayerTrackEmsgHandler} instance to handle emsg * messages targeting the player. Maybe null if this is not necessary. * @param playerId The {@link PlayerId} of the player using this chunk source. + * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source. */ public DefaultDashChunkSource( ChunkExtractor.Factory chunkExtractorFactory, @@ -193,7 +200,8 @@ public class DefaultDashChunkSource implements DashChunkSource { boolean enableEventMessageTrack, List closedCaptionFormats, @Nullable PlayerTrackEmsgHandler playerTrackEmsgHandler, - PlayerId playerId) { + PlayerId playerId, + @Nullable CmcdConfiguration cmcdConfiguration) { this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; this.manifest = manifest; this.baseUrlExclusionList = baseUrlExclusionList; @@ -205,6 +213,7 @@ public class DefaultDashChunkSource implements DashChunkSource { this.elapsedRealtimeOffsetMs = elapsedRealtimeOffsetMs; this.maxSegmentsPerLoad = maxSegmentsPerLoad; this.playerTrackEmsgHandler = playerTrackEmsgHandler; + this.cmcdConfiguration = cmcdConfiguration; long periodDurationUs = manifest.getPeriodDurationUs(periodIndex); @@ -436,6 +445,13 @@ public class DefaultDashChunkSource implements DashChunkSource { } } + @Nullable + CmcdLog cmcdLog = + cmcdConfiguration == null + ? null + : CmcdLog.createInstance( + cmcdConfiguration, trackSelection, playbackPositionUs, loadPositionUs); + long seekTimeUs = queue.isEmpty() ? loadPositionUs : C.TIME_UNSET; out.chunk = newMediaChunk( @@ -448,7 +464,8 @@ public class DefaultDashChunkSource implements DashChunkSource { segmentNum, maxSegmentCount, seekTimeUs, - nowPeriodTimeUs); + nowPeriodTimeUs, + cmcdLog); } @Override @@ -658,10 +675,13 @@ public class DefaultDashChunkSource implements DashChunkSource { long firstSegmentNum, int maxSegmentCount, long seekTimeUs, - long nowPeriodTimeUs) { + long nowPeriodTimeUs, + @Nullable CmcdLog cmcdLog) { Representation representation = representationHolder.representation; long startTimeUs = representationHolder.getSegmentStartTimeUs(firstSegmentNum); RangedUri segmentUri = representationHolder.getSegmentUrl(firstSegmentNum); + ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = + cmcdLog == null ? ImmutableMap.of() : cmcdLog.getHttpRequestHeaders(); if (representationHolder.chunkExtractor == null) { long endTimeUs = representationHolder.getSegmentEndTimeUs(firstSegmentNum); int flags = @@ -672,6 +692,7 @@ public class DefaultDashChunkSource implements DashChunkSource { DataSpec dataSpec = DashUtil.buildDataSpec( representation, representationHolder.selectedBaseUrl.url, segmentUri, flags); + dataSpec = dataSpec.buildUpon().setHttpRequestHeaders(httpRequestHeaders).build(); return new SingleSampleMediaChunk( dataSource, dataSpec, @@ -711,6 +732,7 @@ public class DefaultDashChunkSource implements DashChunkSource { DataSpec dataSpec = DashUtil.buildDataSpec( representation, representationHolder.selectedBaseUrl.url, segmentUri, flags); + dataSpec = dataSpec.buildUpon().setHttpRequestHeaders(httpRequestHeaders).build(); long sampleOffsetUs = -representation.presentationTimeOffsetUs; return new ContainerMediaChunk( dataSource, diff --git a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DashMediaPeriodTest.java b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DashMediaPeriodTest.java index 4b201c2467..e00560e34c 100644 --- a/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DashMediaPeriodTest.java +++ b/libraries/exoplayer_dash/src/test/java/androidx/media3/exoplayer/dash/DashMediaPeriodTest.java @@ -211,6 +211,7 @@ public final class DashMediaPeriodTest { periodIndex, mock(DashChunkSource.Factory.class), mock(TransferListener.class), + /* cmcdConfiguration= */ null, DrmSessionManager.DRM_UNSUPPORTED, new DrmSessionEventListener.EventDispatcher() .withParameters(/* windowIndex= */ 0, mediaPeriodId), 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 720398d6f9..a2ca922600 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 @@ -24,6 +24,7 @@ import android.os.SystemClock; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; import androidx.media3.common.TrackGroup; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Util; @@ -39,6 +40,7 @@ import androidx.media3.exoplayer.source.chunk.Chunk; import androidx.media3.exoplayer.source.chunk.ChunkHolder; import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection; import androidx.media3.exoplayer.trackselection.FixedTrackSelection; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; @@ -97,7 +99,8 @@ public class DefaultDashChunkSourceTest { /* enableEventMessageTrack= */ false, /* closedCaptionFormats= */ ImmutableList.of(), /* playerTrackEmsgHandler= */ null, - PlayerId.UNSET); + PlayerId.UNSET, + /* cmcdConfiguration= */ null); long nowInPeriodUs = Util.msToUs(nowMs - manifest.availabilityStartTimeMs); ChunkHolder output = new ChunkHolder(); @@ -146,7 +149,8 @@ public class DefaultDashChunkSourceTest { /* enableEventMessageTrack= */ false, /* closedCaptionFormats= */ ImmutableList.of(), /* playerTrackEmsgHandler= */ null, - PlayerId.UNSET); + PlayerId.UNSET, + /* cmcdConfiguration= */ null); ChunkHolder output = new ChunkHolder(); chunkSource.getNextChunk( @@ -171,7 +175,8 @@ public class DefaultDashChunkSourceTest { } }; List chunks = new ArrayList<>(); - DashChunkSource chunkSource = createDashChunkSource(/* numberOfTracks= */ 1); + DashChunkSource chunkSource = + createDashChunkSource(/* numberOfTracks= */ 1, /* cmcdConfiguration= */ null); ChunkHolder output = new ChunkHolder(); boolean requestReplacementChunk = true; @@ -228,7 +233,8 @@ public class DefaultDashChunkSourceTest { FALLBACK_TYPE_TRACK, DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_EXCLUSION_MS); } }; - DashChunkSource chunkSource = createDashChunkSource(/* numberOfTracks= */ 4); + DashChunkSource chunkSource = + createDashChunkSource(/* numberOfTracks= */ 4, /* cmcdConfiguration= */ null); ChunkHolder output = new ChunkHolder(); List chunks = new ArrayList<>(); boolean requestReplacementChunk = true; @@ -269,7 +275,8 @@ public class DefaultDashChunkSourceTest { return null; } }; - DashChunkSource chunkSource = createDashChunkSource(/* numberOfTracks= */ 2); + DashChunkSource chunkSource = + createDashChunkSource(/* numberOfTracks= */ 2, /* cmcdConfiguration= */ null); ChunkHolder output = new ChunkHolder(); chunkSource.getNextChunk( /* playbackPositionUs= */ 0, @@ -288,7 +295,127 @@ public class DefaultDashChunkSourceTest { assertThat(requestReplacementChunk).isFalse(); } - private DashChunkSource createDashChunkSource(int numberOfTracks) throws IOException { + @Test + public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCmcdLoggingHeaders() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = CmcdConfiguration.Factory.DEFAULT; + 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( + /* playbackPositionUs= */ 0, + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + output); + + assertThat(output.chunk.dataSpec.httpRequestHeaders) + .containsExactly( + "CMCD-Object", + "br=700", + "CMCD-Request", + "bl=0", + "CMCD-Session", + "cid=\"mediaId\",sid=\"" + cmcdConfiguration.sessionId + "\""); + } + + @Test + public void getNextChunk_chunkSourceWithCustomCmcdConfiguration_setsCmcdLoggingHeaders() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> { + CmcdConfiguration.RequestConfig cmcdRequestConfig = + new CmcdConfiguration.RequestConfig() { + @Override + public boolean isKeyAllowed(String key) { + return !key.equals(CmcdConfiguration.KEY_SESSION_ID); + } + + @Override + public int getRequestedMaximumThroughputKbps(int throughputKbps) { + return 5 * throughputKbps; + } + }; + + return new CmcdConfiguration( + /* sessionId= */ "sessionId", + /* contentId= */ mediaItem.mediaId + "contentIdSuffix", + cmcdRequestConfig); + }; + 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( + /* playbackPositionUs= */ 0, + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + output); + + assertThat(output.chunk.dataSpec.httpRequestHeaders) + .containsExactly( + "CMCD-Object", + "br=700", + "CMCD-Request", + "bl=0", + "CMCD-Session", + "cid=\"mediaIdcontentIdSuffix\"", + "CMCD-Status", + "rtp=3500"); + } + + @Test + public void + getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdLoggingHeaders() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> { + CmcdConfiguration.RequestConfig cmcdRequestConfig = + new CmcdConfiguration.RequestConfig() { + @Override + public ImmutableMap<@CmcdConfiguration.HeaderKey String, String> getCustomData() { + return new ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String>() + .put(CmcdConfiguration.KEY_CMCD_OBJECT, "key1=value1") + .put(CmcdConfiguration.KEY_CMCD_REQUEST, "key2=\"stringValue\"") + .put(CmcdConfiguration.KEY_CMCD_SESSION, "key3=1") + .put(CmcdConfiguration.KEY_CMCD_STATUS, "key4=5.0") + .buildOrThrow(); + } + }; + + return new CmcdConfiguration( + /* sessionId= */ "sessionId", /* contentId= */ mediaItem.mediaId, cmcdRequestConfig); + }; + 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( + /* playbackPositionUs= */ 0, + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + output); + + assertThat(output.chunk.dataSpec.httpRequestHeaders) + .containsExactly( + "CMCD-Object", + "br=700,key1=value1", + "CMCD-Request", + "bl=0,key2=\"stringValue\"", + "CMCD-Session", + "cid=\"mediaId\",sid=\"" + cmcdConfiguration.sessionId + "\",key3=1", + "CMCD-Status", + "key4=5.0"); + } + + private DashChunkSource createDashChunkSource( + int numberOfTracks, @Nullable CmcdConfiguration cmcdConfiguration) throws IOException { Assertions.checkArgument(numberOfTracks < 6); DashManifest manifest = new DashManifestParser() @@ -330,7 +457,8 @@ public class DefaultDashChunkSourceTest { /* enableEventMessageTrack= */ false, /* closedCaptionFormats= */ ImmutableList.of(), /* playerTrackEmsgHandler= */ null, - PlayerId.UNSET); + PlayerId.UNSET, + cmcdConfiguration); } private LoadErrorHandlingPolicy.LoadErrorInfo createFakeLoadErrorInfo( 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 62a8cf8556..82414961ed 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 @@ -47,6 +47,8 @@ import androidx.media3.exoplayer.source.chunk.MediaChunk; 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.CmcdLog; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.primitives.Ints; @@ -130,6 +132,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Nullable private final List muxedCaptionFormats; private final FullSegmentEncryptionKeyCache keyCache; private final PlayerId playerId; + @Nullable private final CmcdConfiguration cmcdConfiguration; private boolean isPrimaryTimestampSource; private byte[] scratchSpace; @@ -170,7 +173,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Nullable TransferListener mediaTransferListener, TimestampAdjusterProvider timestampAdjusterProvider, @Nullable List muxedCaptionFormats, - PlayerId playerId) { + PlayerId playerId, + @Nullable CmcdConfiguration cmcdConfiguration) { this.extractorFactory = extractorFactory; this.playlistTracker = playlistTracker; this.playlistUrls = playlistUrls; @@ -178,6 +182,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; this.timestampAdjusterProvider = timestampAdjusterProvider; this.muxedCaptionFormats = muxedCaptionFormats; this.playerId = playerId; + this.cmcdConfiguration = cmcdConfiguration; keyCache = new FullSegmentEncryptionKeyCache(KEY_CACHE_SIZE); scratchSpace = Util.EMPTY_BYTE_ARRAY; liveEdgeInPeriodTimeUs = C.TIME_UNSET; @@ -493,6 +498,13 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return; } + @Nullable + CmcdLog cmcdLog = + cmcdConfiguration == null + ? null + : CmcdLog.createInstance( + cmcdConfiguration, trackSelection, playbackPositionUs, loadPositionUs); + out.chunk = HlsMediaChunk.createInstance( extractorFactory, @@ -511,7 +523,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /* mediaSegmentKey= */ keyCache.get(mediaSegmentKeyUri), /* initSegmentKey= */ keyCache.get(initSegmentKeyUri), shouldSpliceIn, - playerId); + playerId, + cmcdLog); } @Nullable 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 1de96e0d9a..565836a5cf 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,12 +33,15 @@ 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.CmcdLog; 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; @@ -93,15 +96,19 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; @Nullable byte[] mediaSegmentKey, @Nullable byte[] initSegmentKey, boolean shouldSpliceIn, - PlayerId playerId) { + PlayerId playerId, + @Nullable CmcdLog cmcdLog) { // Media segment. HlsMediaPlaylist.SegmentBase mediaSegment = segmentBaseHolder.segmentBase; + ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = + cmcdLog == null ? ImmutableMap.of() : cmcdLog.getHttpRequestHeaders(); 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(); boolean mediaSegmentEncrypted = mediaSegmentKey != null; @Nullable diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaPeriod.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaPeriod.java index f318597604..18064dd43e 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaPeriod.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaPeriod.java @@ -47,6 +47,7 @@ import androidx.media3.exoplayer.source.SequenceableLoader; import androidx.media3.exoplayer.source.TrackGroupArray; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.extractor.Extractor; import com.google.common.primitives.Ints; @@ -69,6 +70,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsPlaylistTracker.Pla private final HlsPlaylistTracker playlistTracker; private final HlsDataSourceFactory dataSourceFactory; @Nullable private final TransferListener mediaTransferListener; + @Nullable private final CmcdConfiguration cmcdConfiguration; private final DrmSessionManager drmSessionManager; private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; @@ -102,6 +104,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsPlaylistTracker.Pla * and keys. * @param mediaTransferListener The transfer listener to inform of any media data transfers. May * be null if no listener is available. + * @param cmcdConfiguration The {@link CmcdConfiguration} for the period. * @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession * DrmSessions} with. * @param drmEventDispatcher A {@link DrmSessionEventListener.EventDispatcher} used to distribute @@ -121,6 +124,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsPlaylistTracker.Pla HlsPlaylistTracker playlistTracker, HlsDataSourceFactory dataSourceFactory, @Nullable TransferListener mediaTransferListener, + @Nullable CmcdConfiguration cmcdConfiguration, DrmSessionManager drmSessionManager, DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, @@ -135,6 +139,7 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsPlaylistTracker.Pla this.playlistTracker = playlistTracker; this.dataSourceFactory = dataSourceFactory; this.mediaTransferListener = mediaTransferListener; + this.cmcdConfiguration = cmcdConfiguration; this.drmSessionManager = drmSessionManager; this.drmEventDispatcher = drmEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; @@ -777,7 +782,8 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsPlaylistTracker.Pla mediaTransferListener, timestampAdjusterProvider, muxedCaptionFormats, - playerId); + playerId, + cmcdConfiguration); return new HlsSampleStreamWrapper( uid, trackType, diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java index 57ad6b5d4c..6e223787a0 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java @@ -53,6 +53,7 @@ import androidx.media3.exoplayer.source.MediaSourceFactory; import androidx.media3.exoplayer.source.SequenceableLoader; import androidx.media3.exoplayer.source.SinglePeriodTimeline; import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.extractor.Extractor; @@ -105,6 +106,7 @@ public final class HlsMediaSource extends BaseMediaSource private HlsPlaylistParserFactory playlistParserFactory; private HlsPlaylistTracker.Factory playlistTrackerFactory; private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + @Nullable private CmcdConfiguration.Factory cmcdConfigurationFactory; private DrmSessionManagerProvider drmSessionManagerProvider; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private boolean allowChunklessPreparation; @@ -300,6 +302,13 @@ public final class HlsMediaSource extends BaseMediaSource return this; } + @CanIgnoreReturnValue + @Override + public Factory setCmcdConfigurationFactory(CmcdConfiguration.Factory cmcdConfigurationFactory) { + this.cmcdConfigurationFactory = checkNotNull(cmcdConfigurationFactory); + return this; + } + @CanIgnoreReturnValue @Override public Factory setDrmSessionManagerProvider( @@ -344,12 +353,18 @@ public final class HlsMediaSource extends BaseMediaSource playlistParserFactory = new FilteringHlsPlaylistParserFactory(playlistParserFactory, streamKeys); } + @Nullable + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory == null + ? null + : cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); return new HlsMediaSource( mediaItem, hlsDataSourceFactory, extractorFactory, compositeSequenceableLoaderFactory, + cmcdConfiguration, drmSessionManagerProvider.get(mediaItem), loadErrorHandlingPolicy, playlistTrackerFactory.createTracker( @@ -370,6 +385,7 @@ public final class HlsMediaSource extends BaseMediaSource private final MediaItem.LocalConfiguration localConfiguration; private final HlsDataSourceFactory dataSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + @Nullable private final CmcdConfiguration cmcdConfiguration; private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final boolean allowChunklessPreparation; @@ -387,6 +403,7 @@ public final class HlsMediaSource extends BaseMediaSource HlsDataSourceFactory dataSourceFactory, HlsExtractorFactory extractorFactory, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + @Nullable CmcdConfiguration cmcdConfiguration, DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, HlsPlaylistTracker playlistTracker, @@ -400,6 +417,7 @@ public final class HlsMediaSource extends BaseMediaSource this.dataSourceFactory = dataSourceFactory; this.extractorFactory = extractorFactory; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + this.cmcdConfiguration = cmcdConfiguration; this.drmSessionManager = drmSessionManager; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.playlistTracker = playlistTracker; @@ -440,6 +458,7 @@ public final class HlsMediaSource extends BaseMediaSource playlistTracker, dataSourceFactory, mediaTransferListener, + cmcdConfiguration, drmSessionManager, drmEventDispatcher, loadErrorHandlingPolicy, 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 d85d44ef59..806155b4d8 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 @@ -21,19 +21,24 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import android.net.Uri; +import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; import androidx.media3.exoplayer.SeekParameters; import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser; import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.test.utils.ExoPlayerTestRunner; import androidx.media3.test.utils.FakeDataSource; import androidx.media3.test.utils.TestUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.io.InputStream; import org.junit.Before; @@ -63,7 +68,6 @@ public class HlsChunkSourceTest { .build(); @Mock private HlsPlaylistTracker mockPlaylistTracker; - private HlsChunkSource testChunkSource; @Before public void setup() throws IOException { @@ -77,18 +81,6 @@ public class HlsChunkSourceTest { when(mockPlaylistTracker.getPlaylistSnapshot(eq(PLAYLIST_URI), anyBoolean())) .thenReturn(playlist); - testChunkSource = - new HlsChunkSource( - HlsExtractorFactory.DEFAULT, - mockPlaylistTracker, - new Uri[] {IFRAME_URI, PLAYLIST_URI}, - new Format[] {IFRAME_FORMAT, ExoPlayerTestRunner.VIDEO_FORMAT}, - new DefaultHlsDataSourceFactory(new FakeDataSource.Factory()), - /* mediaTransferListener= */ null, - new TimestampAdjusterProvider(), - /* muxedCaptionFormats= */ null, - PlayerId.UNSET); - when(mockPlaylistTracker.isSnapshotValid(eq(PLAYLIST_URI))).thenReturn(true); // Mock that segments totalling PLAYLIST_START_PERIOD_OFFSET_US in duration have been removed // from the start of the playlist. @@ -97,7 +89,9 @@ public class HlsChunkSourceTest { } @Test - public void getAdjustedSeekPositionUs_previousSync() { + public void getAdjustedSeekPositionUs_previousSync() throws IOException { + HlsChunkSource testChunkSource = createHlsChunkSource(/* cmcdConfiguration= */ null); + long adjustedPositionUs = testChunkSource.getAdjustedSeekPositionUs( playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.PREVIOUS_SYNC); @@ -106,7 +100,9 @@ public class HlsChunkSourceTest { } @Test - public void getAdjustedSeekPositionUs_nextSync() { + public void getAdjustedSeekPositionUs_nextSync() throws IOException { + HlsChunkSource testChunkSource = createHlsChunkSource(/* cmcdConfiguration= */ null); + long adjustedPositionUs = testChunkSource.getAdjustedSeekPositionUs( playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.NEXT_SYNC); @@ -115,7 +111,9 @@ public class HlsChunkSourceTest { } @Test - public void getAdjustedSeekPositionUs_nextSyncAtEnd() { + public void getAdjustedSeekPositionUs_nextSyncAtEnd() throws IOException { + HlsChunkSource testChunkSource = createHlsChunkSource(/* cmcdConfiguration= */ null); + long adjustedPositionUs = testChunkSource.getAdjustedSeekPositionUs( playlistTimeToPeriodTimeUs(24_000_000), SeekParameters.NEXT_SYNC); @@ -124,7 +122,9 @@ public class HlsChunkSourceTest { } @Test - public void getAdjustedSeekPositionUs_closestSyncBefore() { + public void getAdjustedSeekPositionUs_closestSyncBefore() throws IOException { + HlsChunkSource testChunkSource = createHlsChunkSource(/* cmcdConfiguration= */ null); + long adjustedPositionUs = testChunkSource.getAdjustedSeekPositionUs( playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.CLOSEST_SYNC); @@ -133,7 +133,9 @@ public class HlsChunkSourceTest { } @Test - public void getAdjustedSeekPositionUs_closestSyncAfter() { + public void getAdjustedSeekPositionUs_closestSyncAfter() throws IOException { + HlsChunkSource testChunkSource = createHlsChunkSource(/* cmcdConfiguration= */ null); + long adjustedPositionUs = testChunkSource.getAdjustedSeekPositionUs( playlistTimeToPeriodTimeUs(19_000_000), SeekParameters.CLOSEST_SYNC); @@ -142,7 +144,9 @@ public class HlsChunkSourceTest { } @Test - public void getAdjustedSeekPositionUs_exact() { + public void getAdjustedSeekPositionUs_exact() throws IOException { + HlsChunkSource testChunkSource = createHlsChunkSource(/* cmcdConfiguration= */ null); + long adjustedPositionUs = testChunkSource.getAdjustedSeekPositionUs( playlistTimeToPeriodTimeUs(17_000_000), SeekParameters.EXACT); @@ -152,6 +156,8 @@ public class HlsChunkSourceTest { @Test public void getAdjustedSeekPositionUs_noIndependentSegments() throws IOException { + HlsChunkSource testChunkSource = createHlsChunkSource(/* cmcdConfiguration= */ null); + InputStream inputStream = TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), PLAYLIST); HlsMediaPlaylist playlist = @@ -168,6 +174,8 @@ public class HlsChunkSourceTest { @Test public void getAdjustedSeekPositionUs_emptyPlaylist() throws IOException { + HlsChunkSource testChunkSource = createHlsChunkSource(/* cmcdConfiguration= */ null); + InputStream inputStream = TestUtil.getInputStream(ApplicationProvider.getApplicationContext(), PLAYLIST_EMPTY); HlsMediaPlaylist playlist = @@ -182,6 +190,142 @@ public class HlsChunkSourceTest { assertThat(periodTimeToPlaylistTimeUs(adjustedPositionUs)).isEqualTo(100_000_000); } + @Test + public void getNextChunk_chunkSourceWithDefaultCmcdConfiguration_setsCmcdLoggingHeaders() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = CmcdConfiguration.Factory.DEFAULT; + 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( + /* playbackPositionUs= */ 0, + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + /* allowEndOfStream= */ true, + output); + + assertThat(output.chunk.dataSpec.httpRequestHeaders) + .containsExactly( + "CMCD-Object", + "br=800", + "CMCD-Request", + "bl=0", + "CMCD-Session", + "cid=\"mediaId\",sid=\"" + cmcdConfiguration.sessionId + "\""); + } + + @Test + public void getNextChunk_chunkSourceWithCustomCmcdConfiguration_setsCmcdLoggingHeaders() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> { + CmcdConfiguration.RequestConfig cmcdRequestConfig = + new CmcdConfiguration.RequestConfig() { + @Override + public boolean isKeyAllowed(String key) { + return !key.equals(CmcdConfiguration.KEY_SESSION_ID); + } + + @Override + public int getRequestedMaximumThroughputKbps(int throughputKbps) { + return 5 * throughputKbps; + } + }; + + return new CmcdConfiguration( + /* sessionId= */ "sessionId", + /* contentId= */ mediaItem.mediaId + "contentIdSuffix", + cmcdRequestConfig); + }; + 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( + /* playbackPositionUs= */ 0, + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + /* allowEndOfStream= */ true, + output); + + assertThat(output.chunk.dataSpec.httpRequestHeaders) + .containsExactly( + "CMCD-Object", + "br=800", + "CMCD-Request", + "bl=0", + "CMCD-Session", + "cid=\"mediaIdcontentIdSuffix\"", + "CMCD-Status", + "rtp=4000"); + } + + @Test + public void + getNextChunk_chunkSourceWithCustomCmcdConfigurationAndCustomData_setsCmcdLoggingHeaders() + throws Exception { + CmcdConfiguration.Factory cmcdConfigurationFactory = + mediaItem -> { + CmcdConfiguration.RequestConfig cmcdRequestConfig = + new CmcdConfiguration.RequestConfig() { + @Override + public ImmutableMap<@CmcdConfiguration.HeaderKey String, String> getCustomData() { + return new ImmutableMap.Builder<@CmcdConfiguration.HeaderKey String, String>() + .put(CmcdConfiguration.KEY_CMCD_OBJECT, "key1=value1") + .put(CmcdConfiguration.KEY_CMCD_REQUEST, "key2=\"stringValue\"") + .put(CmcdConfiguration.KEY_CMCD_SESSION, "key3=1") + .put(CmcdConfiguration.KEY_CMCD_STATUS, "key4=5.0") + .buildOrThrow(); + } + }; + + return new CmcdConfiguration( + /* sessionId= */ "sessionId", /* contentId= */ mediaItem.mediaId, cmcdRequestConfig); + }; + 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( + /* playbackPositionUs= */ 0, + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + /* allowEndOfStream= */ true, + output); + + assertThat(output.chunk.dataSpec.httpRequestHeaders) + .containsExactly( + "CMCD-Object", + "br=800,key1=value1", + "CMCD-Request", + "bl=0,key2=\"stringValue\"", + "CMCD-Session", + "cid=\"mediaId\",sid=\"" + cmcdConfiguration.sessionId + "\",key3=1", + "CMCD-Status", + "key4=5.0"); + } + + private HlsChunkSource createHlsChunkSource(@Nullable CmcdConfiguration cmcdConfiguration) { + return new HlsChunkSource( + HlsExtractorFactory.DEFAULT, + mockPlaylistTracker, + new Uri[] {IFRAME_URI, PLAYLIST_URI}, + new Format[] {IFRAME_FORMAT, ExoPlayerTestRunner.VIDEO_FORMAT}, + new DefaultHlsDataSourceFactory(new FakeDataSource.Factory()), + /* mediaTransferListener= */ null, + new TimestampAdjusterProvider(), + /* muxedCaptionFormats= */ null, + PlayerId.UNSET, + cmcdConfiguration); + } + private static long playlistTimeToPeriodTimeUs(long playlistTimeUs) { return playlistTimeUs + PLAYLIST_START_PERIOD_OFFSET_US; } diff --git a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsMediaPeriodTest.java b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsMediaPeriodTest.java index 6bbf3799cf..fc090b6c76 100644 --- a/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsMediaPeriodTest.java +++ b/libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsMediaPeriodTest.java @@ -84,6 +84,7 @@ public final class HlsMediaPeriodTest { mockPlaylistTracker, mockDataSourceFactory, mock(TransferListener.class), + /* cmcdConfiguration= */ null, mock(DrmSessionManager.class), new DrmSessionEventListener.EventDispatcher() .withParameters(/* windowIndex= */ 0, mediaPeriodId), 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 d64725f033..ab07b1e1c1 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 @@ -39,12 +39,15 @@ import androidx.media3.exoplayer.source.chunk.ContainerMediaChunk; 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.CmcdLog; 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; @@ -66,13 +69,19 @@ public class DefaultSsChunkSource implements SsChunkSource { SsManifest manifest, int streamElementIndex, ExoTrackSelection trackSelection, - @Nullable TransferListener transferListener) { + @Nullable TransferListener transferListener, + @Nullable CmcdConfiguration cmcdConfiguration) { DataSource dataSource = dataSourceFactory.createDataSource(); if (transferListener != null) { dataSource.addTransferListener(transferListener); } return new DefaultSsChunkSource( - manifestLoaderErrorThrower, manifest, streamElementIndex, trackSelection, dataSource); + manifestLoaderErrorThrower, + manifest, + streamElementIndex, + trackSelection, + dataSource, + cmcdConfiguration); } } @@ -80,6 +89,7 @@ public class DefaultSsChunkSource implements SsChunkSource { private final int streamElementIndex; private final ChunkExtractor[] chunkExtractors; private final DataSource dataSource; + @Nullable private final CmcdConfiguration cmcdConfiguration; private ExoTrackSelection trackSelection; private SsManifest manifest; @@ -93,18 +103,21 @@ public class DefaultSsChunkSource implements SsChunkSource { * @param streamElementIndex The index of the stream element in the manifest. * @param trackSelection The track selection. * @param dataSource A {@link DataSource} suitable for loading the media data. + * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source. */ public DefaultSsChunkSource( LoaderErrorThrower manifestLoaderErrorThrower, SsManifest manifest, int streamElementIndex, ExoTrackSelection trackSelection, - DataSource dataSource) { + DataSource dataSource, + @Nullable CmcdConfiguration cmcdConfiguration) { this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; this.manifest = manifest; this.streamElementIndex = streamElementIndex; this.trackSelection = trackSelection; this.dataSource = dataSource; + this.cmcdConfiguration = cmcdConfiguration; StreamElement streamElement = manifest.streamElements[streamElementIndex]; chunkExtractors = new ChunkExtractor[trackSelection.length()]; @@ -267,6 +280,14 @@ public class DefaultSsChunkSource implements SsChunkSource { int manifestTrackIndex = trackSelection.getIndexInTrackGroup(trackSelectionIndex); Uri uri = streamElement.buildRequestUri(manifestTrackIndex, chunkIndex); + @Nullable + CmcdLog cmcdLog = + cmcdConfiguration == null + ? null + : CmcdLog.createInstance( + cmcdConfiguration, trackSelection, playbackPositionUs, loadPositionUs); + ; + out.chunk = newMediaChunk( trackSelection.getSelectedFormat(), @@ -278,7 +299,8 @@ public class DefaultSsChunkSource implements SsChunkSource { chunkSeekTimeUs, trackSelection.getSelectionReason(), trackSelection.getSelectionData(), - chunkExtractor); + chunkExtractor, + cmcdLog); } @Override @@ -322,8 +344,12 @@ public class DefaultSsChunkSource implements SsChunkSource { long chunkSeekTimeUs, @C.SelectionReason int trackSelectionReason, @Nullable Object trackSelectionData, - ChunkExtractor chunkExtractor) { - DataSpec dataSpec = new DataSpec(uri); + ChunkExtractor chunkExtractor, + @Nullable CmcdLog cmcdLog) { + ImmutableMap<@CmcdConfiguration.HeaderKey String, String> httpRequestHeaders = + cmcdLog == null ? ImmutableMap.of() : cmcdLog.getHttpRequestHeaders(); + DataSpec dataSpec = + new DataSpec.Builder().setUri(uri).setHttpRequestHeaders(httpRequestHeaders).build(); // 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/main/java/androidx/media3/exoplayer/smoothstreaming/SsChunkSource.java b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsChunkSource.java index 5f50b4f294..edca06db91 100644 --- a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsChunkSource.java +++ b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsChunkSource.java @@ -21,6 +21,7 @@ import androidx.media3.datasource.TransferListener; import androidx.media3.exoplayer.smoothstreaming.manifest.SsManifest; import androidx.media3.exoplayer.source.chunk.ChunkSource; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.LoaderErrorThrower; /** A {@link ChunkSource} for SmoothStreaming. */ @@ -39,6 +40,7 @@ public interface SsChunkSource extends ChunkSource { * @param trackSelection The track selection. * @param transferListener The transfer listener which should be informed of any data transfers. * May be null if no listener is available. + * @param cmcdConfiguration The {@link CmcdConfiguration} for this chunk source. * @return The created {@link SsChunkSource}. */ SsChunkSource createChunkSource( @@ -46,7 +48,8 @@ public interface SsChunkSource extends ChunkSource { SsManifest manifest, int streamElementIndex, ExoTrackSelection trackSelection, - @Nullable TransferListener transferListener); + @Nullable TransferListener transferListener, + @Nullable CmcdConfiguration cmcdConfiguration); } /** diff --git a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaPeriod.java b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaPeriod.java index 0ff9a96246..dff66a7e51 100644 --- a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaPeriod.java +++ b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaPeriod.java @@ -34,6 +34,7 @@ import androidx.media3.exoplayer.source.TrackGroupArray; import androidx.media3.exoplayer.source.chunk.ChunkSampleStream; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoaderErrorThrower; import java.io.IOException; @@ -49,6 +50,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable private final TransferListener transferListener; private final LoaderErrorThrower manifestLoaderErrorThrower; private final DrmSessionManager drmSessionManager; + @Nullable private final CmcdConfiguration cmcdConfiguration; private final DrmSessionEventListener.EventDispatcher drmEventDispatcher; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher; @@ -66,6 +68,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; SsChunkSource.Factory chunkSourceFactory, @Nullable TransferListener transferListener, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + @Nullable CmcdConfiguration cmcdConfiguration, DrmSessionManager drmSessionManager, DrmSessionEventListener.EventDispatcher drmEventDispatcher, LoadErrorHandlingPolicy loadErrorHandlingPolicy, @@ -76,6 +79,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; this.chunkSourceFactory = chunkSourceFactory; this.transferListener = transferListener; this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; + this.cmcdConfiguration = cmcdConfiguration; this.drmSessionManager = drmSessionManager; this.drmEventDispatcher = drmEventDispatcher; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; @@ -237,7 +241,12 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; int streamElementIndex = trackGroups.indexOf(selection.getTrackGroup()); SsChunkSource chunkSource = chunkSourceFactory.createChunkSource( - manifestLoaderErrorThrower, manifest, streamElementIndex, selection, transferListener); + manifestLoaderErrorThrower, + manifest, + streamElementIndex, + selection, + transferListener, + cmcdConfiguration); return new ChunkSampleStream<>( manifest.streamElements[streamElementIndex].type, null, diff --git a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSource.java b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSource.java index edbb43c61d..7b81f7e50f 100644 --- a/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSource.java +++ b/libraries/exoplayer_smoothstreaming/src/main/java/androidx/media3/exoplayer/smoothstreaming/SsMediaSource.java @@ -56,6 +56,7 @@ import androidx.media3.exoplayer.source.MediaSourceFactory; import androidx.media3.exoplayer.source.SequenceableLoader; import androidx.media3.exoplayer.source.SinglePeriodTimeline; import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; @@ -86,6 +87,7 @@ public final class SsMediaSource extends BaseMediaSource @Nullable private final DataSource.Factory manifestDataSourceFactory; private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + @Nullable private CmcdConfiguration.Factory cmcdConfigurationFactory; private DrmSessionManagerProvider drmSessionManagerProvider; private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private long livePresentationDelayMs; @@ -198,6 +200,13 @@ public final class SsMediaSource extends BaseMediaSource return this; } + @CanIgnoreReturnValue + @Override + public Factory setCmcdConfigurationFactory(CmcdConfiguration.Factory cmcdConfigurationFactory) { + this.cmcdConfigurationFactory = checkNotNull(cmcdConfigurationFactory); + return this; + } + @CanIgnoreReturnValue @Override public Factory setDrmSessionManagerProvider( @@ -248,6 +257,11 @@ public final class SsMediaSource extends BaseMediaSource .setMimeType(MimeTypes.APPLICATION_SS) .setUri(hasUri ? mediaItem.localConfiguration.uri : Uri.EMPTY) .build(); + @Nullable + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory == null + ? null + : cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); return new SsMediaSource( mediaItem, manifest, @@ -255,6 +269,7 @@ public final class SsMediaSource extends BaseMediaSource /* manifestParser= */ null, chunkSourceFactory, compositeSequenceableLoaderFactory, + cmcdConfiguration, drmSessionManagerProvider.get(mediaItem), loadErrorHandlingPolicy, livePresentationDelayMs); @@ -278,6 +293,11 @@ public final class SsMediaSource extends BaseMediaSource if (!streamKeys.isEmpty()) { manifestParser = new FilteringManifestParser<>(manifestParser, streamKeys); } + @Nullable + CmcdConfiguration cmcdConfiguration = + cmcdConfigurationFactory == null + ? null + : cmcdConfigurationFactory.createCmcdConfiguration(mediaItem); return new SsMediaSource( mediaItem, @@ -286,6 +306,7 @@ public final class SsMediaSource extends BaseMediaSource manifestParser, chunkSourceFactory, compositeSequenceableLoaderFactory, + cmcdConfiguration, drmSessionManagerProvider.get(mediaItem), loadErrorHandlingPolicy, livePresentationDelayMs); @@ -317,6 +338,7 @@ public final class SsMediaSource extends BaseMediaSource private final DataSource.Factory manifestDataSourceFactory; private final SsChunkSource.Factory chunkSourceFactory; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + @Nullable private final CmcdConfiguration cmcdConfiguration; private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; private final long livePresentationDelayMs; @@ -341,6 +363,7 @@ public final class SsMediaSource extends BaseMediaSource @Nullable ParsingLoadable.Parser manifestParser, SsChunkSource.Factory chunkSourceFactory, CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + @Nullable CmcdConfiguration cmcdConfiguration, DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadErrorHandlingPolicy, long livePresentationDelayMs) { @@ -356,6 +379,7 @@ public final class SsMediaSource extends BaseMediaSource this.manifestParser = manifestParser; this.chunkSourceFactory = chunkSourceFactory; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + this.cmcdConfiguration = cmcdConfiguration; this.drmSessionManager = drmSessionManager; this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.livePresentationDelayMs = livePresentationDelayMs; @@ -403,6 +427,7 @@ public final class SsMediaSource extends BaseMediaSource chunkSourceFactory, mediaTransferListener, compositeSequenceableLoaderFactory, + cmcdConfiguration, drmSessionManager, drmEventDispatcher, loadErrorHandlingPolicy, diff --git a/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/SsMediaPeriodTest.java b/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/SsMediaPeriodTest.java index e4b8d355cd..5123231ae8 100644 --- a/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/SsMediaPeriodTest.java +++ b/libraries/exoplayer_smoothstreaming/src/test/java/androidx/media3/exoplayer/smoothstreaming/SsMediaPeriodTest.java @@ -67,6 +67,7 @@ public class SsMediaPeriodTest { mock(SsChunkSource.Factory.class), mock(TransferListener.class), mock(CompositeSequenceableLoaderFactory.class), + /* cmcdConfiguration= */ null, mock(DrmSessionManager.class), new DrmSessionEventListener.EventDispatcher() .withParameters(/* windowIndex= */ 0, mediaPeriodId),