From e897608d83f7059802504d4c97e2e1eddd69e163 Mon Sep 17 00:00:00 2001 From: ibaker Date: Mon, 31 Jan 2022 17:08:15 +0000 Subject: [PATCH] Publish the ImaServerSideAdInsertionMediaSource Issue: google/ExoPlayer#8213 #minor-release PiperOrigin-RevId: 425381474 --- .../ImaServerSideAdInsertionMediaSource.java | 1128 +++++++++++++++++ .../ServerSideAdInsertionStreamRequest.java | 476 +++++++ ...erverSideAdInsertionStreamRequestTest.java | 148 +++ 3 files changed, 1752 insertions(+) create mode 100644 libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java create mode 100644 libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ServerSideAdInsertionStreamRequest.java create mode 100644 libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ServerSideAdInsertionStreamRequestTest.java diff --git a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java new file mode 100644 index 00000000000..5932eb9e1df --- /dev/null +++ b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ImaServerSideAdInsertionMediaSource.java @@ -0,0 +1,1128 @@ +/* + * Copyright (C) 2021 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.ima; + +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; +import static androidx.media3.common.util.Util.secToUs; +import static androidx.media3.common.util.Util.sum; +import static androidx.media3.common.util.Util.usToMs; +import static androidx.media3.exoplayer.ima.ImaUtil.expandAdGroupPlaceholder; +import static androidx.media3.exoplayer.ima.ImaUtil.splitAdPlaybackStateForPeriods; +import static androidx.media3.exoplayer.ima.ImaUtil.updateAdDurationAndPropagate; +import static androidx.media3.exoplayer.ima.ImaUtil.updateAdDurationInAdGroup; +import static androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState; +import static java.lang.Math.min; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.view.ViewGroup; +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.media3.common.AdOverlayInfo; +import androidx.media3.common.AdPlaybackState; +import androidx.media3.common.AdViewProvider; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Metadata; +import androidx.media3.common.Player; +import androidx.media3.common.Timeline; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.ConditionVariable; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.TransferListener; +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import androidx.media3.exoplayer.source.CompositeMediaSource; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.ForwardingTimeline; +import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource; +import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource.AdPlaybackStateUpdater; +import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionUtil; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.exoplayer.upstream.Loader; +import androidx.media3.exoplayer.upstream.Loader.LoadErrorAction; +import androidx.media3.exoplayer.upstream.Loader.Loadable; +import androidx.media3.extractor.metadata.emsg.EventMessage; +import androidx.media3.extractor.metadata.id3.TextInformationFrame; +import com.google.ads.interactivemedia.v3.api.Ad; +import com.google.ads.interactivemedia.v3.api.AdDisplayContainer; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent; +import com.google.ads.interactivemedia.v3.api.AdErrorEvent.AdErrorListener; +import com.google.ads.interactivemedia.v3.api.AdEvent; +import com.google.ads.interactivemedia.v3.api.AdEvent.AdEventListener; +import com.google.ads.interactivemedia.v3.api.AdPodInfo; +import com.google.ads.interactivemedia.v3.api.AdsLoader.AdsLoadedListener; +import com.google.ads.interactivemedia.v3.api.AdsManager; +import com.google.ads.interactivemedia.v3.api.AdsManagerLoadedEvent; +import com.google.ads.interactivemedia.v3.api.AdsRenderingSettings; +import com.google.ads.interactivemedia.v3.api.CompanionAdSlot; +import com.google.ads.interactivemedia.v3.api.CuePoint; +import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; +import com.google.ads.interactivemedia.v3.api.ImaSdkSettings; +import com.google.ads.interactivemedia.v3.api.StreamDisplayContainer; +import com.google.ads.interactivemedia.v3.api.StreamManager; +import com.google.ads.interactivemedia.v3.api.StreamRequest; +import com.google.ads.interactivemedia.v3.api.player.VideoProgressUpdate; +import com.google.ads.interactivemedia.v3.api.player.VideoStreamPlayer; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * MediaSource for IMA server side inserted ad streams. + * + *

