From e6046a5c073f83592757bee1b5ebe8f667aed4bc Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 30 Nov 2020 17:34:30 +0000 Subject: [PATCH] Discard HLS preload chunks when an HTTP 410 or 404 occurs This change avoids an early fatal exception for replaced hinted parts. Issue: #5011 PiperOrigin-RevId: 344828076 --- .../exoplayer2/util/StableApiCandidate.java | 36 ++++++++++ .../exoplayer2/source/hls/HlsChunkSource.java | 67 ++++++++++++++----- .../exoplayer2/source/hls/HlsMediaChunk.java | 26 +++++-- .../source/hls/HlsSampleStreamWrapper.java | 34 ++++++++-- 4 files changed, 135 insertions(+), 28 deletions(-) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/util/StableApiCandidate.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/StableApiCandidate.java b/library/core/src/main/java/com/google/android/exoplayer2/util/StableApiCandidate.java new file mode 100644 index 0000000000..ff96e02e05 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/StableApiCandidate.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2019 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.util; + +import static java.lang.annotation.ElementType.CONSTRUCTOR; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PACKAGE; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.CLASS; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Annotation to mark an API as a candidate for being part of a stable subset of the API. + * + *

Note: this is experimental, and no guarantees are made about the stability of APIs even if + * they are marked with this annotation. + */ +@Retention(CLASS) +@Target({TYPE, METHOD, CONSTRUCTOR, FIELD, PACKAGE}) +public @interface StableApiCandidate {} diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java index 314714fe95..c2254a4280 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -21,6 +21,7 @@ import static java.lang.Math.max; import android.net.Uri; import android.os.SystemClock; import android.util.Pair; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; @@ -47,6 +48,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.primitives.Ints; import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -86,6 +89,29 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } } + /** + * Chunk publication state. One of {@link #CHUNK_PUBLICATION_STATE_PRELOAD}, {@link + * #CHUNK_PUBLICATION_STATE_PUBLISHED}, {@link #CHUNK_PUBLICATION_STATE_REMOVED}. + */ + @IntDef({ + CHUNK_PUBLICATION_STATE_PRELOAD, + CHUNK_PUBLICATION_STATE_PUBLISHED, + CHUNK_PUBLICATION_STATE_REMOVED + }) + @Retention(RetentionPolicy.SOURCE) + @interface ChunkPublicationState {} + + /** Indicates that the chunk is based on a preload hint. */ + public static final int CHUNK_PUBLICATION_STATE_PRELOAD = 0; + /** Indicates that the chunk is definitely published. */ + public static final int CHUNK_PUBLICATION_STATE_PUBLISHED = 1; + /** + * Indicates that the chunk has been removed from the playlist. + * + *

See RFC 8216, Section 6.2.6 also. + */ + public static final int CHUNK_PUBLICATION_STATE_REMOVED = 2; + /** * The maximum number of keys that the key cache can hold. This value must be 2 or greater in * order to hold initialization segment and media segment keys simultaneously. @@ -222,29 +248,32 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } /** - * Checks whether the previous media chunk is a preload chunk that has been removed in the current - * playlist. + * Returns the publication state of the given chunk. * - * @param previous The previous media chunk. - * @return True if the previous media chunk has been removed in the current playlist. + * @param mediaChunk The media chunk for which to evaluate the publication state. + * @return Whether the media chunk is {@link #CHUNK_PUBLICATION_STATE_PRELOAD a preload chunk}, + * has been {@link #CHUNK_PUBLICATION_STATE_REMOVED removed} or is definitely {@link + * #CHUNK_PUBLICATION_STATE_PUBLISHED published}. */ - public boolean isMediaChunkRemoved(HlsMediaChunk previous) { - if (!previous.isPreload) { - return false; + @ChunkPublicationState + public int getChunkPublicationState(HlsMediaChunk mediaChunk) { + if (mediaChunk.partIndex == C.INDEX_UNSET) { + // Chunks based on full segments can't be removed and are always published. + return CHUNK_PUBLICATION_STATE_PUBLISHED; } - Uri playlistUrl = playlistUrls[trackGroup.indexOf(previous.trackFormat)]; + Uri playlistUrl = playlistUrls[trackGroup.indexOf(mediaChunk.trackFormat)]; HlsMediaPlaylist mediaPlaylist = checkNotNull(playlistTracker.getPlaylistSnapshot(playlistUrl, /* isForPlayback= */ false)); - int segmentIndexInPlaylist = (int) (previous.chunkIndex - mediaPlaylist.mediaSequence); + int segmentIndexInPlaylist = (int) (mediaChunk.chunkIndex - mediaPlaylist.mediaSequence); if (segmentIndexInPlaylist < 0) { - // The segment of the previous chunk is not in the current playlist anymore. - return false; + // The parent segment of the previous chunk is not in the current playlist anymore. + return CHUNK_PUBLICATION_STATE_PUBLISHED; } List partsInCurrentPlaylist = segmentIndexInPlaylist < mediaPlaylist.segments.size() ? mediaPlaylist.segments.get(segmentIndexInPlaylist).parts : mediaPlaylist.trailingParts; - if (previous.partIndex >= partsInCurrentPlaylist.size()) { + if (mediaChunk.partIndex >= partsInCurrentPlaylist.size()) { // In case the part hinted in the previous playlist has been wrongly assigned to the then full // but not yet terminated segment, we discard it regardless whether the URI is different or // not. While this is theoretically possible and unspecified, it appears to be an edge case @@ -252,11 +281,17 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; // here but, if the chunk is not discarded, it could create unpredictable problems later, // because the media sequence in previous.chunkIndex does not match to the actual media // sequence in the new playlist. - return true; + return CHUNK_PUBLICATION_STATE_REMOVED; } - HlsMediaPlaylist.Part publishedPart = partsInCurrentPlaylist.get(previous.partIndex); - Uri publishedUri = Uri.parse(UriUtil.resolve(mediaPlaylist.baseUri, publishedPart.url)); - return !Util.areEqual(publishedUri, previous.dataSpec.uri); + HlsMediaPlaylist.Part newPart = partsInCurrentPlaylist.get(mediaChunk.partIndex); + if (newPart.isPreload) { + // The playlist did not change and the part in the new playlist is still a preload hint. + return CHUNK_PUBLICATION_STATE_PRELOAD; + } + Uri newUri = Uri.parse(UriUtil.resolve(mediaPlaylist.baseUri, newPart.url)); + return Util.areEqual(newUri, mediaChunk.dataSpec.uri) + ? CHUNK_PUBLICATION_STATE_PUBLISHED + : CHUNK_PUBLICATION_STATE_REMOVED; } /** diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java index e294a3f1be..643f5e5dd5 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -169,7 +169,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; segmentEndTimeInPeriodUs, segmentBaseHolder.mediaSequence, segmentBaseHolder.partIndex, - segmentBaseHolder.isPreload, + /* isPublished= */ !segmentBaseHolder.isPreload, discontinuitySequenceNumber, mediaSegment.hasGapTag, isMasterTimestampSource, @@ -205,9 +205,6 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** The part index or {@link C#INDEX_UNSET} if the chunk is a full segment */ public final int partIndex; - /** Whether this chunk is a preload chunk. */ - public final boolean isPreload; - @Nullable private final DataSource initDataSource; @Nullable private final DataSpec initDataSpec; @Nullable private final HlsMediaChunkExtractor previousExtractor; @@ -233,6 +230,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; private boolean loadCompleted; private ImmutableList sampleQueueFirstSampleIndices; private boolean extractorInvalidated; + private boolean isPublished; private HlsMediaChunk( HlsExtractorFactory extractorFactory, @@ -251,7 +249,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; long endTimeUs, long chunkMediaSequence, int partIndex, - boolean isPreload, + boolean isPublished, int discontinuitySequenceNumber, boolean hasGapTag, boolean isMasterTimestampSource, @@ -272,7 +270,7 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; chunkMediaSequence); this.mediaSegmentEncrypted = mediaSegmentEncrypted; this.partIndex = partIndex; - this.isPreload = isPreload; + this.isPublished = isPublished; this.discontinuitySequenceNumber = discontinuitySequenceNumber; this.initDataSpec = initDataSpec; this.initDataSource = initDataSource; @@ -356,6 +354,22 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } } + /** + * Whether the chunk is a published chunk as opposed to a preload hint that may change when the + * playlist updates. + */ + public boolean isPublished() { + return isPublished; + } + + /** + * Sets the publish flag of the media chunk to indicate that it is not based on a part that is a + * preload hint in the playlist. + */ + public void publish() { + isPublished = true; + } + // Internal methods. @RequiresNonNull("output") diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index b8a026aa30..2255151b92 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2.source.hls; +import static com.google.android.exoplayer2.source.hls.HlsChunkSource.CHUNK_PUBLICATION_STATE_PUBLISHED; +import static com.google.android.exoplayer2.source.hls.HlsChunkSource.CHUNK_PUBLICATION_STATE_REMOVED; import static java.lang.Math.max; import android.net.Uri; @@ -54,6 +56,7 @@ import com.google.android.exoplayer2.source.chunk.MediaChunkIterator; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DataReader; +import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy.LoadErrorInfo; import com.google.android.exoplayer2.upstream.Loader; @@ -506,10 +509,17 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** Called when the playlist is updated. */ public void onPlaylistUpdated() { - if (!loadingFinished - && loader.isLoading() - && !mediaChunks.isEmpty() - && chunkSource.isMediaChunkRemoved(Iterables.getLast(mediaChunks))) { + if (mediaChunks.isEmpty()) { + return; + } + HlsMediaChunk lastMediaChunk = Iterables.getLast(mediaChunks); + @HlsChunkSource.ChunkPublicationState + int chunkState = chunkSource.getChunkPublicationState(lastMediaChunk); + if (chunkState == CHUNK_PUBLICATION_STATE_PUBLISHED) { + lastMediaChunk.publish(); + } else if (chunkState == CHUNK_PUBLICATION_STATE_REMOVED + && !loadingFinished + && loader.isLoading()) { loader.cancelLoading(); } } @@ -738,7 +748,8 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; } if (!readOnlyMediaChunks.isEmpty() - && chunkSource.isMediaChunkRemoved(Iterables.getLast(readOnlyMediaChunks))) { + && chunkSource.getChunkPublicationState(Iterables.getLast(readOnlyMediaChunks)) + == CHUNK_PUBLICATION_STATE_REMOVED) { discardUpstream(mediaChunks.size() - 1); } @@ -820,8 +831,19 @@ import org.checkerframework.checker.nullness.qual.RequiresNonNull; long loadDurationMs, IOException error, int errorCount) { - long bytesLoaded = loadable.bytesLoaded(); boolean isMediaChunk = isMediaChunk(loadable); + if (isMediaChunk + && !((HlsMediaChunk) loadable).isPublished() + && error instanceof HttpDataSource.InvalidResponseCodeException) { + int responseCode = ((HttpDataSource.InvalidResponseCodeException) error).responseCode; + if (responseCode == 410 || responseCode == 404) { + // According to RFC 8216, Section 6.2.6 a server should respond with an HTTP 404 (Not found) + // for requests of hinted parts that are replaced and not available anymore. We've seen test + // streams with HTTP 410 (Gone) also. + return Loader.RETRY; + } + } + long bytesLoaded = loadable.bytesLoaded(); boolean exclusionSucceeded = false; LoadEventInfo loadEventInfo = new LoadEventInfo(