From 3f9093cc02efec218429d5eb6fcd697136a86f2c Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 26 Jul 2021 13:31:17 +0100 Subject: [PATCH] Add BaseUrlExlusionList and use it to select base URLs Issues: #771 and #7654 PiperOrigin-RevId: 386850707 --- RELEASENOTES.md | 9 + .../source/dash/BaseUrlExclusionList.java | 210 +++++++++++++++ .../source/dash/DashChunkSource.java | 2 + .../source/dash/DashMediaPeriod.java | 4 + .../source/dash/DashMediaSource.java | 4 + .../source/dash/DefaultDashChunkSource.java | 83 +++++- .../source/dash/BaseUrlExclusionListTest.java | 247 ++++++++++++++++++ .../source/dash/DashMediaPeriodTest.java | 1 + .../dash/DefaultDashChunkSourceTest.java | 100 ++++++- .../mpd/sample_mpd_vod_location_fallback | 41 +++ 10 files changed, 678 insertions(+), 23 deletions(-) create mode 100644 library/dash/src/main/java/com/google/android/exoplayer2/source/dash/BaseUrlExclusionList.java create mode 100644 library/dash/src/test/java/com/google/android/exoplayer2/source/dash/BaseUrlExclusionListTest.java create mode 100644 testdata/src/test/assets/media/mpd/sample_mpd_vod_location_fallback diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a0957b8ad2..a0dbfe8c15 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -47,6 +47,9 @@ `TrackSelection#getFormat()`. * Deprecate `ControlDispatcher` and `DefaultControlDispatcher`. Use a `ForwardingPlayer` or configure the player to customize operations. + * Change interface of `LoadErrorHandlingPolicy` to support configuring the + behavior of track and location fallback. Location fallback is currently + only supported for DASH manifests with multiple base URLs. * Remove deprecated symbols: * Remove `Player.getPlaybackError`. Use `Player.getPlayerError` instead. * Remove `Player.getCurrentTag`. Use `Player.getCurrentMediaItem` and @@ -160,6 +163,12 @@ ([#9158](https://github.com/google/ExoPlayer/issues/9158)). * Fix issue around TS synchronization when reading a file's duration ([#9100](https://github.com/google/ExoPlayer/pull/9100)). +* DASH: + * Add support for multiple base URLs and DVB attributes in the manifest. + Apps that are using `DefaultLoadErrorHandlingPolicy` with such manifests + have base URL fallback automatically enabled + ([#771](https://github.com/google/ExoPlayer/issues/771) and + [#7654](https://github.com/google/ExoPlayer/issues/7654)). * HLS: * Fix issue where playback of a live event could become stuck rather than transitioning to `STATE_ENDED` when the event ends diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/BaseUrlExclusionList.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/BaseUrlExclusionList.java new file mode 100644 index 0000000000..7971085933 --- /dev/null +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/BaseUrlExclusionList.java @@ -0,0 +1,210 @@ +/* + * 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 com.google.android.exoplayer2.source.dash; + +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.max; + +import android.os.SystemClock; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.source.dash.manifest.BaseUrl; +import com.google.common.collect.Iterables; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +/** + * Holds the state of {@link #exclude(BaseUrl, long) excluded} base URLs to be used {@link + * #selectBaseUrl(List) to select} a base URL based on these exclusions. + */ +public final class BaseUrlExclusionList { + + private final Map excludedServiceLocations; + private final Map excludedPriorities; + private final Map>, BaseUrl> selectionsTaken = new HashMap<>(); + private final Random random; + + /** Creates an instance. */ + public BaseUrlExclusionList() { + this(new Random()); + } + + /** Creates an instance with the given {@link Random}. */ + @VisibleForTesting + /* package */ BaseUrlExclusionList(Random random) { + this.random = random; + excludedServiceLocations = new HashMap<>(); + excludedPriorities = new HashMap<>(); + } + + /** + * Excludes the given base URL. + * + * @param baseUrlToExclude The base URL to exclude. + * @param exclusionDurationMs The duration of exclusion, in milliseconds. + */ + public void exclude(BaseUrl baseUrlToExclude, long exclusionDurationMs) { + long excludeUntilMs = SystemClock.elapsedRealtime() + exclusionDurationMs; + addExclusion(baseUrlToExclude.serviceLocation, excludeUntilMs, excludedServiceLocations); + addExclusion(baseUrlToExclude.priority, excludeUntilMs, excludedPriorities); + } + + /** + * Selects the base URL to use from the given list. + * + *