TODO(bachinger) add code snippet from PlayerActivity + */ +@UnstableApi +public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSource { + + /** + * Factory for creating {@link ImaServerSideAdInsertionMediaSource + * ImaServerSideAdInsertionMediaSources}. + * + *

Apps can use the {@link ImaServerSideAdInsertionMediaSource.Factory} to customized the + * {@link DefaultMediaSourceFactory} that is used to build a player: + * + *

TODO(bachinger) add code snippet from PlayerActivity + */ + public static final class Factory implements MediaSource.Factory { + + private final AdsLoader adsLoader; + private final MediaSource.Factory contentMediaSourceFactory; + + /** + * Creates a new factory for {@link ImaServerSideAdInsertionMediaSource + * ImaServerSideAdInsertionMediaSources}. + * + * @param adsLoader The {@link AdsLoader}. + * @param contentMediaSourceFactory The content media source factory to create content sources. + */ + public Factory(AdsLoader adsLoader, MediaSource.Factory contentMediaSourceFactory) { + this.adsLoader = adsLoader; + this.contentMediaSourceFactory = contentMediaSourceFactory; + } + + @Override + public MediaSource.Factory setLoadErrorHandlingPolicy( + @Nullable LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + contentMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); + return this; + } + + @Override + public MediaSource.Factory setDrmSessionManagerProvider( + @Nullable DrmSessionManagerProvider drmSessionManagerProvider) { + contentMediaSourceFactory.setDrmSessionManagerProvider(drmSessionManagerProvider); + return this; + } + + @Override + public int[] getSupportedTypes() { + return contentMediaSourceFactory.getSupportedTypes(); + } + + @Override + public MediaSource createMediaSource(MediaItem mediaItem) { + Player player = checkNotNull(adsLoader.player); + StreamPlayer streamPlayer = new StreamPlayer(player, mediaItem); + ImaSdkFactory imaSdkFactory = ImaSdkFactory.getInstance(); + StreamDisplayContainer streamDisplayContainer = + createStreamDisplayContainer(imaSdkFactory, adsLoader.configuration, streamPlayer); + com.google.ads.interactivemedia.v3.api.AdsLoader imaAdsLoader = + imaSdkFactory.createAdsLoader( + adsLoader.context, adsLoader.configuration.imaSdkSettings, streamDisplayContainer); + ImaServerSideAdInsertionMediaSource mediaSource = + new ImaServerSideAdInsertionMediaSource( + mediaItem, + player, + imaAdsLoader, + streamPlayer, + contentMediaSourceFactory, + adsLoader.configuration.applicationAdEventListener, + adsLoader.configuration.applicationAdErrorListener); + adsLoader.addMediaSourceResources(mediaSource, streamPlayer, imaAdsLoader); + return mediaSource; + } + } + + /** An ads loader for IMA server side ad insertion streams. */ + public static final class AdsLoader { + + /** Builder for building an {@link AdsLoader}. */ + public static final class Builder { + + private final Context context; + private final AdViewProvider adViewProvider; + + @Nullable private ImaSdkSettings imaSdkSettings; + @Nullable private AdEventListener adEventListener; + @Nullable private AdErrorEvent.AdErrorListener adErrorListener; + private ImmutableList companionAdSlots; + + /** + * Creates an instance. + * + * @param context A context. + * @param adViewProvider A provider for {@link ViewGroup} instances. + */ + public Builder(Context context, AdViewProvider adViewProvider) { + this.context = context; + this.adViewProvider = adViewProvider; + companionAdSlots = ImmutableList.of(); + } + + /** + * Sets the IMA SDK settings. + * + *

If this method is not called the default settings will be used. + * + * @param imaSdkSettings The {@link ImaSdkSettings}. + * @return This builder, for convenience. + */ + public AdsLoader.Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) { + this.imaSdkSettings = imaSdkSettings; + return this; + } + + /** + * Sets the optional {@link AdEventListener} that will be passed to {@link + * AdsManager#addAdEventListener(AdEventListener)}. + * + * @param adEventListener The ad event listener. + * @return This builder, for convenience. + */ + public AdsLoader.Builder setAdEventListener(AdEventListener adEventListener) { + this.adEventListener = adEventListener; + return this; + } + + /** + * Sets the optional {@link AdErrorEvent.AdErrorListener} that will be passed to {@link + * AdsManager#addAdErrorListener(AdErrorEvent.AdErrorListener)}. + * + * @param adErrorListener The {@link AdErrorEvent.AdErrorListener}. + * @return This builder, for convenience. + */ + public AdsLoader.Builder setAdErrorListener(AdErrorEvent.AdErrorListener adErrorListener) { + this.adErrorListener = adErrorListener; + return this; + } + + /** + * Sets the slots to use for companion ads, if they are present in the loaded ad. + * + * @param companionAdSlots The slots to use for companion ads. + * @return This builder, for convenience. + * @see AdDisplayContainer#setCompanionSlots(Collection) + */ + public AdsLoader.Builder setCompanionAdSlots(Collection companionAdSlots) { + this.companionAdSlots = ImmutableList.copyOf(companionAdSlots); + return this; + } + + /** Returns a new {@link AdsLoader}. */ + public AdsLoader build() { + @Nullable ImaSdkSettings imaSdkSettings = this.imaSdkSettings; + if (imaSdkSettings == null) { + imaSdkSettings = ImaSdkFactory.getInstance().createImaSdkSettings(); + imaSdkSettings.setLanguage(Util.getSystemLanguageCodes()[0]); + } + ImaUtil.ServerSideAdInsertionConfiguration configuration = + new ImaUtil.ServerSideAdInsertionConfiguration( + adViewProvider, + imaSdkSettings, + adEventListener, + adErrorListener, + companionAdSlots, + imaSdkSettings.isDebugMode()); + return new AdsLoader(context, configuration); + } + } + + private final ImaUtil.ServerSideAdInsertionConfiguration configuration; + private final Context context; + private final Map + mediaSourceResources; + + @Nullable private Player player; + + private AdsLoader(Context context, ImaUtil.ServerSideAdInsertionConfiguration configuration) { + this.context = context.getApplicationContext(); + this.configuration = configuration; + mediaSourceResources = new HashMap<>(); + } + + /** + * Sets the player. + * + *

This method needs to be called before adding server side ad insertion media items to the + * player. + */ + public void setPlayer(Player player) { + this.player = player; + } + + public void addMediaSourceResources( + ImaServerSideAdInsertionMediaSource mediaSource, + StreamPlayer streamPlayer, + com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) { + mediaSourceResources.put(mediaSource, new MediaSourceResourceHolder(streamPlayer, adsLoader)); + } + + /** Releases resources when the ads loader is no longer needed. */ + public void release() { + for (MediaSourceResourceHolder resourceHolder : mediaSourceResources.values()) { + resourceHolder.streamPlayer.release(); + resourceHolder.adsLoader.release(); + } + mediaSourceResources.clear(); + } + + private static final class MediaSourceResourceHolder { + public final StreamPlayer streamPlayer; + public final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + + private MediaSourceResourceHolder( + StreamPlayer streamPlayer, com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader) { + this.streamPlayer = streamPlayer; + this.adsLoader = adsLoader; + } + } + } + + private final MediaItem mediaItem; + private final Player player; + private final MediaSource.Factory contentMediaSourceFactory; + private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + @Nullable private final AdEventListener applicationAdEventListener; + @Nullable private final AdErrorListener applicationAdErrorListener; + private final ServerSideAdInsertionStreamRequest streamRequest; + private final StreamPlayer streamPlayer; + private final Handler mainHandler; + private final ComponentListener componentListener; + + @Nullable private Loader loader; + @Nullable private StreamManager streamManager; + @Nullable private ServerSideAdInsertionMediaSource serverSideAdInsertionMediaSource; + @Nullable private IOException loadError; + private @MonotonicNonNull Timeline contentTimeline; + private AdPlaybackState adPlaybackState; + + private ImaServerSideAdInsertionMediaSource( + MediaItem mediaItem, + Player player, + com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader, + StreamPlayer streamPlayer, + MediaSource.Factory contentMediaSourceFactory, + @Nullable AdEventListener applicationAdEventListener, + @Nullable AdErrorEvent.AdErrorListener applicationAdErrorListener) { + this.mediaItem = mediaItem; + this.player = player; + this.adsLoader = adsLoader; + this.streamPlayer = streamPlayer; + this.contentMediaSourceFactory = contentMediaSourceFactory; + this.applicationAdEventListener = applicationAdEventListener; + this.applicationAdErrorListener = applicationAdErrorListener; + componentListener = new ComponentListener(); + adPlaybackState = AdPlaybackState.NONE; + mainHandler = Util.createHandlerForCurrentLooper(); + Uri streamRequestUri = checkNotNull(mediaItem.localConfiguration).uri; + streamRequest = ServerSideAdInsertionStreamRequest.fromUri(streamRequestUri); + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + if (loader == null) { + Loader loader = new Loader("ImaServerSideAdInsertionMediaSource"); + player.addListener(componentListener); + StreamManagerLoadable streamManagerLoadable = + new StreamManagerLoadable( + adsLoader, + streamRequest.getStreamRequest(), + streamPlayer, + applicationAdErrorListener, + streamRequest.loadVideoTimeoutMs); + loader.startLoading( + streamManagerLoadable, + new StreamManagerLoadableCallback(), + /* defaultMinRetryCount= */ 0); + this.loader = loader; + } + } + + @Override + protected void onChildSourceInfoRefreshed( + Void id, MediaSource mediaSource, Timeline newTimeline) { + refreshSourceInfo( + new ForwardingTimeline(newTimeline) { + @Override + public Window getWindow( + int windowIndex, Window window, long defaultPositionProjectionUs) { + newTimeline.getWindow(windowIndex, window, defaultPositionProjectionUs); + window.mediaItem = mediaItem; + return window; + } + }); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return checkNotNull(serverSideAdInsertionMediaSource) + .createPeriod(id, allocator, startPositionUs); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + checkNotNull(serverSideAdInsertionMediaSource).releasePeriod(mediaPeriod); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + super.maybeThrowSourceInfoRefreshError(); + if (loadError != null) { + IOException loadError = this.loadError; + this.loadError = null; + throw loadError; + } + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + if (loader != null) { + loader.release(); + player.removeListener(componentListener); + mainHandler.post(() -> setStreamManager(/* streamManager= */ null)); + loader = null; + } + } + + // Internal methods (called on the main thread). + + @MainThread + private void setStreamManager(@Nullable StreamManager streamManager) { + if (this.streamManager == streamManager) { + return; + } + if (this.streamManager != null) { + if (applicationAdEventListener != null) { + this.streamManager.removeAdEventListener(applicationAdEventListener); + } + if (applicationAdErrorListener != null) { + this.streamManager.removeAdErrorListener(applicationAdErrorListener); + } + this.streamManager.removeAdEventListener(componentListener); + this.streamManager.destroy(); + this.streamManager = null; + } + this.streamManager = streamManager; + if (streamManager != null) { + streamManager.addAdEventListener(componentListener); + if (applicationAdEventListener != null) { + streamManager.addAdEventListener(applicationAdEventListener); + } + if (applicationAdErrorListener != null) { + streamManager.addAdErrorListener(applicationAdErrorListener); + } + } + } + + @MainThread + private void setAdPlaybackState(AdPlaybackState adPlaybackState) { + if (adPlaybackState.equals(this.adPlaybackState)) { + return; + } + this.adPlaybackState = adPlaybackState; + invalidateServerSideAdInsertionAdPlaybackState(); + } + + @MainThread + private void setContentTimeline(Timeline contentTimeline) { + if (contentTimeline.equals(this.contentTimeline)) { + return; + } + this.contentTimeline = contentTimeline; + invalidateServerSideAdInsertionAdPlaybackState(); + } + + @MainThread + private void invalidateServerSideAdInsertionAdPlaybackState() { + if (!adPlaybackState.equals(AdPlaybackState.NONE) && contentTimeline != null) { + ImmutableMap splitAdPlaybackStates = + splitAdPlaybackStateForPeriods(adPlaybackState, contentTimeline); + streamPlayer.setAdPlaybackStates(streamRequest.adsId, splitAdPlaybackStates, contentTimeline); + checkNotNull(serverSideAdInsertionMediaSource).setAdPlaybackStates(splitAdPlaybackStates); + } + } + + // Internal methods (called on the playback thread). + + private void setContentUri(Uri contentUri) { + if (serverSideAdInsertionMediaSource != null) { + return; + } + ServerSideAdInsertionMediaSource serverSideAdInsertionMediaSource = + new ServerSideAdInsertionMediaSource( + contentMediaSourceFactory.createMediaSource(MediaItem.fromUri(contentUri)), + componentListener); + this.serverSideAdInsertionMediaSource = serverSideAdInsertionMediaSource; + if (streamRequest.isLiveStream()) { + AdPlaybackState liveAdPlaybackState = + new AdPlaybackState(streamRequest.adsId) + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ C.TIME_END_OF_SOURCE) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + mainHandler.post(() -> setAdPlaybackState(liveAdPlaybackState)); + } + prepareChildSource(/* id= */ null, serverSideAdInsertionMediaSource); + } + + // Static methods. + + private static AdPlaybackState setVodAdGroupPlaceholders( + List cuePoints, AdPlaybackState adPlaybackState) { + for (int i = 0; i < cuePoints.size(); i++) { + CuePoint cuePoint = cuePoints.get(i); + adPlaybackState = + addAdGroupToAdPlaybackState( + adPlaybackState, + /* fromPositionUs= */ secToUs(cuePoint.getStartTime()), + /* contentResumeOffsetUs= */ 0, + /* adDurationsUs...= */ secToUs(cuePoint.getEndTime() - cuePoint.getStartTime())); + } + return adPlaybackState; + } + + private static AdPlaybackState setVodAdInPlaceholder(Ad ad, AdPlaybackState adPlaybackState) { + AdPodInfo adPodInfo = ad.getAdPodInfo(); + // Handle post rolls that have a podIndex of -1. + int adGroupIndex = + adPodInfo.getPodIndex() == -1 ? adPlaybackState.adGroupCount - 1 : adPodInfo.getPodIndex(); + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + if (adGroup.count < adPodInfo.getTotalAds()) { + adPlaybackState = + expandAdGroupPlaceholder( + adGroupIndex, + /* adGroupDurationUs= */ secToUs(adPodInfo.getMaxDuration()), + adIndexInAdGroup, + /* adDurationUs= */ secToUs(ad.getDuration()), + /* adsInAdGroupCount= */ adPodInfo.getTotalAds(), + adPlaybackState); + } else if (adIndexInAdGroup < adGroup.count - 1) { + adPlaybackState = + updateAdDurationInAdGroup( + adGroupIndex, + adIndexInAdGroup, + /* adDurationUs= */ secToUs(ad.getDuration()), + adPlaybackState); + } + return adPlaybackState; + } + + private static AdPlaybackState addLiveAdBreak( + Ad ad, long currentPeriodPositionUs, AdPlaybackState adPlaybackState) { + AdPodInfo adPodInfo = ad.getAdPodInfo(); + long adDurationUs = secToUs(ad.getDuration()); + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + + // TODO(b/208398934) Support seeking backwards. + if (adIndexInAdGroup == 0 || adPlaybackState.adGroupCount == 1) { + // First ad of group. Create a new group with all ads. + long[] adDurationsUs = + updateAdDurationAndPropagate( + new long[adPodInfo.getTotalAds()], + adIndexInAdGroup, + adDurationUs, + secToUs(adPodInfo.getMaxDuration())); + adPlaybackState = + addAdGroupToAdPlaybackState( + adPlaybackState, + /* fromPositionUs= */ currentPeriodPositionUs, + /* contentResumeOffsetUs= */ sum(adDurationsUs), + /* adDurationsUs...= */ adDurationsUs); + } else { + int adGroupIndex = adPlaybackState.adGroupCount - 2; + adPlaybackState = + updateAdDurationInAdGroup(adGroupIndex, adIndexInAdGroup, adDurationUs, adPlaybackState); + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + return adPlaybackState.withContentResumeOffsetUs( + adGroupIndex, min(adGroup.contentResumeOffsetUs, sum(adGroup.durationsUs))); + } + return adPlaybackState; + } + + private static AdPlaybackState skipAd(Ad ad, AdPlaybackState adPlaybackState) { + AdPodInfo adPodInfo = ad.getAdPodInfo(); + int adGroupIndex = adPodInfo.getPodIndex(); + // IMA SDK always returns index starting at 1. + int adIndexInAdGroup = adPodInfo.getAdPosition() - 1; + return adPlaybackState.withSkippedAd(adGroupIndex, adIndexInAdGroup); + } + + private final class ComponentListener + implements AdEvent.AdEventListener, Player.Listener, AdPlaybackStateUpdater { + + // Implement Player.Listener. + + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + if (reason != Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { + // Only auto transitions within the same or to the next media item are of interest. + return; + } + + if (mediaItem.equals(oldPosition.mediaItem) && !mediaItem.equals(newPosition.mediaItem)) { + // Playback automatically transitioned to the next media item. Notify the SDK. + streamPlayer.onContentCompleted(); + } + + if (!mediaItem.equals(oldPosition.mediaItem) + || !mediaItem.equals(newPosition.mediaItem) + || !streamRequest.adsId.equals( + player + .getCurrentTimeline() + .getPeriodByUid(checkNotNull(newPosition.periodUid), new Timeline.Period()) + .getAdsId())) { + // Discontinuity not within this ad media source. + return; + } + + if (oldPosition.adGroupIndex != C.INDEX_UNSET && newPosition.adGroupIndex == C.INDEX_UNSET) { + AdPlaybackState newAdPlaybackState = adPlaybackState; + for (int i = 0; i <= oldPosition.adIndexInAdGroup; i++) { + int state = newAdPlaybackState.getAdGroup(oldPosition.adGroupIndex).states[i]; + if (state != AdPlaybackState.AD_STATE_SKIPPED + && state != AdPlaybackState.AD_STATE_ERROR) { + newAdPlaybackState = + newAdPlaybackState.withPlayedAd( + oldPosition.adGroupIndex, /* adIndexInAdGroup= */ i); + } + } + setAdPlaybackState(newAdPlaybackState); + } + } + + @Override + public void onMetadata(Metadata metadata) { + if (!isCurrentAdPlaying(player, mediaItem, streamRequest.adsId)) { + return; + } + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof TextInformationFrame) { + TextInformationFrame textFrame = (TextInformationFrame) entry; + if ("TXXX".equals(textFrame.id)) { + streamPlayer.triggerUserTextReceived(textFrame.value); + } + } else if (entry instanceof EventMessage) { + EventMessage eventMessage = (EventMessage) entry; + String eventMessageValue = new String(eventMessage.messageData); + streamPlayer.triggerUserTextReceived(eventMessageValue); + } + } + } + + @Override + public void onPlaybackStateChanged(@Player.State int state) { + if (state == Player.STATE_ENDED + && isCurrentAdPlaying(player, mediaItem, streamRequest.adsId)) { + streamPlayer.onContentCompleted(); + } + } + + @Override + public void onVolumeChanged(float volume) { + if (!isCurrentAdPlaying(player, mediaItem, streamRequest.adsId)) { + return; + } + int volumePct = (int) Math.floor(volume * 100); + streamPlayer.onContentVolumeChanged(volumePct); + } + + // Implement AdEvent.AdEventListener. + + @MainThread + @Override + public void onAdEvent(AdEvent event) { + AdPlaybackState newAdPlaybackState = adPlaybackState; + switch (event.getType()) { + case CUEPOINTS_CHANGED: + // CUEPOINTS_CHANGED event is firing multiple times with the same queue points. + if (!streamRequest.isLiveStream() && newAdPlaybackState.equals(AdPlaybackState.NONE)) { + newAdPlaybackState = + setVodAdGroupPlaceholders( + checkNotNull(streamManager).getCuePoints(), + new AdPlaybackState(streamRequest.adsId)); + } + break; + case LOADED: + if (streamRequest.isLiveStream()) { + Timeline timeline = player.getCurrentTimeline(); + Timeline.Window window = + timeline.getWindow(player.getCurrentMediaItemIndex(), new Timeline.Window()); + if (window.lastPeriodIndex > window.firstPeriodIndex) { + // multi-period live not integrated + return; + } + long positionInWindowUs = + timeline.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period()) + .positionInWindowUs; + long currentPeriodPosition = + Util.msToUs(player.getCurrentPosition()) - positionInWindowUs; + newAdPlaybackState = + addLiveAdBreak( + event.getAd(), + currentPeriodPosition, + newAdPlaybackState.equals(AdPlaybackState.NONE) + ? new AdPlaybackState(streamRequest.adsId) + : newAdPlaybackState); + } else { + newAdPlaybackState = setVodAdInPlaceholder(event.getAd(), newAdPlaybackState); + } + break; + case SKIPPED: + if (!streamRequest.isLiveStream()) { + newAdPlaybackState = skipAd(event.getAd(), newAdPlaybackState); + } + break; + default: + // Do nothing. + break; + } + setAdPlaybackState(newAdPlaybackState); + } + + // Implement AdPlaybackStateUpdater (called on the playback thread). + + @Override + public boolean onAdPlaybackStateUpdateRequested(Timeline contentTimeline) { + mainHandler.post(() -> setContentTimeline(contentTimeline)); + // Defer source refresh to ad playback state update for VOD. Refresh immediately when live + // with single period. + return !streamRequest.isLiveStream() || contentTimeline.getPeriodCount() > 1; + } + } + + private final class StreamManagerLoadableCallback + implements Loader.Callback { + + @Override + public void onLoadCompleted( + StreamManagerLoadable loadable, long elapsedRealtimeMs, long loadDurationMs) { + mainHandler.post(() -> setStreamManager(checkNotNull(loadable.getStreamManager()))); + setContentUri(checkNotNull(loadable.getContentUri())); + } + + @Override + public void onLoadCanceled( + StreamManagerLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + // We only cancel when the loader is released. + checkState(released); + } + + @Override + public LoadErrorAction onLoadError( + StreamManagerLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + loadError = error; + return Loader.DONT_RETRY; + } + } + + /** Loads the {@link StreamManager} and the content URI. */ + private static class StreamManagerLoadable + implements Loadable, AdsLoadedListener, AdErrorListener { + + private final com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader; + private final StreamRequest request; + private final StreamPlayer streamPlayer; + @Nullable private final AdErrorListener adErrorListener; + private final int loadVideoTimeoutMs; + private final ConditionVariable conditionVariable; + + @Nullable private volatile StreamManager streamManager; + @Nullable private volatile Uri contentUri; + private volatile boolean cancelled; + private volatile boolean error; + @Nullable private volatile String errorMessage; + private volatile int errorCode; + + /** Creates an instance. */ + private StreamManagerLoadable( + com.google.ads.interactivemedia.v3.api.AdsLoader adsLoader, + StreamRequest request, + StreamPlayer streamPlayer, + @Nullable AdErrorListener adErrorListener, + int loadVideoTimeoutMs) { + this.adsLoader = adsLoader; + this.request = request; + this.streamPlayer = streamPlayer; + this.adErrorListener = adErrorListener; + this.loadVideoTimeoutMs = loadVideoTimeoutMs; + conditionVariable = new ConditionVariable(); + errorCode = -1; + } + + /** Returns the DAI content URI or null if not yet available. */ + @Nullable + public Uri getContentUri() { + return contentUri; + } + + /** Returns the stream manager or null if not yet loaded. */ + @Nullable + public StreamManager getStreamManager() { + return streamManager; + } + + // Implement Loadable. + + @Override + public void load() throws IOException { + try { + // SDK will call loadUrl on stream player for SDK once manifest uri is available. + streamPlayer.setStreamLoadListener( + (streamUri, subtitles) -> { + contentUri = Uri.parse(streamUri); + conditionVariable.open(); + }); + if (adErrorListener != null) { + adsLoader.addAdErrorListener(adErrorListener); + } + adsLoader.addAdsLoadedListener(this); + adsLoader.addAdErrorListener(this); + adsLoader.requestStream(request); + while (contentUri == null && !cancelled && !error) { + try { + conditionVariable.block(); + } catch (InterruptedException e) { + /* Do nothing. */ + } + } + if (error && contentUri == null) { + throw new IOException(errorMessage + " [errorCode: " + errorCode + "]"); + } + } finally { + adsLoader.removeAdsLoadedListener(this); + adsLoader.removeAdErrorListener(this); + if (adErrorListener != null) { + adsLoader.removeAdErrorListener(adErrorListener); + } + } + } + + @Override + public void cancelLoad() { + cancelled = true; + } + + // AdsLoader.AdsLoadedListener implementation. + + @MainThread + @Override + public void onAdsManagerLoaded(AdsManagerLoadedEvent event) { + StreamManager streamManager = event.getStreamManager(); + if (streamManager == null) { + error = true; + errorMessage = "streamManager is null after ads manager has been loaded"; + conditionVariable.open(); + return; + } + AdsRenderingSettings adsRenderingSettings = + ImaSdkFactory.getInstance().createAdsRenderingSettings(); + adsRenderingSettings.setLoadVideoTimeout(loadVideoTimeoutMs); + // After initialization completed the streamUri will be reported to the streamPlayer. + streamManager.init(adsRenderingSettings); + this.streamManager = streamManager; + } + + // AdErrorEvent.AdErrorListener implementation. + + @MainThread + @Override + public void onAdError(AdErrorEvent adErrorEvent) { + error = true; + if (adErrorEvent.getError() != null) { + @Nullable String errorMessage = adErrorEvent.getError().getMessage(); + if (errorMessage != null) { + this.errorMessage = errorMessage.replace('\n', ' '); + } + errorCode = adErrorEvent.getError().getErrorCodeNumber(); + } + conditionVariable.open(); + } + } + + /** + * Receives the content URI from the SDK and sends back in-band media metadata and playback + * progression data to the SDK. + */ + private static final class StreamPlayer implements VideoStreamPlayer { + + /** A listener to listen for the stream URI loaded by the SDK. */ + public interface StreamLoadListener { + /** + * Loads a stream with dynamic ad insertion given the stream url and subtitles array. The + * subtitles array is only used in VOD streams. + * + *

