From b911e2ee4eba1e41d0afd04243eb72521b1610fb Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 2 Feb 2022 11:41:55 +0000 Subject: [PATCH] Mark played ads in multi-period VOD streams #minor-release PiperOrigin-RevId: 425842813 --- .../ImaServerSideAdInsertionMediaSource.java | 36 +++++---- .../android/exoplayer2/ext/ima/ImaUtil.java | 76 ++++++++++++++++--- .../exoplayer2/ext/ima/ImaUtilTest.java | 75 +++++++++++++++++- 3 files changed, 161 insertions(+), 26 deletions(-) diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaServerSideAdInsertionMediaSource.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaServerSideAdInsertionMediaSource.java index 0c2aa28003..96b9ec353a 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaServerSideAdInsertionMediaSource.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaServerSideAdInsertionMediaSource.java @@ -16,12 +16,14 @@ package com.google.android.exoplayer2.ext.ima; import static com.google.android.exoplayer2.ext.ima.ImaUtil.expandAdGroupPlaceholder; +import static com.google.android.exoplayer2.ext.ima.ImaUtil.getAdGroupAndIndexInMultiPeriodWindow; import static com.google.android.exoplayer2.ext.ima.ImaUtil.splitAdPlaybackStateForPeriods; import static com.google.android.exoplayer2.ext.ima.ImaUtil.updateAdDurationAndPropagate; import static com.google.android.exoplayer2.ext.ima.ImaUtil.updateAdDurationInAdGroup; import static com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil.addAdGroupToAdPlaybackState; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Util.msToUs; import static com.google.android.exoplayer2.util.Util.secToUs; import static com.google.android.exoplayer2.util.Util.sum; import static com.google.android.exoplayer2.util.Util.usToMs; @@ -30,6 +32,7 @@ import static java.lang.Math.min; import android.content.Context; import android.net.Uri; import android.os.Handler; +import android.util.Pair; import android.view.ViewGroup; import androidx.annotation.MainThread; import androidx.annotation.Nullable; @@ -618,18 +621,26 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou 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); - } + if (oldPosition.adGroupIndex != C.INDEX_UNSET) { + int adGroupIndex = oldPosition.adGroupIndex; + int adIndexInAdGroup = oldPosition.adIndexInAdGroup; + Timeline timeline = player.getCurrentTimeline(); + Timeline.Window window = + timeline.getWindow(oldPosition.mediaItemIndex, new Timeline.Window()); + if (window.lastPeriodIndex > window.firstPeriodIndex) { + // Map adGroupIndex and adIndexInAdGroup to multi-period window. + Pair adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow( + oldPosition.periodIndex, adPlaybackState, timeline); + adGroupIndex = adGroupIndexAndAdIndexInAdGroup.first; + adIndexInAdGroup = adGroupIndexAndAdIndexInAdGroup.second; + } + int adState = adPlaybackState.getAdGroup(adGroupIndex).states[adIndexInAdGroup]; + if (adState == AdPlaybackState.AD_STATE_AVAILABLE + || adState == AdPlaybackState.AD_STATE_UNAVAILABLE) { + setAdPlaybackState( + adPlaybackState.withPlayedAd(adGroupIndex, /* adIndexInAdGroup= */ adIndexInAdGroup)); } - setAdPlaybackState(newAdPlaybackState); } } @@ -698,8 +709,7 @@ public final class ImaServerSideAdInsertionMediaSource extends CompositeMediaSou long positionInWindowUs = timeline.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period()) .positionInWindowUs; - long currentPeriodPosition = - Util.msToUs(player.getCurrentPosition()) - positionInWindowUs; + long currentPeriodPosition = msToUs(player.getCurrentPosition()) - positionInWindowUs; newAdPlaybackState = addLiveAdBreak( event.getAd(), diff --git a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java index b35b1c8b4c..1a3ed829f3 100644 --- a/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java +++ b/extensions/ima/src/main/java/com/google/android/exoplayer2/ext/ima/ImaUtil.java @@ -23,6 +23,7 @@ import static java.lang.Math.max; import android.content.Context; import android.os.Looper; +import android.util.Pair; import android.view.View; import android.view.ViewGroup; import androidx.annotation.CheckResult; @@ -355,14 +356,13 @@ import java.util.Set; /** * Splits an {@link AdPlaybackState} into a separate {@link AdPlaybackState} for each period of a - * content timeline. Ad group times are expected to not take previous ad duration into account and - * needs to be translated to the actual position in the {@code contentTimeline} by adding prior ad - * durations. + * content timeline. * - *

If a period is enclosed by an ad group, the period is considered an ad period and gets an ad - * playback state assigned with a single ad in a single ad group. The duration of the ad is set to - * the duration of the period. All other periods are considered content periods with an empty ad - * playback state without any ads. + *