The list is reduced by service location and priority of base URLs that have been passed to + * {@link #exclude(BaseUrl, long)}. The base URL to use is then selected from the remaining base + * URLs by priority and weight. + * + * @param baseUrls The list of {@link BaseUrl base URLs} to select from. + * @return The selected base URL after exclusion or null if all elements have been excluded. + */ + @Nullable + public BaseUrl selectBaseUrl(List baseUrls) { + List includedBaseUrls = applyExclusions(baseUrls); + if (includedBaseUrls.size() < 2) { + return Iterables.getFirst(includedBaseUrls, /* defaultValue= */ null); + } + // Sort by priority and service location to make the sort order of the candidates deterministic. + Collections.sort(includedBaseUrls, BaseUrlExclusionList::compareBaseUrl); + // Get candidates of the lowest priority from the head of the sorted list. + List> candidateKeys = new ArrayList<>(); + int lowestPriority = includedBaseUrls.get(0).priority; + for (int i = 0; i < includedBaseUrls.size(); i++) { + BaseUrl baseUrl = includedBaseUrls.get(i); + if (lowestPriority != baseUrl.priority) { + if (candidateKeys.size() == 1) { + // Only a single candidate of lowest priority; no choice. + return includedBaseUrls.get(0); + } + break; + } + candidateKeys.add(new Pair<>(baseUrl.serviceLocation, baseUrl.weight)); + } + // Check whether selection has already been taken. + @Nullable BaseUrl baseUrl = selectionsTaken.get(candidateKeys); + if (baseUrl == null) { + // Weighted random selection from multiple candidates of the same priority. + baseUrl = selectWeighted(includedBaseUrls.subList(0, candidateKeys.size())); + // Remember the selection taken for later. + selectionsTaken.put(candidateKeys, baseUrl); + } + return baseUrl; + } + + /** + * Returns the number of priority levels for the given list of base URLs after exclusion. + * + * @param baseUrls The list of base URLs. + * @return The number of priority levels after exclusion. + */ + public int getPriorityCountAfterExclusion(List baseUrls) { + Set priorities = new HashSet<>(); + List includedBaseUrls = applyExclusions(baseUrls); + for (int i = 0; i < includedBaseUrls.size(); i++) { + priorities.add(includedBaseUrls.get(i).priority); + } + return priorities.size(); + } + + /** + * Returns the number of priority levels of the given list of base URLs. + * + * @param baseUrls The list of base URLs. + * @return The number of priority levels before exclusion. + */ + public static int getPriorityCount(List baseUrls) { + Set priorities = new HashSet<>(); + for (int i = 0; i < baseUrls.size(); i++) { + priorities.add(baseUrls.get(i).priority); + } + return priorities.size(); + } + + /** Resets the state. */ + public void reset() { + excludedServiceLocations.clear(); + excludedPriorities.clear(); + selectionsTaken.clear(); + } + + // Internal methods. + + private List applyExclusions(List baseUrls) { + long nowMs = SystemClock.elapsedRealtime(); + removeExpiredExclusions(nowMs, excludedServiceLocations); + removeExpiredExclusions(nowMs, excludedPriorities); + List includedBaseUrls = new ArrayList<>(); + for (int i = 0; i < baseUrls.size(); i++) { + BaseUrl baseUrl = baseUrls.get(i); + if (!excludedServiceLocations.containsKey(baseUrl.serviceLocation) + && !excludedPriorities.containsKey(baseUrl.priority)) { + includedBaseUrls.add(baseUrl); + } + } + return includedBaseUrls; + } + + private BaseUrl selectWeighted(List candidates) { + int totalWeight = 0; + for (int i = 0; i < candidates.size(); i++) { + totalWeight += candidates.get(i).weight; + } + int randomChoice = random.nextInt(/* bound= */ totalWeight); + totalWeight = 0; + for (int i = 0; i < candidates.size(); i++) { + BaseUrl baseUrl = candidates.get(i); + totalWeight += baseUrl.weight; + if (randomChoice < totalWeight) { + return baseUrl; + } + } + return Iterables.getLast(candidates); + } + + private static void addExclusion( + T toExclude, long excludeUntilMs, Map currentExclusions) { + if (currentExclusions.containsKey(toExclude)) { + excludeUntilMs = max(excludeUntilMs, castNonNull(currentExclusions.get(toExclude))); + } + currentExclusions.put(toExclude, excludeUntilMs); + } + + private static void removeExpiredExclusions(long nowMs, Map exclusions) { + List expiredExclusions = new ArrayList<>(); + for (Map.Entry entries : exclusions.entrySet()) { + if (entries.getValue() <= nowMs) { + expiredExclusions.add(entries.getKey()); + } + } + for (int i = 0; i < expiredExclusions.size(); i++) { + exclusions.remove(expiredExclusions.get(i)); + } + } + + /** Compare by priority and service location. */ + private static int compareBaseUrl(BaseUrl a, BaseUrl b) { + int compare = Integer.compare(a.priority, b.priority); + return compare != 0 ? compare : a.serviceLocation.compareTo(b.serviceLocation); + } +} diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashChunkSource.java index d93915f761..128334ced2 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashChunkSource.java @@ -35,6 +35,7 @@ public interface DashChunkSource extends ChunkSource { /** * @param manifestLoaderErrorThrower Throws errors affecting loading of manifests. * @param manifest The initial manifest. + * @param baseUrlExclusionList The base URL exclusion list. * @param periodIndex The index of the corresponding period in the manifest. * @param adaptationSetIndices The indices of the corresponding adaptation sets in the period. * @param trackSelection The track selection. @@ -51,6 +52,7 @@ public interface DashChunkSource extends ChunkSource { DashChunkSource createDashChunkSource( LoaderErrorThrower manifestLoaderErrorThrower, DashManifest manifest, + BaseUrlExclusionList baseUrlExclusionList, int periodIndex, int[] adaptationSetIndices, ExoTrackSelection trackSelection, diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index 6cf10b3578..38205a4621 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -84,6 +84,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; @Nullable private final TransferListener transferListener; private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final BaseUrlExclusionList baseUrlExclusionList; private final long elapsedRealtimeOffsetMs; private final LoaderErrorThrower manifestLoaderErrorThrower; private final Allocator allocator; @@ -107,6 +108,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; public DashMediaPeriod( int id, DashManifest manifest, + BaseUrlExclusionList baseUrlExclusionList, int periodIndex, DashChunkSource.Factory chunkSourceFactory, @Nullable TransferListener transferListener, @@ -121,6 +123,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; PlayerEmsgCallback playerEmsgCallback) { this.id = id; this.manifest = manifest; + this.baseUrlExclusionList = baseUrlExclusionList; this.periodIndex = periodIndex; this.chunkSourceFactory = chunkSourceFactory; this.transferListener = transferListener; @@ -766,6 +769,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; chunkSourceFactory.createDashChunkSource( manifestLoaderErrorThrower, manifest, + baseUrlExclusionList, periodIndex, trackGroupInfo.adaptationSetIndices, selection, diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java index 6921245cac..f01d2eab52 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaSource.java @@ -448,6 +448,7 @@ public final class DashMediaSource extends BaseMediaSource { private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final BaseUrlExclusionList baseUrlExclusionList; private final long fallbackTargetLiveOffsetMs; private final EventDispatcher manifestEventDispatcher; private final ParsingLoadable.Parser manifestParser; @@ -502,6 +503,7 @@ public final class DashMediaSource extends BaseMediaSource { this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; this.fallbackTargetLiveOffsetMs = fallbackTargetLiveOffsetMs; this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + baseUrlExclusionList = new BaseUrlExclusionList(); sideloadedManifest = manifest != null; manifestEventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); manifestUriLock = new Object(); @@ -572,6 +574,7 @@ public final class DashMediaSource extends BaseMediaSource { new DashMediaPeriod( firstPeriodId + periodIndex, manifest, + baseUrlExclusionList, periodIndex, chunkSourceFactory, mediaTransferListener, @@ -617,6 +620,7 @@ public final class DashMediaSource extends BaseMediaSource { expiredManifestPublishTimeUs = C.TIME_UNSET; firstPeriodId = 0; periodsById.clear(); + baseUrlExclusionList.reset(); drmSessionManager.release(); } diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java index 3c5bd1373d..e5fd60a2b0 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSource.java @@ -15,8 +15,6 @@ */ package com.google.android.exoplayer2.source.dash; -import static com.google.android.exoplayer2.trackselection.TrackSelectionUtil.createFallbackOptions; -import static com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK; import static java.lang.Math.max; import static java.lang.Math.min; @@ -103,6 +101,7 @@ public class DefaultDashChunkSource implements DashChunkSource { public DashChunkSource createDashChunkSource( LoaderErrorThrower manifestLoaderErrorThrower, DashManifest manifest, + BaseUrlExclusionList baseUrlExclusionList, int periodIndex, int[] adaptationSetIndices, ExoTrackSelection trackSelection, @@ -120,6 +119,7 @@ public class DefaultDashChunkSource implements DashChunkSource { chunkExtractorFactory, manifestLoaderErrorThrower, manifest, + baseUrlExclusionList, periodIndex, adaptationSetIndices, trackSelection, @@ -134,6 +134,7 @@ public class DefaultDashChunkSource implements DashChunkSource { } private final LoaderErrorThrower manifestLoaderErrorThrower; + private final BaseUrlExclusionList baseUrlExclusionList; private final int[] adaptationSetIndices; private final int trackType; private final DataSource dataSource; @@ -154,6 +155,7 @@ public class DefaultDashChunkSource implements DashChunkSource { * chunks. * @param manifestLoaderErrorThrower Throws errors affecting loading of manifests. * @param manifest The initial manifest. + * @param baseUrlExclusionList The base URL exclusion list. * @param periodIndex The index of the period in the manifest. * @param adaptationSetIndices The indices of the adaptation sets in the period. * @param trackSelection The track selection. @@ -174,6 +176,7 @@ public class DefaultDashChunkSource implements DashChunkSource { ChunkExtractor.Factory chunkExtractorFactory, LoaderErrorThrower manifestLoaderErrorThrower, DashManifest manifest, + BaseUrlExclusionList baseUrlExclusionList, int periodIndex, int[] adaptationSetIndices, ExoTrackSelection trackSelection, @@ -186,6 +189,7 @@ public class DefaultDashChunkSource implements DashChunkSource { @Nullable PlayerTrackEmsgHandler playerTrackEmsgHandler) { this.manifestLoaderErrorThrower = manifestLoaderErrorThrower; this.manifest = manifest; + this.baseUrlExclusionList = baseUrlExclusionList; this.adaptationSetIndices = adaptationSetIndices; this.trackSelection = trackSelection; this.trackType = trackType; @@ -201,11 +205,13 @@ public class DefaultDashChunkSource implements DashChunkSource { representationHolders = new RepresentationHolder[trackSelection.length()]; for (int i = 0; i < representationHolders.length; i++) { Representation representation = representations.get(trackSelection.getIndexInTrackGroup(i)); + @Nullable + BaseUrl selectedBaseUrl = baseUrlExclusionList.selectBaseUrl(representation.baseUrls); representationHolders[i] = new RepresentationHolder( periodDurationUs, representation, - representation.baseUrls.get(0), + selectedBaseUrl != null ? selectedBaseUrl : representation.baseUrls.get(0), BundledChunkExtractor.FACTORY.createProgressiveMediaExtractor( trackType, representation.format, @@ -486,18 +492,45 @@ public class DefaultDashChunkSource implements DashChunkSource { } } } - LoadErrorHandlingPolicy.FallbackOptions fallbackOptions = createFallbackOptions(trackSelection); - if (fallbackOptions.numberOfTracks - fallbackOptions.numberOfExcludedTracks <= 1) { - // No more alternative tracks remaining. + + int trackIndex = trackSelection.indexOf(chunk.trackFormat); + RepresentationHolder representationHolder = representationHolders[trackIndex]; + LoadErrorHandlingPolicy.FallbackOptions fallbackOptions = + createFallbackOptions(trackSelection, representationHolder.representation.baseUrls); + if (!fallbackOptions.isFallbackAvailable(LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) + && !fallbackOptions.isFallbackAvailable(LoadErrorHandlingPolicy.FALLBACK_TYPE_LOCATION)) { + // No more alternatives remaining. return false; } @Nullable LoadErrorHandlingPolicy.FallbackSelection fallbackSelection = loadErrorHandlingPolicy.getFallbackSelectionFor(fallbackOptions, loadErrorInfo); - return fallbackSelection != null - && fallbackSelection.type == FALLBACK_TYPE_TRACK - && trackSelection.blacklist( - trackSelection.indexOf(chunk.trackFormat), fallbackSelection.exclusionDurationMs); + if (fallbackSelection == null) { + // Policy indicated to not use any fallback. + return false; + } + + boolean cancelLoad = false; + if (fallbackSelection.type == LoadErrorHandlingPolicy.FALLBACK_TYPE_TRACK) { + cancelLoad = + trackSelection.blacklist( + trackSelection.indexOf(chunk.trackFormat), fallbackSelection.exclusionDurationMs); + } else if (fallbackSelection.type == LoadErrorHandlingPolicy.FALLBACK_TYPE_LOCATION) { + baseUrlExclusionList.exclude( + representationHolder.selectedBaseUrl, fallbackSelection.exclusionDurationMs); + for (int i = 0; i < representationHolders.length; i++) { + @Nullable + BaseUrl baseUrl = + baseUrlExclusionList.selectBaseUrl(representationHolders[i].representation.baseUrls); + if (baseUrl != null) { + if (i == trackIndex) { + cancelLoad = true; + } + representationHolders[i] = representationHolders[i].copyWithNewSelectedBaseUrl(baseUrl); + } + } + } + return cancelLoad; } @Override @@ -512,6 +545,25 @@ public class DefaultDashChunkSource implements DashChunkSource { // Internal methods. + private LoadErrorHandlingPolicy.FallbackOptions createFallbackOptions( + ExoTrackSelection trackSelection, List baseUrls) { + long nowMs = SystemClock.elapsedRealtime(); + int numberOfTracks = trackSelection.length(); + int numberOfExcludedTracks = 0; + for (int i = 0; i < numberOfTracks; i++) { + if (trackSelection.isBlacklisted(i, nowMs)) { + numberOfExcludedTracks++; + } + } + int priorityCount = BaseUrlExclusionList.getPriorityCount(baseUrls); + return new LoadErrorHandlingPolicy.FallbackOptions( + /* numberOfLocations= */ priorityCount, + /* numberOfExcludedLocations= */ priorityCount + - baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls), + numberOfTracks, + numberOfExcludedTracks); + } + private long getSegmentNum( RepresentationHolder representationHolder, @Nullable MediaChunk previousChunk, @@ -848,6 +900,17 @@ public class DefaultDashChunkSource implements DashChunkSource { segmentIndex); } + @CheckResult + /* package */ RepresentationHolder copyWithNewSelectedBaseUrl(BaseUrl selectedBaseUrl) { + return new RepresentationHolder( + periodDurationUs, + representation, + selectedBaseUrl, + chunkExtractor, + segmentNumShift, + segmentIndex); + } + public long getFirstSegmentNum() { return segmentIndex.getFirstSegmentNum() + segmentNumShift; } diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/BaseUrlExclusionListTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/BaseUrlExclusionListTest.java new file mode 100644 index 0000000000..cb75903c81 --- /dev/null +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/BaseUrlExclusionListTest.java @@ -0,0 +1,247 @@ +/* + * 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 com.google.android.exoplayer2.source.dash; + +import static com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy.DEFAULT_LOCATION_EXCLUSION_MS; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.source.dash.manifest.BaseUrl; +import com.google.common.collect.ImmutableList; +import java.time.Duration; +import java.util.List; +import java.util.Random; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowSystemClock; + +/** Unit tests for {@link BaseUrlExclusionList}. */ +@RunWith(AndroidJUnit4.class) +public class BaseUrlExclusionListTest { + + @Test + public void selectBaseUrl_excludeByServiceLocation_excludesAllBaseUrlOfSameServiceLocation() { + BaseUrlExclusionList baseUrlExclusionList = new BaseUrlExclusionList(); + List baseUrls = + ImmutableList.of( + new BaseUrl( + /* url= */ "a", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 1), + new BaseUrl( + /* url= */ "b", /* serviceLocation= */ "a", /* priority= */ 2, /* weight= */ 1), + new BaseUrl( + /* url= */ "c", /* serviceLocation= */ "c", /* priority= */ 3, /* weight= */ 1)); + + baseUrlExclusionList.exclude(baseUrls.get(0), 5000); + + ShadowSystemClock.advanceBy(Duration.ofMillis(4999)); + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls).url).isEqualTo("c"); + ShadowSystemClock.advanceBy(Duration.ofMillis(1)); + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls).url).isEqualTo("a"); + } + + @Test + public void selectBaseUrl_excludeByPriority_excludesAllBaseUrlsOfSamePriority() { + Random mockRandom = mock(Random.class); + when(mockRandom.nextInt(anyInt())).thenReturn(0); + BaseUrlExclusionList baseUrlExclusionList = new BaseUrlExclusionList(mockRandom); + List baseUrls = + ImmutableList.of( + new BaseUrl( + /* url= */ "a", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 1), + new BaseUrl( + /* url= */ "b", /* serviceLocation= */ "b", /* priority= */ 1, /* weight= */ 99), + new BaseUrl( + /* url= */ "c", /* serviceLocation= */ "c", /* priority= */ 2, /* weight= */ 1)); + + baseUrlExclusionList.exclude(baseUrls.get(0), 5000); + + ShadowSystemClock.advanceBy(Duration.ofMillis(4999)); + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls).url).isEqualTo("c"); + ShadowSystemClock.advanceBy(Duration.ofMillis(1)); + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls).url).isEqualTo("a"); + } + + @Test + public void selectBaseUrl_samePriority_choiceIsRandom() { + List baseUrls = + ImmutableList.of( + new BaseUrl( + /* url= */ "a", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 99), + new BaseUrl( + /* url= */ "b", /* serviceLocation= */ "b", /* priority= */ 1, /* weight= */ 1)); + Random mockRandom = mock(Random.class); + when(mockRandom.nextInt(anyInt())).thenReturn(99); + + assertThat(new BaseUrlExclusionList(mockRandom).selectBaseUrl(baseUrls)) + .isEqualTo(baseUrls.get(1)); + + // Random is used for random choice. + verify(mockRandom).nextInt(/* bound= */ 100); + verifyNoMoreInteractions(mockRandom); + } + + @Test + public void selectBaseUrl_samePriority_choiceFromSameElementsRandomOnlyOnceSameAfterwards() { + List baseUrlsVideo = + ImmutableList.of( + new BaseUrl( + /* url= */ "a/v", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 99), + new BaseUrl( + /* url= */ "b/v", /* serviceLocation= */ "b", /* priority= */ 1, /* weight= */ 1)); + List baseUrlsAudio = + ImmutableList.of( + new BaseUrl( + /* url= */ "a/a", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 99), + new BaseUrl( + /* url= */ "b/a", /* serviceLocation= */ "b", /* priority= */ 1, /* weight= */ 1)); + Random mockRandom = mock(Random.class); + BaseUrlExclusionList baseUrlExclusionList = new BaseUrlExclusionList(mockRandom); + when(mockRandom.nextInt(anyInt())).thenReturn(99); + + for (int i = 0; i < 5; i++) { + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrlsVideo).serviceLocation).isEqualTo("b"); + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrlsAudio).serviceLocation).isEqualTo("b"); + } + // Random is used only once. + verify(mockRandom).nextInt(/* bound= */ 100); + verifyNoMoreInteractions(mockRandom); + } + + @Test + public void selectBaseUrl_twiceTheSameLocationExcluded_correctExpirationDuration() { + List baseUrls = + ImmutableList.of( + new BaseUrl( + /* url= */ "a", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 1), + new BaseUrl( + /* url= */ "c", /* serviceLocation= */ "a", /* priority= */ 2, /* weight= */ 1), + new BaseUrl( + /* url= */ "d", /* serviceLocation= */ "d", /* priority= */ 2, /* weight= */ 1)); + BaseUrlExclusionList baseUrlExclusionList = new BaseUrlExclusionList(); + + // Exclude location 'a'. + baseUrlExclusionList.exclude(baseUrls.get(0), 5000); + // Exclude location 'a' which increases exclusion duration of 'a'. + baseUrlExclusionList.exclude(baseUrls.get(1), 10000); + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls)).isNull(); + ShadowSystemClock.advanceBy(Duration.ofMillis(9999)); + // Location 'a' still excluded. + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls)).isNull(); + ShadowSystemClock.advanceBy(Duration.ofMillis(1)); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(2); + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls).url).isEqualTo("a"); + } + + @Test + public void selectBaseUrl_twiceTheSamePriorityExcluded_correctExpirationDuration() { + List baseUrls = + ImmutableList.of( + new BaseUrl( + /* url= */ "a", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 1), + new BaseUrl( + /* url= */ "b", /* serviceLocation= */ "b", /* priority= */ 1, /* weight= */ 1), + new BaseUrl( + /* url= */ "c", /* serviceLocation= */ "c", /* priority= */ 2, /* weight= */ 1)); + BaseUrlExclusionList baseUrlExclusionList = new BaseUrlExclusionList(); + + // Exclude priority 1. + baseUrlExclusionList.exclude(baseUrls.get(0), 5000); + // Exclude priority 1 again which increases the exclusion duration. + baseUrlExclusionList.exclude(baseUrls.get(1), 10000); + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls).url).isEqualTo("c"); + ShadowSystemClock.advanceBy(Duration.ofMillis(9999)); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(1); + ShadowSystemClock.advanceBy(Duration.ofMillis(1)); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(2); + } + + @Test + public void selectBaseUrl_emptyBaseUrlList_selectionIsNull() { + BaseUrlExclusionList baseUrlExclusionList = new BaseUrlExclusionList(); + + assertThat(baseUrlExclusionList.selectBaseUrl(ImmutableList.of())).isNull(); + } + + @Test + public void reset_dropsAllExclusions() { + BaseUrlExclusionList baseUrlExclusionList = new BaseUrlExclusionList(); + List baseUrls = ImmutableList.of(new BaseUrl("a")); + baseUrlExclusionList.exclude(baseUrls.get(0), 5000); + + baseUrlExclusionList.reset(); + + assertThat(baseUrlExclusionList.selectBaseUrl(baseUrls).url).isEqualTo("a"); + } + + @Test + public void getPriorityCountAfterExclusion_correctPriorityCount() { + List baseUrls = + ImmutableList.of( + new BaseUrl( + /* url= */ "a", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 1), + new BaseUrl( + /* url= */ "b", /* serviceLocation= */ "b", /* priority= */ 2, /* weight= */ 1), + new BaseUrl( + /* url= */ "c", /* serviceLocation= */ "c", /* priority= */ 2, /* weight= */ 1), + new BaseUrl( + /* url= */ "d", /* serviceLocation= */ "d", /* priority= */ 3, /* weight= */ 1), + new BaseUrl( + /* url= */ "e", /* serviceLocation= */ "e", /* priority= */ 3, /* weight= */ 1)); + BaseUrlExclusionList baseUrlExclusionList = new BaseUrlExclusionList(); + + // Empty base URL list. + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(ImmutableList.of())) + .isEqualTo(0); + + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(3); + // Exclude base urls. + baseUrlExclusionList.exclude(baseUrls.get(0), DEFAULT_LOCATION_EXCLUSION_MS); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(2); + baseUrlExclusionList.exclude(baseUrls.get(1), 2 * DEFAULT_LOCATION_EXCLUSION_MS); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(1); + baseUrlExclusionList.exclude(baseUrls.get(3), 3 * DEFAULT_LOCATION_EXCLUSION_MS); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(0); + // Time passes. + ShadowSystemClock.advanceBy(Duration.ofMillis(DEFAULT_LOCATION_EXCLUSION_MS)); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(1); + ShadowSystemClock.advanceBy(Duration.ofMillis(DEFAULT_LOCATION_EXCLUSION_MS)); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(2); + ShadowSystemClock.advanceBy(Duration.ofMillis(DEFAULT_LOCATION_EXCLUSION_MS)); + assertThat(baseUrlExclusionList.getPriorityCountAfterExclusion(baseUrls)).isEqualTo(3); + } + + @Test + public void getPriorityCount_correctPriorityCount() { + List baseUrls = + ImmutableList.of( + new BaseUrl( + /* url= */ "a", /* serviceLocation= */ "a", /* priority= */ 1, /* weight= */ 1), + new BaseUrl( + /* url= */ "b", /* serviceLocation= */ "b", /* priority= */ 2, /* weight= */ 1), + new BaseUrl( + /* url= */ "c", /* serviceLocation= */ "c", /* priority= */ 2, /* weight= */ 1), + new BaseUrl( + /* url= */ "d", /* serviceLocation= */ "d", /* priority= */ 3, /* weight= */ 1)); + + assertThat(BaseUrlExclusionList.getPriorityCount(baseUrls)).isEqualTo(3); + assertThat(BaseUrlExclusionList.getPriorityCount(ImmutableList.of())).isEqualTo(0); + } +} diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java index 2391825237..fd55087b47 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DashMediaPeriodTest.java @@ -200,6 +200,7 @@ public final class DashMediaPeriodTest { return new DashMediaPeriod( /* id= */ periodIndex, manifest, + new BaseUrlExclusionList(), periodIndex, mock(DashChunkSource.Factory.class), mock(TransferListener.class), diff --git a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSourceTest.java b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSourceTest.java index b5673bd7ee..16ea603d00 100644 --- a/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSourceTest.java +++ b/library/dash/src/test/java/com/google/android/exoplayer2/source/dash/DefaultDashChunkSourceTest.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source.dash; +import static com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy.DEFAULT_LOCATION_EXCLUSION_MS; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.common.truth.Truth.assertThat; @@ -29,6 +30,7 @@ import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.BundledChunkExtractor; +import com.google.android.exoplayer2.source.chunk.Chunk; import com.google.android.exoplayer2.source.chunk.ChunkHolder; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; @@ -45,10 +47,16 @@ import com.google.android.exoplayer2.upstream.LoaderErrorThrower; import com.google.android.exoplayer2.util.Assertions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.internal.DoNotInstrument; +import org.robolectric.shadows.ShadowSystemClock; /** Unit test for {@link DefaultDashChunkSource}. */ @RunWith(AndroidJUnit4.class) @@ -58,6 +66,8 @@ public class DefaultDashChunkSourceTest { private static final String SAMPLE_MPD_LIVE_WITH_OFFSET_INSIDE_WINDOW = "media/mpd/sample_mpd_live_with_offset_inside_window"; private static final String SAMPLE_MPD_VOD = "media/mpd/sample_mpd_vod"; + private static final String SAMPLE_MPD_VOD_LOCATION_FALLBACK = + "media/mpd/sample_mpd_vod_location_fallback"; @Test public void getNextChunk_forLowLatencyManifest_setsCorrectMayNotLoadAtFullNetworkSpeedFlag() @@ -76,6 +86,7 @@ public class DefaultDashChunkSourceTest { BundledChunkExtractor.FACTORY, new LoaderErrorThrower.Dummy(), manifest, + new BaseUrlExclusionList(), /* periodIndex= */ 0, /* adaptationSetIndices= */ new int[] {0}, new FixedTrackSelection(new TrackGroup(new Format.Builder().build()), /* track= */ 0), @@ -123,6 +134,7 @@ public class DefaultDashChunkSourceTest { BundledChunkExtractor.FACTORY, new LoaderErrorThrower.Dummy(), manifest, + new BaseUrlExclusionList(), /* periodIndex= */ 0, /* adaptationSetIndices= */ new int[] {0}, new FixedTrackSelection(new TrackGroup(new Format.Builder().build()), /* track= */ 0), @@ -146,7 +158,63 @@ public class DefaultDashChunkSourceTest { } @Test - public void onChunkLoadError_trackExclusionEnabled_requestReplacementChunkAsLongAsAvailable() + public void getNextChunk_onChunkLoadErrorLocationExclusionEnabled_correctFallbackBehavior() + throws Exception { + DefaultLoadErrorHandlingPolicy loadErrorHandlingPolicy = + new DefaultLoadErrorHandlingPolicy() { + @Override + public FallbackSelection getFallbackSelectionFor( + FallbackOptions fallbackOptions, LoadErrorInfo loadErrorInfo) { + return new FallbackSelection(FALLBACK_TYPE_LOCATION, DEFAULT_LOCATION_EXCLUSION_MS); + } + }; + List chunks = new ArrayList<>(); + DashChunkSource chunkSource = createDashChunkSource(/* numberOfTracks= */ 1); + ChunkHolder output = new ChunkHolder(); + + boolean requestReplacementChunk = true; + while (requestReplacementChunk) { + chunkSource.getNextChunk( + /* playbackPositionUs= */ 0, + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + output); + chunks.add(output.chunk); + requestReplacementChunk = + chunkSource.onChunkLoadError( + checkNotNull(output.chunk), + /* cancelable= */ true, + createFakeLoadErrorInfo( + output.chunk.dataSpec, /* httpResponseCode= */ 404, /* errorCount= */ 1), + loadErrorHandlingPolicy); + } + + assertThat(Lists.transform(chunks, (chunk) -> chunk.dataSpec.uri.toString())) + .containsExactly( + "http://video.com/baseUrl/a/video/video_0_1300000.m4s", + "http://video.com/baseUrl/b/video/video_0_1300000.m4s", + "http://video.com/baseUrl/d/video/video_0_1300000.m4s") + .inOrder(); + + // Assert expiration of exclusions. + ShadowSystemClock.advanceBy(Duration.ofMillis(DEFAULT_LOCATION_EXCLUSION_MS)); + chunkSource.onChunkLoadError( + checkNotNull(output.chunk), + /* cancelable= */ true, + createFakeLoadErrorInfo( + output.chunk.dataSpec, /* httpResponseCode= */ 404, /* errorCount= */ 1), + loadErrorHandlingPolicy); + chunkSource.getNextChunk( + /* playbackPositionUs= */ 0, + /* loadPositionUs= */ 0, + /* queue= */ ImmutableList.of(), + output); + assertThat(output.chunk.dataSpec.uri.toString()) + .isEqualTo("http://video.com/baseUrl/a/video/video_0_1300000.m4s"); + } + + @Test + public void getNextChunk_onChunkLoadErrorTrackExclusionEnabled_correctFallbackBehavior() throws Exception { DefaultLoadErrorHandlingPolicy loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy() { @@ -158,31 +226,36 @@ public class DefaultDashChunkSourceTest { FALLBACK_TYPE_TRACK, DefaultLoadErrorHandlingPolicy.DEFAULT_TRACK_EXCLUSION_MS); } }; - int numberOfTracks = 2; - DashChunkSource chunkSource = createDashChunkSource(numberOfTracks); + DashChunkSource chunkSource = createDashChunkSource(/* numberOfTracks= */ 4); ChunkHolder output = new ChunkHolder(); - for (int i = 0; i < numberOfTracks; i++) { + List chunks = new ArrayList<>(); + boolean requestReplacementChunk = true; + while (requestReplacementChunk) { chunkSource.getNextChunk( /* playbackPositionUs= */ 0, /* loadPositionUs= */ 0, /* queue= */ ImmutableList.of(), output); - - boolean alternativeTrackAvailable = + chunks.add(output.chunk); + requestReplacementChunk = chunkSource.onChunkLoadError( checkNotNull(output.chunk), /* cancelable= */ true, createFakeLoadErrorInfo( output.chunk.dataSpec, /* httpResponseCode= */ 404, /* errorCount= */ 1), loadErrorHandlingPolicy); - - // Expect true except for the last track remaining. - assertThat(alternativeTrackAvailable).isEqualTo(i != numberOfTracks - 1); } + assertThat(Lists.transform(chunks, (chunk) -> chunk.dataSpec.uri.toString())) + .containsExactly( + "http://video.com/baseUrl/a/video/video_0_700000.m4s", + "http://video.com/baseUrl/a/video/video_0_452000.m4s", + "http://video.com/baseUrl/a/video/video_0_250000.m4s", + "http://video.com/baseUrl/a/video/video_0_1300000.m4s") + .inOrder(); } @Test - public void onChunkLoadError_trackExclusionDisabled_neverRequestReplacementChunk() + public void getNextChunk_onChunkLoadErrorExclusionDisabled_neverRequestReplacementChunk() throws Exception { DefaultLoadErrorHandlingPolicy loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy() { @@ -202,7 +275,7 @@ public class DefaultDashChunkSourceTest { /* queue= */ ImmutableList.of(), output); - boolean alternativeTrackAvailable = + boolean requestReplacementChunk = chunkSource.onChunkLoadError( checkNotNull(output.chunk), /* cancelable= */ true, @@ -210,7 +283,7 @@ public class DefaultDashChunkSourceTest { output.chunk.dataSpec, /* httpResponseCode= */ 404, /* errorCount= */ 1), loadErrorHandlingPolicy); - assertThat(alternativeTrackAvailable).isFalse(); + assertThat(requestReplacementChunk).isFalse(); } private DashChunkSource createDashChunkSource(int numberOfTracks) throws IOException { @@ -220,7 +293,7 @@ public class DefaultDashChunkSourceTest { .parse( Uri.parse("https://example.com/test.mpd"), TestUtil.getInputStream( - ApplicationProvider.getApplicationContext(), SAMPLE_MPD_VOD)); + ApplicationProvider.getApplicationContext(), SAMPLE_MPD_VOD_LOCATION_FALLBACK)); int[] adaptationSetIndices = new int[] {0}; int[] selectedTracks = new int[numberOfTracks]; Format[] formats = new Format[numberOfTracks]; @@ -244,6 +317,7 @@ public class DefaultDashChunkSourceTest { BundledChunkExtractor.FACTORY, new LoaderErrorThrower.Dummy(), manifest, + new BaseUrlExclusionList(new Random(/* seed= */ 1234)), /* periodIndex= */ 0, /* adaptationSetIndices= */ adaptationSetIndices, adaptiveTrackSelection, diff --git a/testdata/src/test/assets/media/mpd/sample_mpd_vod_location_fallback b/testdata/src/test/assets/media/mpd/sample_mpd_vod_location_fallback new file mode 100644 index 0000000000..32207dde45 --- /dev/null +++ b/testdata/src/test/assets/media/mpd/sample_mpd_vod_location_fallback @@ -0,0 +1,41 @@ + + + http://video.com/baseUrl/a/ + http://video.com/baseUrl/b/ + http://video.com/baseUrl/b/ + http://video.com/baseUrl/d/ + + + + video/ + + + + + + + + + + + + + audio/ + + + + + + + + + + +