Each entry in the subtitles array is a HashMap that corresponds to a language. Each map + * will have a "language" key with a two letter language string value, a "language name" to + * specify the set of subtitles if multiple sets exist for the same language, and one or more + * subtitle key/value pairs. Here's an example the map for English: + * + *

"language" -> "en" "language_name" -> "English" "webvtt" -> + * "https://example.com/vtt/en.vtt" "ttml" -> "https://example.com/ttml/en.ttml" + */ + void onLoadStream(String streamUri, List> subtitles); + } + + private final List callbacks; + private final Player player; + private final MediaItem mediaItem; + private final Timeline.Window window; + private final Timeline.Period period; + + private ImmutableMap adPlaybackStates; + @Nullable private Timeline contentTimeline; + @Nullable private Object adsId; + @Nullable private StreamLoadListener streamLoadListener; + + /** Creates an instance. */ + public StreamPlayer(Player player, MediaItem mediaItem) { + this.player = player; + this.mediaItem = mediaItem; + callbacks = new ArrayList<>(/* initialCapacity= */ 1); + adPlaybackStates = ImmutableMap.of(); + window = new Timeline.Window(); + period = new Timeline.Period(); + } + + /** Registers the ad playback states matching to the given content timeline. */ + public void setAdPlaybackStates( + Object adsId, + ImmutableMap adPlaybackStates, + Timeline contentTimeline) { + this.adsId = adsId; + this.adPlaybackStates = adPlaybackStates; + this.contentTimeline = contentTimeline; + } + + /** Sets the {@link StreamLoadListener} to be called when the SSAI content URI was loaded. */ + public void setStreamLoadListener(StreamLoadListener listener) { + streamLoadListener = Assertions.checkNotNull(listener); + } + + /** Called when the content has completed playback. */ + public void onContentCompleted() { + for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) { + callback.onContentComplete(); + } + } + + /** Called when the content player changed the volume. */ + public void onContentVolumeChanged(int volumePct) { + for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) { + callback.onVolumeChanged(volumePct); + } + } + + /** Releases the player. */ + public void release() { + callbacks.clear(); + adsId = null; + adPlaybackStates = ImmutableMap.of(); + contentTimeline = null; + streamLoadListener = null; + } + + // Implements VolumeProvider. + + @Override + public int getVolume() { + return (int) Math.floor(player.getVolume() * 100); + } + + // Implement ContentProgressProvider. + + @Override + public VideoProgressUpdate getContentProgress() { + if (!isCurrentAdPlaying(player, mediaItem, adsId)) { + return VideoProgressUpdate.VIDEO_TIME_NOT_READY; + } else if (adPlaybackStates.isEmpty()) { + return new VideoProgressUpdate(/* currentTimeMs= */ 0, /* durationMs= */ C.TIME_UNSET); + } + + Timeline timeline = player.getCurrentTimeline(); + int currentPeriodIndex = player.getCurrentPeriodIndex(); + timeline.getPeriod(currentPeriodIndex, period, /* setIds= */ true); + timeline.getWindow(player.getCurrentMediaItemIndex(), window); + + // We need the period of the content timeline because its period UIDs are the key used in the + // ad playback state map. The period UIDs of the public timeline are different (masking). + Timeline.Period contentPeriod = + checkNotNull(contentTimeline) + .getPeriod( + currentPeriodIndex - window.firstPeriodIndex, + new Timeline.Period(), + /* setIds= */ true); + AdPlaybackState adPlaybackState = checkNotNull(adPlaybackStates.get(contentPeriod.uid)); + + long streamPositionMs = + usToMs(ServerSideAdInsertionUtil.getStreamPositionUs(player, adPlaybackState)); + if (window.windowStartTimeMs != C.TIME_UNSET) { + // Add the time since epoch at start of the window for live streams. + streamPositionMs += window.windowStartTimeMs + period.getPositionInWindowMs(); + } else if (currentPeriodIndex > window.firstPeriodIndex) { + // Add the end position of the previous period in the underlying stream. + checkNotNull(contentTimeline) + .getPeriod( + currentPeriodIndex - window.firstPeriodIndex - 1, + contentPeriod, + /* setIds= */ true); + streamPositionMs += usToMs(contentPeriod.positionInWindowUs + contentPeriod.durationUs); + } + return new VideoProgressUpdate( + streamPositionMs, + checkNotNull(contentTimeline).getWindow(/* windowIndex= */ 0, window).getDurationMs()); + } + + // Implement VideoStreamPlayer. + + @Override + public void loadUrl(String url, List> subtitles) { + if (streamLoadListener != null) { + // SDK provided manifest url, notify the listener. + streamLoadListener.onLoadStream(url, subtitles); + } + } + + @Override + public void addCallback(VideoStreamPlayer.VideoStreamPlayerCallback callback) { + callbacks.add(callback); + } + + @Override + public void removeCallback(VideoStreamPlayer.VideoStreamPlayerCallback callback) { + callbacks.remove(callback); + } + + @Override + public void onAdBreakStarted() { + // Do nothing. + } + + @Override + public void onAdBreakEnded() { + // Do nothing. + } + + @Override + public void onAdPeriodStarted() { + // Do nothing. + } + + @Override + public void onAdPeriodEnded() { + // Do nothing. + } + + @Override + public void pause() { + // Do nothing. + } + + @Override + public void resume() { + // Do nothing. + } + + @Override + public void seek(long timeMs) { + // Do nothing. + } + + // Internal methods. + + private void triggerUserTextReceived(String userText) { + for (VideoStreamPlayer.VideoStreamPlayerCallback callback : callbacks) { + callback.onUserTextReceived(userText); + } + } + } + + private static boolean isCurrentAdPlaying( + Player player, MediaItem mediaItem, @Nullable Object adsId) { + if (player.getPlaybackState() == Player.STATE_IDLE) { + return false; + } + Timeline.Period period = new Timeline.Period(); + player.getCurrentTimeline().getPeriod(player.getCurrentPeriodIndex(), period); + return (period.isPlaceholder && mediaItem.equals(player.getCurrentMediaItem())) + || (adsId != null && adsId.equals(period.getAdsId())); + } + + private static StreamDisplayContainer createStreamDisplayContainer( + ImaSdkFactory imaSdkFactory, + ImaUtil.ServerSideAdInsertionConfiguration config, + StreamPlayer streamPlayer) { + StreamDisplayContainer container = + ImaSdkFactory.createStreamDisplayContainer( + checkNotNull(config.adViewProvider.getAdViewGroup()), streamPlayer); + container.setCompanionSlots(config.companionAdSlots); + registerFriendlyObstructions(imaSdkFactory, container, config.adViewProvider); + return container; + } + + private static void registerFriendlyObstructions( + ImaSdkFactory imaSdkFactory, + StreamDisplayContainer container, + AdViewProvider adViewProvider) { + for (int i = 0; i < adViewProvider.getAdOverlayInfos().size(); i++) { + AdOverlayInfo overlayInfo = adViewProvider.getAdOverlayInfos().get(i); + container.registerFriendlyObstruction( + imaSdkFactory.createFriendlyObstruction( + overlayInfo.view, + ImaUtil.getFriendlyObstructionPurpose(overlayInfo.purpose), + overlayInfo.reasonDetail != null ? overlayInfo.reasonDetail : "Unknown reason")); + } + } +} diff --git a/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ServerSideAdInsertionStreamRequest.java b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ServerSideAdInsertionStreamRequest.java new file mode 100644 index 00000000000..9fbd3eee9f0 --- /dev/null +++ b/libraries/exoplayer_ima/src/main/java/androidx/media3/exoplayer/ima/ServerSideAdInsertionStreamRequest.java @@ -0,0 +1,476 @@ +/* + * Copyright (C) 2021 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.ima; + +import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.C.ContentType; +import com.google.ads.interactivemedia.v3.api.ImaSdkFactory; +import com.google.ads.interactivemedia.v3.api.StreamRequest; +import com.google.ads.interactivemedia.v3.api.StreamRequest.StreamFormat; +import com.google.common.base.Objects; +import com.google.common.collect.ImmutableMap; +import java.util.HashMap; +import java.util.Map; + +/** Stream request data for an IMA DAI stream. */ +/* package */ final class ServerSideAdInsertionStreamRequest { + + /** The default timeout for loading the video URI, in milliseconds. */ + public static final int DEFAULT_LOAD_VIDEO_TIMEOUT_MS = 10_000; + + /** Builds a {@link ServerSideAdInsertionStreamRequest}. */ + public static final class Builder { + + @Nullable private String adsId; + @Nullable private String assetKey; + @Nullable private String apiKey; + @Nullable private String contentSourceId; + @Nullable private String videoId; + @Nullable private String manifestSuffix; + @Nullable private String contentUrl; + @Nullable private String authToken; + @Nullable private String streamActivityMonitorId; + private ImmutableMap adTagParameters; + @ContentType public int format = C.TYPE_HLS; + private int loadVideoTimeoutMs; + + /** Creates a new instance. */ + public Builder() { + adTagParameters = ImmutableMap.of(); + loadVideoTimeoutMs = DEFAULT_LOAD_VIDEO_TIMEOUT_MS; + } + + /** + * An opaque identifier for associated ad playback state, or {@code null} if the {@link + * #setAssetKey(String) asset key} (for live) or {@link #setVideoId(String) video id} (for VOD) + * should be used as the ads identifier. + * + * @param adsId The ads identifier. + * @return This instance, for convenience. + */ + public Builder setAdsId(String adsId) { + this.adsId = adsId; + return this; + } + + /** + * The stream request asset key used for live streams. + * + * @param assetKey Live stream asset key. + * @return This instance, for convenience. + */ + public Builder setAssetKey(@Nullable String assetKey) { + this.assetKey = assetKey; + return this; + } + + /** + * Sets the stream request authorization token. Used in place of {@link #setApiKey(String) the + * API key} for stricter content authorization. The publisher can control individual content + * streams authorizations based on this token. + * + * @param authToken Live stream authorization token. + * @return This instance, for convenience. + */ + public Builder setAuthToken(@Nullable String authToken) { + this.authToken = authToken; + return this; + } + + /** + * The stream request content source ID used for on-demand streams. + * + * @param contentSourceId VOD stream content source id. + * @return This instance, for convenience. + */ + public Builder setContentSourceId(@Nullable String contentSourceId) { + this.contentSourceId = contentSourceId; + return this; + } + + /** + * The stream request video ID used for on-demand streams. + * + * @param videoId VOD stream video id. + * @return This instance, for convenience. + */ + public Builder setVideoId(@Nullable String videoId) { + this.videoId = videoId; + return this; + } + + /** + * Sets the format of the stream request. + * + * @param format VOD or live stream type. + * @return This instance, for convenience. + */ + public Builder setFormat(@ContentType int format) { + checkArgument(format == C.TYPE_DASH || format == C.TYPE_HLS); + this.format = format; + return this; + } + + /** + * The stream request API key. This is used for content authentication. The API key is provided + * to the publisher to unlock their content. It's a security measure used to verify the + * applications that are attempting to access the content. + * + * @param apiKey Stream api key. + * @return This instance, for convenience. + */ + public Builder setApiKey(@Nullable String apiKey) { + this.apiKey = apiKey; + return this; + } + + /** + * Sets the ID to be used to debug the stream with the stream activity monitor. This is used to + * provide a convenient way to allow publishers to find a stream log in the stream activity + * monitor tool. + * + * @param streamActivityMonitorId ID for debugging the stream with the stream activity monitor. + * @return This instance, for convenience. + */ + public Builder setStreamActivityMonitorId(@Nullable String streamActivityMonitorId) { + this.streamActivityMonitorId = streamActivityMonitorId; + return this; + } + + /** + * Sets the overridable ad tag parameters on the stream request. Supply targeting parameters to your + * stream provides more information. + * + *