If a period is enclosed by an ad group, the period is considered an ad period. Splitting + * results in a separate {@link AdPlaybackState ad playback state} for each period that has either + * no ads or a single ad. In the latter case, the duration of the single ad is set to the duration + * of the period consuming the entire duration of the period. Accordingly an ad period does not + * contribute to the duration of the containing window. * * @param adPlaybackState The ad playback state to be split. * @param contentTimeline The content timeline for each period of which to create an {@link @@ -398,15 +398,19 @@ import java.util.Set; long elapsedAdGroupAdDurationUs = 0; for (int j = periodIndex; j < contentTimeline.getPeriodCount(); j++) { contentTimeline.getPeriod(j, period, /* setIds= */ true); - if (totalElapsedContentDurationUs < adGroup.timeUs) { + // TODO(b/192231683) Remove subtracted US from ad group time when we can upgrade the SDK. + // Subtract one microsecond to work around rounding errors with adGroup.timeUs. + if (totalElapsedContentDurationUs < adGroup.timeUs - 1) { // Period starts before the ad group, so it is a content period. adPlaybackStates.put(checkNotNull(period.uid), contentOnlyAdPlaybackState); totalElapsedContentDurationUs += period.durationUs; } else { long periodStartUs = totalElapsedContentDurationUs + elapsedAdGroupAdDurationUs; - if (periodStartUs + period.durationUs <= adGroup.timeUs + adGroupDurationUs) { - // The period ends before the end of the ad group, so it is an ad period (Note: An ad - // reported by the IMA SDK may span multiple periods). + // TODO(b/192231683) Remove additional US when we can upgrade the SDK. + // Add one microsecond to work around rounding errors with adGroup.timeUs. + if (periodStartUs + period.durationUs <= adGroup.timeUs + adGroupDurationUs + 1) { + // The period ends before the end of the ad group, so it is an ad period (Note: A VOD ad + // reported by the IMA SDK spans multiple periods before the LOADED event arrives). adPlaybackStates.put( checkNotNull(period.uid), splitAdGroupForPeriod(adsId, adGroup, periodStartUs, period.durationUs)); @@ -430,7 +434,6 @@ import java.util.Set; private static AdPlaybackState splitAdGroupForPeriod( Object adsId, AdPlaybackState.AdGroup adGroup, long periodStartUs, long periodDurationUs) { - checkState(adGroup.timeUs <= periodStartUs); AdPlaybackState adPlaybackState = new AdPlaybackState(checkNotNull(adsId), /* adGroupTimesUs...= */ 0) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -465,5 +468,54 @@ import java.util.Set; return adPlaybackState; } + /** + * Returns the {@code adGroupIndex} and the {@code adIndexInAdGroup} for the given period index of + * an ad period. + * + * @param adPeriodIndex The period index of the ad period. + * @param adPlaybackState The ad playback state that holds the ad group and ad information. + * @param timeline The timeline that contains the ad period. + * @return A pair with the ad group index (first) and the ad index in that ad group (second). + */ + public static Pair getAdGroupAndIndexInMultiPeriodWindow( + int adPeriodIndex, AdPlaybackState adPlaybackState, Timeline timeline) { + Timeline.Period period = new Timeline.Period(); + int periodIndex = 0; + long totalElapsedContentDurationUs = 0; + for (int i = adPlaybackState.removedAdGroupCount; i < adPlaybackState.adGroupCount; i++) { + int adIndexInAdGroup = 0; + AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(/* adGroupIndex= */ i); + long adGroupDurationUs = sum(adGroup.durationsUs); + long elapsedAdGroupAdDurationUs = 0; + for (int j = periodIndex; j < timeline.getPeriodCount(); j++) { + timeline.getPeriod(j, period, /* setIds= */ true); + // TODO(b/192231683) Remove subtracted US from ad group time when we can upgrade the SDK. + // Subtract one microsecond to work around rounding errors with adGroup.timeUs. + if (totalElapsedContentDurationUs < adGroup.timeUs - 1) { + // Period starts before the ad group, so it is a content period. + totalElapsedContentDurationUs += period.durationUs; + } else { + long periodStartUs = totalElapsedContentDurationUs + elapsedAdGroupAdDurationUs; + // TODO(b/192231683) Remove additional US when we can upgrade the SDK. + // Add one microsecond to work around rounding errors with adGroup.timeUs. + if (periodStartUs + period.durationUs <= adGroup.timeUs + adGroupDurationUs + 1) { + // The period ends before the end of the ad group, so it is an ad period. + if (j == adPeriodIndex) { + return new Pair<>(/* adGroupIndex= */ i, adIndexInAdGroup); + } + elapsedAdGroupAdDurationUs += period.durationUs; + adIndexInAdGroup++; + } else { + // Period is after the current ad group. Continue with next ad group. + break; + } + } + // Increment the period index to the next unclassified period. + periodIndex++; + } + } + throw new IllegalStateException(); + } + private ImaUtil() {} } diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaUtilTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaUtilTest.java index aaf962a195..5e82d9e1c4 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaUtilTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaUtilTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.ext.ima; +import static com.google.android.exoplayer2.ext.ima.ImaUtil.getAdGroupAndIndexInMultiPeriodWindow; import static com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US; import static com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US; import static com.google.common.truth.Truth.assertThat; @@ -27,6 +28,7 @@ import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.ServerSideAdInsertionUtil; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.common.collect.ImmutableMap; +import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -463,7 +465,9 @@ public class ImaUtilTest { AdPlaybackState adPlaybackState = new AdPlaybackState( /* adsId= */ "adsId", - DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + periodDurationUs + 1) + // TODO(b/192231683) Reduce additional period duration to 1 when rounding work + // around removed. + DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + periodDurationUs + 2) .withAdCount(/* adGroupIndex= */ 0, 1) .withAdDurationsUs(/* adGroupIndex= */ 0, /* adDurationsUs...= */ periodDurationUs) .withIsServerSideInserted(/* adGroupIndex= */ 0, true); @@ -724,4 +728,73 @@ public class ImaUtilTest { assertThat(adGroup.durationsUs[1]).isEqualTo(5_000_000); assertThat(adGroup.durationsUs[2]).isEqualTo(20_000_000); } + + @Test + public void getAdGroupAndIndexInMultiPeriodWindow_correctAdGroupIndexAndAdIndexInAdGroup() { + FakeTimeline timeline = + new FakeTimeline( + new FakeTimeline.TimelineWindowDefinition(/* periodCount= */ 9, new Object())); + long periodDurationUs = DEFAULT_WINDOW_DURATION_US / 9; + // [ad, ad, content, ad, ad, ad, content, ad, ad] + AdPlaybackState adPlaybackState = + new AdPlaybackState(/* adsId= */ "adsId", 0, periodDurationUs, 2 * periodDurationUs) + .withAdCount(/* adGroupIndex= */ 0, 2) + .withAdCount(/* adGroupIndex= */ 1, 3) + .withAdCount(/* adGroupIndex= */ 2, 2) + .withAdDurationsUs( + /* adGroupIndex= */ 0, + DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US + periodDurationUs, + periodDurationUs) + .withAdDurationsUs( + /* adGroupIndex= */ 1, periodDurationUs, periodDurationUs, periodDurationUs) + .withAdDurationsUs(/* adGroupIndex= */ 2, periodDurationUs, periodDurationUs) + .withIsServerSideInserted(/* adGroupIndex= */ 0, true); + + Pair adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow(/* adPeriodIndex= */ 0, adPlaybackState, timeline); + assertThat(adGroupIndexAndAdIndexInAdGroup.first).isEqualTo(0); + assertThat(adGroupIndexAndAdIndexInAdGroup.second).isEqualTo(0); + + adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow(/* adPeriodIndex= */ 1, adPlaybackState, timeline); + assertThat(adGroupIndexAndAdIndexInAdGroup.first).isEqualTo(0); + assertThat(adGroupIndexAndAdIndexInAdGroup.second).isEqualTo(1); + + Assert.assertThrows( + IllegalStateException.class, + () -> + getAdGroupAndIndexInMultiPeriodWindow( + /* adPeriodIndex= */ 2, adPlaybackState, timeline)); + + adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow(/* adPeriodIndex= */ 3, adPlaybackState, timeline); + assertThat(adGroupIndexAndAdIndexInAdGroup.first).isEqualTo(1); + assertThat(adGroupIndexAndAdIndexInAdGroup.second).isEqualTo(0); + + adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow(/* adPeriodIndex= */ 4, adPlaybackState, timeline); + assertThat(adGroupIndexAndAdIndexInAdGroup.first).isEqualTo(1); + assertThat(adGroupIndexAndAdIndexInAdGroup.second).isEqualTo(1); + + adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow(/* adPeriodIndex= */ 5, adPlaybackState, timeline); + assertThat(adGroupIndexAndAdIndexInAdGroup.first).isEqualTo(1); + assertThat(adGroupIndexAndAdIndexInAdGroup.second).isEqualTo(2); + + Assert.assertThrows( + IllegalStateException.class, + () -> + getAdGroupAndIndexInMultiPeriodWindow( + /* adPeriodIndex= */ 6, adPlaybackState, timeline)); + + adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow(/* adPeriodIndex= */ 7, adPlaybackState, timeline); + assertThat(adGroupIndexAndAdIndexInAdGroup.first).isEqualTo(2); + assertThat(adGroupIndexAndAdIndexInAdGroup.second).isEqualTo(0); + + adGroupIndexAndAdIndexInAdGroup = + getAdGroupAndIndexInMultiPeriodWindow(/* adPeriodIndex= */ 8, adPlaybackState, timeline); + assertThat(adGroupIndexAndAdIndexInAdGroup.first).isEqualTo(2); + assertThat(adGroupIndexAndAdIndexInAdGroup.second).isEqualTo(1); + } }