You can use the dai-ot and dai-ov parameters for stream variant preference. See Override Stream Variant Parameters + * for more information. + * + * @param adTagParameters A map of extra parameters to pass to the ad server. + * @return This instance, for convenience. + */ + public Builder setAdTagParameters(Map adTagParameters) { + this.adTagParameters = ImmutableMap.copyOf(adTagParameters); + return this; + } + + /** + * Sets the optional stream manifest's suffix, which will be appended to the stream manifest's + * URL. The provided string must be URL-encoded and must not include a leading question mark. + * + * @param manifestSuffix Stream manifest's suffix. + * @return This instance, for convenience. + */ + public Builder setManifestSuffix(@Nullable String manifestSuffix) { + this.manifestSuffix = manifestSuffix; + return this; + } + + /** + * Specifies the deep link to the content's screen. If provided, this parameter is passed to the + * OM SDK. See Android + * documentation for more information. + * + * @param contentUrl Deep link to the content's screen. + * @return This instance, for convenience. + */ + public Builder setContentUrl(@Nullable String contentUrl) { + this.contentUrl = contentUrl; + return this; + } + + /** + * Sets the duration after which resolving the video URI should time out, in milliseconds. + * + *

The default is {@link #DEFAULT_LOAD_VIDEO_TIMEOUT_MS} milliseconds. + * + * @param loadVideoTimeoutMs The timeout after which to give up resolving the video URI. + * @return This instance, for convenience. + */ + public Builder setLoadVideoTimeoutMs(int loadVideoTimeoutMs) { + this.loadVideoTimeoutMs = loadVideoTimeoutMs; + return this; + } + + /** + * Builds a {@link ServerSideAdInsertionStreamRequest} with the builder's current values. + * + * @return The build {@link ServerSideAdInsertionStreamRequest}. + * @throws IllegalStateException If request has missing or invalid inputs. + */ + public ServerSideAdInsertionStreamRequest build() { + checkState( + (TextUtils.isEmpty(assetKey) + && !TextUtils.isEmpty(contentSourceId) + && !TextUtils.isEmpty(videoId)) + || (!TextUtils.isEmpty(assetKey) + && TextUtils.isEmpty(contentSourceId) + && TextUtils.isEmpty(videoId))); + @Nullable String adsId = this.adsId; + if (adsId == null) { + adsId = assetKey != null ? assetKey : checkNotNull(videoId); + } + return new ServerSideAdInsertionStreamRequest( + adsId, + assetKey, + apiKey, + contentSourceId, + videoId, + adTagParameters, + manifestSuffix, + contentUrl, + authToken, + streamActivityMonitorId, + format, + loadVideoTimeoutMs); + } + } + + private static final String SCHEME = "imadai"; + private static final String ADS_ID = "adsId"; + private static final String ASSET_KEY = "assetKey"; + private static final String API_KEY = "apiKey"; + private static final String CONTENT_SOURCE_ID = "contentSourceId"; + private static final String VIDEO_ID = "videoId"; + private static final String AD_TAG_PARAMETERS = "adTagParameters"; + private static final String MANIFEST_SUFFIX = "manifestSuffix"; + private static final String CONTENT_URL = "contentUrl"; + private static final String AUTH_TOKEN = "authToken"; + private static final String STREAM_ACTIVITY_MONITOR_ID = "streamActivityMonitorId"; + private static final String FORMAT = "format"; + private static final String LOAD_VIDEO_TIMEOUT_MS = "loadVideoTimeoutMs"; + + public final String adsId; + @Nullable public final String assetKey; + @Nullable public final String apiKey; + @Nullable public final String contentSourceId; + @Nullable public final String videoId; + public final ImmutableMap adTagParameters; + @Nullable public final String manifestSuffix; + @Nullable public final String contentUrl; + @Nullable public final String authToken; + @Nullable public final String streamActivityMonitorId; + @ContentType public int format = C.TYPE_HLS; + public final int loadVideoTimeoutMs; + + private ServerSideAdInsertionStreamRequest( + String adsId, + @Nullable String assetKey, + @Nullable String apiKey, + @Nullable String contentSourceId, + @Nullable String videoId, + ImmutableMap adTagParameters, + @Nullable String manifestSuffix, + @Nullable String contentUrl, + @Nullable String authToken, + @Nullable String streamActivityMonitorId, + @ContentType int format, + int loadVideoTimeoutMs) { + this.adsId = adsId; + this.assetKey = assetKey; + this.apiKey = apiKey; + this.contentSourceId = contentSourceId; + this.videoId = videoId; + this.adTagParameters = adTagParameters; + this.manifestSuffix = manifestSuffix; + this.contentUrl = contentUrl; + this.authToken = authToken; + this.streamActivityMonitorId = streamActivityMonitorId; + this.format = format; + this.loadVideoTimeoutMs = loadVideoTimeoutMs; + } + + /** Returns whether this request is for a live stream or false if it is a VOD stream. */ + public boolean isLiveStream() { + return !TextUtils.isEmpty(assetKey); + } + + /** Returns the corresponding {@link StreamRequest}. */ + @SuppressWarnings("nullness") // Required for making nullness test pass for library_with_ima_sdk. + public StreamRequest getStreamRequest() { + StreamRequest streamRequest; + if (!TextUtils.isEmpty(assetKey)) { + streamRequest = ImaSdkFactory.getInstance().createLiveStreamRequest(assetKey, apiKey); + } else { + streamRequest = + ImaSdkFactory.getInstance() + .createVodStreamRequest(checkNotNull(contentSourceId), checkNotNull(videoId), apiKey); + } + if (format == C.TYPE_DASH) { + streamRequest.setFormat(StreamFormat.DASH); + } else if (format == C.TYPE_HLS) { + streamRequest.setFormat(StreamFormat.HLS); + } + // Optional params. + streamRequest.setAdTagParameters(adTagParameters); + if (manifestSuffix != null) { + streamRequest.setManifestSuffix(manifestSuffix); + } + if (contentUrl != null) { + streamRequest.setContentUrl(contentUrl); + } + if (authToken != null) { + streamRequest.setAuthToken(authToken); + } + if (streamActivityMonitorId != null) { + streamRequest.setStreamActivityMonitorId(streamActivityMonitorId); + } + return streamRequest; + } + + /** Returns a corresponding {@link Uri}. */ + public Uri toUri() { + Uri.Builder dataUriBuilder = new Uri.Builder(); + dataUriBuilder.scheme(SCHEME); + dataUriBuilder.appendQueryParameter(ADS_ID, adsId); + if (loadVideoTimeoutMs != DEFAULT_LOAD_VIDEO_TIMEOUT_MS) { + dataUriBuilder.appendQueryParameter( + LOAD_VIDEO_TIMEOUT_MS, String.valueOf(loadVideoTimeoutMs)); + } + if (assetKey != null) { + dataUriBuilder.appendQueryParameter(ASSET_KEY, assetKey); + } + if (apiKey != null) { + dataUriBuilder.appendQueryParameter(API_KEY, apiKey); + } + if (contentSourceId != null) { + dataUriBuilder.appendQueryParameter(CONTENT_SOURCE_ID, contentSourceId); + } + if (videoId != null) { + dataUriBuilder.appendQueryParameter(VIDEO_ID, videoId); + } + if (manifestSuffix != null) { + dataUriBuilder.appendQueryParameter(MANIFEST_SUFFIX, manifestSuffix); + } + if (contentUrl != null) { + dataUriBuilder.appendQueryParameter(CONTENT_URL, contentUrl); + } + if (authToken != null) { + dataUriBuilder.appendQueryParameter(AUTH_TOKEN, authToken); + } + if (streamActivityMonitorId != null) { + dataUriBuilder.appendQueryParameter(STREAM_ACTIVITY_MONITOR_ID, streamActivityMonitorId); + } + if (!adTagParameters.isEmpty()) { + Uri.Builder adTagParametersUriBuilder = new Uri.Builder(); + for (Map.Entry entry : adTagParameters.entrySet()) { + adTagParametersUriBuilder.appendQueryParameter(entry.getKey(), entry.getValue()); + } + dataUriBuilder.appendQueryParameter( + AD_TAG_PARAMETERS, adTagParametersUriBuilder.build().toString()); + } + dataUriBuilder.appendQueryParameter(FORMAT, String.valueOf(format)); + return dataUriBuilder.build(); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ServerSideAdInsertionStreamRequest)) { + return false; + } + ServerSideAdInsertionStreamRequest that = (ServerSideAdInsertionStreamRequest) o; + return format == that.format + && loadVideoTimeoutMs == that.loadVideoTimeoutMs + && Objects.equal(adsId, that.adsId) + && Objects.equal(assetKey, that.assetKey) + && Objects.equal(apiKey, that.apiKey) + && Objects.equal(contentSourceId, that.contentSourceId) + && Objects.equal(videoId, that.videoId) + && Objects.equal(adTagParameters, that.adTagParameters) + && Objects.equal(manifestSuffix, that.manifestSuffix) + && Objects.equal(contentUrl, that.contentUrl) + && Objects.equal(authToken, that.authToken) + && Objects.equal(streamActivityMonitorId, that.streamActivityMonitorId); + } + + @Override + public int hashCode() { + return Objects.hashCode( + adsId, + assetKey, + apiKey, + contentSourceId, + videoId, + adTagParameters, + manifestSuffix, + contentUrl, + authToken, + streamActivityMonitorId, + loadVideoTimeoutMs, + format); + } + + /** + * Creates a {@link ServerSideAdInsertionStreamRequest} for the given URI. + * + * @param uri The URI. + * @return An {@link ServerSideAdInsertionStreamRequest} for the given URI. + * @throws IllegalStateException If uri has missing or invalid inputs. + */ + public static ServerSideAdInsertionStreamRequest fromUri(Uri uri) { + ServerSideAdInsertionStreamRequest.Builder request = + new ServerSideAdInsertionStreamRequest.Builder(); + if (!SCHEME.equals(uri.getScheme())) { + throw new IllegalArgumentException("Invalid scheme."); + } + request.setAdsId(checkNotNull(uri.getQueryParameter(ADS_ID))); + request.setAssetKey(uri.getQueryParameter(ASSET_KEY)); + request.setApiKey(uri.getQueryParameter(API_KEY)); + request.setContentSourceId(uri.getQueryParameter(CONTENT_SOURCE_ID)); + request.setVideoId(uri.getQueryParameter(VIDEO_ID)); + request.setManifestSuffix(uri.getQueryParameter(MANIFEST_SUFFIX)); + request.setContentUrl(uri.getQueryParameter(CONTENT_URL)); + request.setAuthToken(uri.getQueryParameter(AUTH_TOKEN)); + request.setStreamActivityMonitorId(uri.getQueryParameter(STREAM_ACTIVITY_MONITOR_ID)); + String adsLoaderTimeoutUs = uri.getQueryParameter(LOAD_VIDEO_TIMEOUT_MS); + request.setLoadVideoTimeoutMs( + TextUtils.isEmpty(adsLoaderTimeoutUs) + ? DEFAULT_LOAD_VIDEO_TIMEOUT_MS + : Integer.parseInt(adsLoaderTimeoutUs)); + String formatValue = uri.getQueryParameter(FORMAT); + if (!TextUtils.isEmpty(formatValue)) { + request.setFormat(Integer.parseInt(formatValue)); + } + Map adTagParameters; + String adTagParametersValue; + String singleAdTagParameterValue; + if (uri.getQueryParameter(AD_TAG_PARAMETERS) != null) { + adTagParameters = new HashMap<>(); + adTagParametersValue = uri.getQueryParameter(AD_TAG_PARAMETERS); + if (!TextUtils.isEmpty(adTagParametersValue)) { + Uri adTagParametersUri = Uri.parse(adTagParametersValue); + for (String paramName : adTagParametersUri.getQueryParameterNames()) { + singleAdTagParameterValue = adTagParametersUri.getQueryParameter(paramName); + if (!TextUtils.isEmpty(singleAdTagParameterValue)) { + adTagParameters.put(paramName, singleAdTagParameterValue); + } + } + } + request.setAdTagParameters(adTagParameters); + } + return request.build(); + } +} diff --git a/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ServerSideAdInsertionStreamRequestTest.java b/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ServerSideAdInsertionStreamRequestTest.java new file mode 100644 index 00000000000..78b71c2f06f --- /dev/null +++ b/libraries/exoplayer_ima/src/test/java/androidx/media3/exoplayer/ima/ServerSideAdInsertionStreamRequestTest.java @@ -0,0 +1,148 @@ +/* + * Copyright 2021 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.ima; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.HashMap; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link ServerSideAdInsertionStreamRequest}. */ +@RunWith(AndroidJUnit4.class) +public final class ServerSideAdInsertionStreamRequestTest { + + private static final String ADS_ID = "testAdsId"; + private static final String ASSET_KEY = "testAssetKey"; + private static final String API_KEY = "testApiKey"; + private static final String CONTENT_SOURCE_ID = "testContentSourceId"; + private static final String VIDEO_ID = "testVideoId"; + private static final String MANIFEST_SUFFIX = "testManifestSuffix"; + private static final String CONTENT_URL = + "http://google.com/contentUrl?queryParamName=queryParamValue"; + private static final String AUTH_TOKEN = "testAuthToken"; + private static final String STREAM_ACTIVITY_MONITOR_ID = "testStreamActivityMonitorId"; + private static final int ADS_LOADER_TIMEOUT_MS = 2; + private static final int FORMAT_DASH = 0; + private static final int FORMAT_HLS = 2; + private static final Map adTagParameters = new HashMap<>(); + + static { + adTagParameters.put("param1", "value1"); + adTagParameters.put("param2", "value2"); + } + + @Test + public void build_live_correctUriAndParsing() { + ServerSideAdInsertionStreamRequest.Builder builder = + new ServerSideAdInsertionStreamRequest.Builder(); + builder.setAdsId(ADS_ID); + builder.setAssetKey(ASSET_KEY); + builder.setApiKey(API_KEY); + builder.setManifestSuffix(MANIFEST_SUFFIX); + builder.setContentUrl(CONTENT_URL); + builder.setAuthToken(AUTH_TOKEN); + builder.setStreamActivityMonitorId(STREAM_ACTIVITY_MONITOR_ID); + builder.setFormat(FORMAT_HLS); + builder.setAdTagParameters(adTagParameters); + builder.setLoadVideoTimeoutMs(ADS_LOADER_TIMEOUT_MS); + ServerSideAdInsertionStreamRequest streamRequest = builder.build(); + + ServerSideAdInsertionStreamRequest requestAfterConversions = + ServerSideAdInsertionStreamRequest.fromUri(streamRequest.toUri()); + + assertThat(streamRequest).isEqualTo(requestAfterConversions); + } + + @Test + public void build_vod_correctUriAndParsing() { + ServerSideAdInsertionStreamRequest.Builder builder = + new ServerSideAdInsertionStreamRequest.Builder(); + builder.setAdsId(ADS_ID); + builder.setApiKey(API_KEY); + builder.setContentSourceId(CONTENT_SOURCE_ID); + builder.setVideoId(VIDEO_ID); + builder.setManifestSuffix(MANIFEST_SUFFIX); + builder.setContentUrl(CONTENT_URL); + builder.setAuthToken(AUTH_TOKEN); + builder.setStreamActivityMonitorId(STREAM_ACTIVITY_MONITOR_ID); + builder.setFormat(FORMAT_DASH); + builder.setAdTagParameters(adTagParameters); + builder.setLoadVideoTimeoutMs(ADS_LOADER_TIMEOUT_MS); + ServerSideAdInsertionStreamRequest streamRequest = builder.build(); + + ServerSideAdInsertionStreamRequest requestAfterConversions = + ServerSideAdInsertionStreamRequest.fromUri(streamRequest.toUri()); + + assertThat(requestAfterConversions).isEqualTo(streamRequest); + } + + @Test + public void build_vodWithNoAdsId_usesVideoIdAsDefault() { + ServerSideAdInsertionStreamRequest.Builder builder = + new ServerSideAdInsertionStreamRequest.Builder(); + builder.setContentSourceId(CONTENT_SOURCE_ID); + builder.setVideoId(VIDEO_ID); + + ServerSideAdInsertionStreamRequest streamRequest = builder.build(); + + assertThat(streamRequest.adsId).isEqualTo(VIDEO_ID); + assertThat(streamRequest.toUri().getQueryParameter("adsId")).isEqualTo(VIDEO_ID); + } + + @Test + public void build_liveWithNoAdsId_usesAssetKeyAsDefault() { + ServerSideAdInsertionStreamRequest.Builder builder = + new ServerSideAdInsertionStreamRequest.Builder(); + builder.setAssetKey(ASSET_KEY); + + ServerSideAdInsertionStreamRequest streamRequest = builder.build(); + + assertThat(streamRequest.adsId).isEqualTo(ASSET_KEY); + assertThat(streamRequest.toUri().getQueryParameter("adsId")).isEqualTo(ASSET_KEY); + } + + @Test + public void build_assetKeyWithVideoId_throwsIllegalStateException() { + ServerSideAdInsertionStreamRequest.Builder requestBuilder = + new ServerSideAdInsertionStreamRequest.Builder(); + requestBuilder.setAssetKey(ASSET_KEY); + requestBuilder.setVideoId(VIDEO_ID); + + Assert.assertThrows(IllegalStateException.class, requestBuilder::build); + } + + @Test + public void build_assetKeyWithContentSource_throwsIllegalStateException() { + ServerSideAdInsertionStreamRequest.Builder requestBuilder = + new ServerSideAdInsertionStreamRequest.Builder(); + requestBuilder.setAssetKey(ASSET_KEY); + requestBuilder.setContentSourceId(CONTENT_SOURCE_ID); + + Assert.assertThrows(IllegalStateException.class, requestBuilder::build); + } + + @Test + public void build_withoutContentSourceAndVideoIdOrAssetKey_throwsIllegalStateException() { + ServerSideAdInsertionStreamRequest.Builder requestBuilder = + new ServerSideAdInsertionStreamRequest.Builder(); + + Assert.assertThrows(IllegalStateException.class, requestBuilder::build); + } +}