diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 708b72ff2..1fce25e78 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -16,6 +16,8 @@ import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; +import org.schabi.newpipe.player.playback.CustomHlsPlaylistTracker; + public class PlayerDataSource { private static final int MANIFEST_MINIMUM_RETRY = 5; private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; @@ -44,8 +46,9 @@ public class PlayerDataSource { public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { return new HlsMediaSource.Factory(cachelessDataSourceFactory) .setAllowChunklessPreparation(true) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)); + .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy( + MANIFEST_MINIMUM_RETRY)) + .setPlaylistTrackerFactory(CustomHlsPlaylistTracker.FACTORY); } public DashMediaSource.Factory getLiveDashMediaSourceFactory() { diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/CustomHlsPlaylistTracker.java b/app/src/main/java/org/schabi/newpipe/player/playback/CustomHlsPlaylistTracker.java new file mode 100644 index 000000000..28f6b01fe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/CustomHlsPlaylistTracker.java @@ -0,0 +1,782 @@ +/* + * Original source code (DefaultHlsPlaylistTracker): Copyright (C) 2016 The Android Open Source + * Project + * + * Original source code licensed under the Apache License, Version 2.0 (the "License"); + * you may not use the original source code of 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 org.schabi.newpipe.player.playback; + +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static java.lang.Math.max; + +import android.net.Uri; +import android.os.Handler; +import android.os.SystemClock; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.source.LoadEventInfo; +import com.google.android.exoplayer2.source.MediaLoadData; +import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Part; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.RenditionReport; +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import com.google.android.exoplayer2.upstream.DataSource; +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; +import com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import com.google.android.exoplayer2.upstream.ParsingLoadable; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.Iterables; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * NewPipe's implementation for {@link HlsPlaylistTracker}, based on + * {@link DefaultHlsPlaylistTracker}. + * + *

+ * It redefines the way of how + * {@link PlaylistStuckException PlaylistStuckExceptions} are thrown: instead of + * using a multiplication between the target duration of segments and + * {@link DefaultHlsPlaylistTracker#DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT}, it uses a + * constant value (see {@link #MAXIMUM_PLAYLIST_STUCK_DURATION_MS}), in order to reduce the number + * of this exception thrown, especially on (very) low-latency livestreams. + *

+ */ +public final class CustomHlsPlaylistTracker implements HlsPlaylistTracker, + Loader.Callback> { + + /** + * Factory for {@link CustomHlsPlaylistTracker} instances. + */ + public static final Factory FACTORY = CustomHlsPlaylistTracker::new; + + /** + * The maximum duration before a {@link PlaylistStuckException} is thrown, in milliseconds. + */ + private static final double MAXIMUM_PLAYLIST_STUCK_DURATION_MS = 15000; + + private final HlsDataSourceFactory dataSourceFactory; + private final HlsPlaylistParserFactory playlistParserFactory; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final HashMap playlistBundles; + private final List listeners; + + @Nullable + private EventDispatcher eventDispatcher; + @Nullable + private Loader initialPlaylistLoader; + @Nullable + private Handler playlistRefreshHandler; + @Nullable + private PrimaryPlaylistListener primaryPlaylistListener; + @Nullable + private HlsMasterPlaylist masterPlaylist; + @Nullable + private Uri primaryMediaPlaylistUrl; + @Nullable + private HlsMediaPlaylist primaryMediaPlaylistSnapshot; + private boolean isLive; + private long initialStartTimeUs; + + /** + * Creates an instance. + * + * @param dataSourceFactory A factory for {@link DataSource} instances. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param playlistParserFactory An {@link HlsPlaylistParserFactory}. + */ + public CustomHlsPlaylistTracker(final HlsDataSourceFactory dataSourceFactory, + final LoadErrorHandlingPolicy loadErrorHandlingPolicy, + final HlsPlaylistParserFactory playlistParserFactory) { + this.dataSourceFactory = dataSourceFactory; + this.playlistParserFactory = playlistParserFactory; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + listeners = new ArrayList<>(); + playlistBundles = new HashMap<>(); + initialStartTimeUs = C.TIME_UNSET; + } + + // HlsPlaylistTracker implementation. + + @Override + public void start(@NonNull final Uri initialPlaylistUri, + @NonNull final EventDispatcher eventDispatcherObject, + @NonNull final PrimaryPlaylistListener primaryPlaylistListenerObject) { + this.playlistRefreshHandler = Util.createHandlerForCurrentLooper(); + this.eventDispatcher = eventDispatcherObject; + this.primaryPlaylistListener = primaryPlaylistListenerObject; + final ParsingLoadable masterPlaylistLoadable = new ParsingLoadable<>( + dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST), + initialPlaylistUri, + C.DATA_TYPE_MANIFEST, + playlistParserFactory.createPlaylistParser()); + Assertions.checkState(initialPlaylistLoader == null); + initialPlaylistLoader = new Loader("CustomHlsPlaylistTracker:MasterPlaylist"); + final long elapsedRealtime = initialPlaylistLoader.startLoading(masterPlaylistLoadable, + this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount( + masterPlaylistLoadable.type)); + eventDispatcherObject.loadStarted(new LoadEventInfo(masterPlaylistLoadable.loadTaskId, + masterPlaylistLoadable.dataSpec, elapsedRealtime), + masterPlaylistLoadable.type); + } + + @Override + public void stop() { + primaryMediaPlaylistUrl = null; + primaryMediaPlaylistSnapshot = null; + masterPlaylist = null; + initialStartTimeUs = C.TIME_UNSET; + initialPlaylistLoader.release(); + initialPlaylistLoader = null; + for (final MediaPlaylistBundle bundle : playlistBundles.values()) { + bundle.release(); + } + playlistRefreshHandler.removeCallbacksAndMessages(null); + playlistRefreshHandler = null; + playlistBundles.clear(); + } + + @Override + public void addListener(@NonNull final PlaylistEventListener listener) { + checkNotNull(listener); + listeners.add(listener); + } + + @Override + public void removeListener(@NonNull final PlaylistEventListener listener) { + listeners.remove(listener); + } + + @Override + @Nullable + public HlsMasterPlaylist getMasterPlaylist() { + return masterPlaylist; + } + + @Override + @Nullable + public HlsMediaPlaylist getPlaylistSnapshot(@NonNull final Uri url, + final boolean isForPlayback) { + final HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot(); + if (snapshot != null && isForPlayback) { + maybeSetPrimaryUrl(url); + } + return snapshot; + } + + @Override + public long getInitialStartTimeUs() { + return initialStartTimeUs; + } + + @Override + public boolean isSnapshotValid(@NonNull final Uri url) { + return playlistBundles.get(url).isSnapshotValid(); + } + + @Override + public void maybeThrowPrimaryPlaylistRefreshError() throws IOException { + if (initialPlaylistLoader != null) { + initialPlaylistLoader.maybeThrowError(); + } + if (primaryMediaPlaylistUrl != null) { + maybeThrowPlaylistRefreshError(primaryMediaPlaylistUrl); + } + } + + @Override + public void maybeThrowPlaylistRefreshError(@NonNull final Uri url) throws IOException { + playlistBundles.get(url).maybeThrowPlaylistRefreshError(); + } + + @Override + public void refreshPlaylist(@NonNull final Uri url) { + playlistBundles.get(url).loadPlaylist(); + } + + @Override + public boolean isLive() { + return isLive; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(@NonNull final ParsingLoadable loadable, + final long elapsedRealtimeMs, + final long loadDurationMs) { + final HlsPlaylist result = loadable.getResult(); + final HlsMasterPlaylist newMasterPlaylist; + final boolean isMediaPlaylist = result instanceof HlsMediaPlaylist; + if (isMediaPlaylist) { + newMasterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist( + result.baseUri); + } else { // result instanceof HlsMasterPlaylist + newMasterPlaylist = (HlsMasterPlaylist) result; + } + this.masterPlaylist = newMasterPlaylist; + primaryMediaPlaylistUrl = newMasterPlaylist.variants.get(0).url; + createBundles(newMasterPlaylist.mediaPlaylistUrls); + final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId, + loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(), + elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + final MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl); + if (isMediaPlaylist) { + // We don't need to load the playlist again. We can use the same result. + primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo); + } else { + primaryBundle.loadPlaylist(); + } + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST); + } + + @Override + public void onLoadCanceled(@NonNull final ParsingLoadable loadable, + final long elapsedRealtimeMs, + final long loadDurationMs, + final boolean released) { + final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId, + loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(), + elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + eventDispatcher.loadCanceled(loadEventInfo, C.DATA_TYPE_MANIFEST); + } + + @Override + public LoadErrorAction onLoadError(@NonNull final ParsingLoadable loadable, + final long elapsedRealtimeMs, + final long loadDurationMs, + final IOException error, + final int errorCount) { + final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId, + loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(), + elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + final MediaLoadData mediaLoadData = new MediaLoadData(loadable.type); + final long retryDelayMs = loadErrorHandlingPolicy.getRetryDelayMsFor(new LoadErrorInfo( + loadEventInfo, mediaLoadData, error, errorCount)); + final boolean isFatal = retryDelayMs == C.TIME_UNSET; + eventDispatcher.loadError(loadEventInfo, loadable.type, error, isFatal); + if (isFatal) { + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } + return isFatal ? Loader.DONT_RETRY_FATAL : Loader.createRetryAction(false, retryDelayMs); + } + + // Internal methods. + + private boolean maybeSelectNewPrimaryUrl() { + final List variants = masterPlaylist.variants; + final int variantsSize = variants.size(); + final long currentTimeMs = SystemClock.elapsedRealtime(); + for (int i = 0; i < variantsSize; i++) { + final MediaPlaylistBundle bundle = checkNotNull(playlistBundles.get( + variants.get(i).url)); + if (currentTimeMs > bundle.excludeUntilMs) { + primaryMediaPlaylistUrl = bundle.playlistUrl; + bundle.loadPlaylistInternal(getRequestUriForPrimaryChange( + primaryMediaPlaylistUrl)); + return true; + } + } + return false; + } + + private void maybeSetPrimaryUrl(@NonNull final Uri url) { + if (url.equals(primaryMediaPlaylistUrl) || !isVariantUrl(url) + || (primaryMediaPlaylistSnapshot != null + && primaryMediaPlaylistSnapshot.hasEndTag)) { + // Ignore if the primary media playlist URL is unchanged, if the media playlist is not + // referenced directly by a variant, or it the last primary snapshot contains an end + // tag. + return; + } + primaryMediaPlaylistUrl = url; + final MediaPlaylistBundle newPrimaryBundle = playlistBundles.get(primaryMediaPlaylistUrl); + final HlsMediaPlaylist newPrimarySnapshot = newPrimaryBundle.playlistSnapshot; + if (newPrimarySnapshot != null && newPrimarySnapshot.hasEndTag) { + primaryMediaPlaylistSnapshot = newPrimarySnapshot; + primaryPlaylistListener.onPrimaryPlaylistRefreshed(newPrimarySnapshot); + } else { + // The snapshot for the new primary media playlist URL may be stale. Defer updating the + // primary snapshot until after we've refreshed it. + newPrimaryBundle.loadPlaylistInternal(getRequestUriForPrimaryChange(url)); + } + } + + private Uri getRequestUriForPrimaryChange(@NonNull final Uri newPrimaryPlaylistUri) { + if (primaryMediaPlaylistSnapshot != null + && primaryMediaPlaylistSnapshot.serverControl.canBlockReload) { + final RenditionReport renditionReport = primaryMediaPlaylistSnapshot.renditionReports + .get(newPrimaryPlaylistUri); + if (renditionReport != null) { + final Uri.Builder uriBuilder = newPrimaryPlaylistUri.buildUpon(); + uriBuilder.appendQueryParameter(MediaPlaylistBundle.BLOCK_MSN_PARAM, + String.valueOf(renditionReport.lastMediaSequence)); + if (renditionReport.lastPartIndex != C.INDEX_UNSET) { + uriBuilder.appendQueryParameter(MediaPlaylistBundle.BLOCK_PART_PARAM, + String.valueOf(renditionReport.lastPartIndex)); + } + return uriBuilder.build(); + } + } + return newPrimaryPlaylistUri; + } + + /** + * @return whether any of the variants in the master playlist have the specified playlist URL. + * @param playlistUrl the playlist URL to test + */ + private boolean isVariantUrl(final Uri playlistUrl) { + final List variants = masterPlaylist.variants; + final int variantsSize = variants.size(); + for (int i = 0; i < variantsSize; i++) { + if (playlistUrl.equals(variants.get(i).url)) { + return true; + } + } + return false; + } + + private void createBundles(@NonNull final List urls) { + final int listSize = urls.size(); + for (int i = 0; i < listSize; i++) { + final Uri url = urls.get(i); + final MediaPlaylistBundle bundle = new MediaPlaylistBundle(url); + playlistBundles.put(url, bundle); + } + } + + /** + * Called by the bundles when a snapshot changes. + * + * @param url The url of the playlist. + * @param newSnapshot The new snapshot. + */ + private void onPlaylistUpdated(@NonNull final Uri url, final HlsMediaPlaylist newSnapshot) { + if (url.equals(primaryMediaPlaylistUrl)) { + if (primaryMediaPlaylistSnapshot == null) { + // This is the first primary URL snapshot. + isLive = !newSnapshot.hasEndTag; + initialStartTimeUs = newSnapshot.startTimeUs; + } + primaryMediaPlaylistSnapshot = newSnapshot; + primaryPlaylistListener.onPrimaryPlaylistRefreshed(newSnapshot); + } + final int listenersSize = listeners.size(); + for (int i = 0; i < listenersSize; i++) { + listeners.get(i).onPlaylistChanged(); + } + } + + private boolean notifyPlaylistError(final Uri playlistUrl, final long exclusionDurationMs) { + final int listenersSize = listeners.size(); + boolean anyExclusionFailed = false; + for (int i = 0; i < listenersSize; i++) { + anyExclusionFailed |= !listeners.get(i).onPlaylistError(playlistUrl, + exclusionDurationMs); + } + return anyExclusionFailed; + } + + private HlsMediaPlaylist getLatestPlaylistSnapshot( + @Nullable final HlsMediaPlaylist oldPlaylist, + @NonNull final HlsMediaPlaylist loadedPlaylist) { + if (!loadedPlaylist.isNewerThan(oldPlaylist)) { + if (loadedPlaylist.hasEndTag) { + // If the loaded playlist has an end tag but is not newer than the old playlist + // then we have an inconsistent state. This is typically caused by the server + // incorrectly resetting the media sequence when appending the end tag. We resolve + // this case as best we can by returning the old playlist with the end tag + // appended. + return oldPlaylist.copyWithEndTag(); + } else { + return oldPlaylist; + } + } + final long startTimeUs = getLoadedPlaylistStartTimeUs(oldPlaylist, loadedPlaylist); + final int discontinuitySequence = getLoadedPlaylistDiscontinuitySequence(oldPlaylist, + loadedPlaylist); + return loadedPlaylist.copyWith(startTimeUs, discontinuitySequence); + } + + private long getLoadedPlaylistStartTimeUs(@Nullable final HlsMediaPlaylist oldPlaylist, + @NonNull final HlsMediaPlaylist loadedPlaylist) { + if (loadedPlaylist.hasProgramDateTime) { + return loadedPlaylist.startTimeUs; + } + final long primarySnapshotStartTimeUs = primaryMediaPlaylistSnapshot != null + ? primaryMediaPlaylistSnapshot.startTimeUs : 0; + if (oldPlaylist == null) { + return primarySnapshotStartTimeUs; + } + final Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, + loadedPlaylist); + if (firstOldOverlappingSegment != null) { + return oldPlaylist.startTimeUs + firstOldOverlappingSegment.relativeStartTimeUs; + } else if (oldPlaylist.segments.size() == loadedPlaylist.mediaSequence + - oldPlaylist.mediaSequence) { + return oldPlaylist.getEndTimeUs(); + } else { + // No segments overlap, we assume the new playlist start coincides with the primary + // playlist. + return primarySnapshotStartTimeUs; + } + } + + private int getLoadedPlaylistDiscontinuitySequence( + @Nullable final HlsMediaPlaylist oldPlaylist, + @NonNull final HlsMediaPlaylist loadedPlaylist) { + if (loadedPlaylist.hasDiscontinuitySequence) { + return loadedPlaylist.discontinuitySequence; + } + // TODO: Improve cross-playlist discontinuity adjustment. + final int primaryUrlDiscontinuitySequence = primaryMediaPlaylistSnapshot != null + ? primaryMediaPlaylistSnapshot.discontinuitySequence : 0; + if (oldPlaylist == null) { + return primaryUrlDiscontinuitySequence; + } + final Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, + loadedPlaylist); + if (firstOldOverlappingSegment != null) { + return oldPlaylist.discontinuitySequence + + firstOldOverlappingSegment.relativeDiscontinuitySequence + - loadedPlaylist.segments.get(0).relativeDiscontinuitySequence; + } + return primaryUrlDiscontinuitySequence; + } + + @Nullable + private static Segment getFirstOldOverlappingSegment( + @NonNull final HlsMediaPlaylist oldPlaylist, + @NonNull final HlsMediaPlaylist loadedPlaylist) { + final int mediaSequenceOffset = (int) (loadedPlaylist.mediaSequence + - oldPlaylist.mediaSequence); + final List oldSegments = oldPlaylist.segments; + return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset) + : null; + } + + /** + * Hold all information related to a specific Media Playlist. + */ + private final class MediaPlaylistBundle + implements Loader.Callback> { + + private static final String BLOCK_MSN_PARAM = "_HLS_msn"; + private static final String BLOCK_PART_PARAM = "_HLS_part"; + private static final String SKIP_PARAM = "_HLS_skip"; + + private final Uri playlistUrl; + private final Loader mediaPlaylistLoader; + private final DataSource mediaPlaylistDataSource; + + @Nullable + private HlsMediaPlaylist playlistSnapshot; + private long lastSnapshotLoadMs; + private long lastSnapshotChangeMs; + private long earliestNextLoadTimeMs; + private long excludeUntilMs; + private boolean loadPending; + @Nullable + private IOException playlistError; + + MediaPlaylistBundle(final Uri playlistUrl) { + this.playlistUrl = playlistUrl; + mediaPlaylistLoader = new Loader("CustomHlsPlaylistTracker:MediaPlaylist"); + mediaPlaylistDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST); + } + + @Nullable + public HlsMediaPlaylist getPlaylistSnapshot() { + return playlistSnapshot; + } + + public boolean isSnapshotValid() { + if (playlistSnapshot == null) { + return false; + } + final long currentTimeMs = SystemClock.elapsedRealtime(); + final long snapshotValidityDurationMs = max(30000, C.usToMs( + playlistSnapshot.durationUs)); + return playlistSnapshot.hasEndTag + || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT + || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD + || lastSnapshotLoadMs + snapshotValidityDurationMs > currentTimeMs; + } + + public void loadPlaylist() { + loadPlaylistInternal(playlistUrl); + } + + public void maybeThrowPlaylistRefreshError() throws IOException { + mediaPlaylistLoader.maybeThrowError(); + if (playlistError != null) { + throw playlistError; + } + } + + public void release() { + mediaPlaylistLoader.release(); + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(@NonNull final ParsingLoadable loadable, + final long elapsedRealtimeMs, + final long loadDurationMs) { + final HlsPlaylist result = loadable.getResult(); + final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId, + loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(), + elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + if (result instanceof HlsMediaPlaylist) { + processLoadedPlaylist((HlsMediaPlaylist) result, loadEventInfo); + eventDispatcher.loadCompleted(loadEventInfo, C.DATA_TYPE_MANIFEST); + } else { + playlistError = new ParserException("Loaded playlist has unexpected type."); + eventDispatcher.loadError( + loadEventInfo, C.DATA_TYPE_MANIFEST, playlistError, true); + } + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } + + @Override + public void onLoadCanceled(@NonNull final ParsingLoadable loadable, + final long elapsedRealtimeMs, + final long loadDurationMs, + final boolean released) { + final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId, + loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(), + elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + eventDispatcher.loadCanceled(loadEventInfo, C.DATA_TYPE_MANIFEST); + } + + @Override + public LoadErrorAction onLoadError(@NonNull final ParsingLoadable loadable, + final long elapsedRealtimeMs, + final long loadDurationMs, + final IOException error, + final int errorCount) { + final LoadEventInfo loadEventInfo = new LoadEventInfo(loadable.loadTaskId, + loadable.dataSpec, loadable.getUri(), loadable.getResponseHeaders(), + elapsedRealtimeMs, loadDurationMs, loadable.bytesLoaded()); + final boolean isBlockingRequest = loadable.getUri().getQueryParameter(BLOCK_MSN_PARAM) + != null; + final boolean deltaUpdateFailed = error instanceof HlsPlaylistParser + .DeltaUpdateException; + if (isBlockingRequest || deltaUpdateFailed) { + int responseCode = Integer.MAX_VALUE; + if (error instanceof HttpDataSource.InvalidResponseCodeException) { + responseCode = ((HttpDataSource.InvalidResponseCodeException) error) + .responseCode; + } + if (deltaUpdateFailed || responseCode == 400 || responseCode == 503) { + // Intercept failed delta updates and blocking requests producing a Bad Request + // (400) and Service Unavailable (503). In such cases, force a full, + // non-blocking request (see RFC 8216, section 6.2.5.2 and 6.3.7). + earliestNextLoadTimeMs = SystemClock.elapsedRealtime(); + loadPlaylist(); + castNonNull(eventDispatcher).loadError(loadEventInfo, loadable.type, error, + true); + return Loader.DONT_RETRY; + } + } + final MediaLoadData mediaLoadData = new MediaLoadData(loadable.type); + final LoadErrorInfo loadErrorInfo = new LoadErrorInfo(loadEventInfo, mediaLoadData, + error, errorCount); + final LoadErrorAction loadErrorAction; + final long exclusionDurationMs = loadErrorHandlingPolicy.getBlacklistDurationMsFor( + loadErrorInfo); + final boolean shouldExclude = exclusionDurationMs != C.TIME_UNSET; + + boolean exclusionFailed = notifyPlaylistError(playlistUrl, exclusionDurationMs) + || !shouldExclude; + if (shouldExclude) { + exclusionFailed |= excludePlaylist(exclusionDurationMs); + } + + if (exclusionFailed) { + final long retryDelay = loadErrorHandlingPolicy.getRetryDelayMsFor(loadErrorInfo); + loadErrorAction = retryDelay != C.TIME_UNSET + ? Loader.createRetryAction(false, retryDelay) + : Loader.DONT_RETRY_FATAL; + } else { + loadErrorAction = Loader.DONT_RETRY; + } + + final boolean wasCanceled = !loadErrorAction.isRetry(); + eventDispatcher.loadError(loadEventInfo, loadable.type, error, wasCanceled); + if (wasCanceled) { + loadErrorHandlingPolicy.onLoadTaskConcluded(loadable.loadTaskId); + } + return loadErrorAction; + } + + // Internal methods. + + private void loadPlaylistInternal(@NonNull final Uri playlistRequestUri) { + excludeUntilMs = 0; + if (loadPending || mediaPlaylistLoader.isLoading() + || mediaPlaylistLoader.hasFatalError()) { + // Load already pending, in progress, or a fatal error has been encountered. Do + // nothing. + return; + } + final long currentTimeMs = SystemClock.elapsedRealtime(); + if (currentTimeMs < earliestNextLoadTimeMs) { + loadPending = true; + playlistRefreshHandler.postDelayed( + () -> { + loadPending = false; + loadPlaylistImmediately(playlistRequestUri); + }, + earliestNextLoadTimeMs - currentTimeMs); + } else { + loadPlaylistImmediately(playlistRequestUri); + } + } + + private void loadPlaylistImmediately(@NonNull final Uri playlistRequestUri) { + final ParsingLoadable.Parser mediaPlaylistParser = playlistParserFactory + .createPlaylistParser(masterPlaylist, playlistSnapshot); + final ParsingLoadable mediaPlaylistLoadable = new ParsingLoadable<>( + mediaPlaylistDataSource, playlistRequestUri, C.DATA_TYPE_MANIFEST, + mediaPlaylistParser); + final long elapsedRealtime = mediaPlaylistLoader.startLoading(mediaPlaylistLoadable, + this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount( + mediaPlaylistLoadable.type)); + eventDispatcher.loadStarted(new LoadEventInfo(mediaPlaylistLoadable.loadTaskId, + mediaPlaylistLoadable.dataSpec, elapsedRealtime), + mediaPlaylistLoadable.type); + } + + private void processLoadedPlaylist(final HlsMediaPlaylist loadedPlaylist, + final LoadEventInfo loadEventInfo) { + final HlsMediaPlaylist oldPlaylist = playlistSnapshot; + final long currentTimeMs = SystemClock.elapsedRealtime(); + lastSnapshotLoadMs = currentTimeMs; + playlistSnapshot = getLatestPlaylistSnapshot(oldPlaylist, loadedPlaylist); + if (playlistSnapshot != oldPlaylist) { + playlistError = null; + lastSnapshotChangeMs = currentTimeMs; + onPlaylistUpdated(playlistUrl, playlistSnapshot); + } else if (!playlistSnapshot.hasEndTag) { + if (loadedPlaylist.mediaSequence + loadedPlaylist.segments.size() + < playlistSnapshot.mediaSequence) { + // TODO: Allow customization of playlist resets handling. + // The media sequence jumped backwards. The server has probably reset. We do + // not try excluding in this case. + playlistError = new PlaylistResetException(playlistUrl); + notifyPlaylistError(playlistUrl, C.TIME_UNSET); + } else if (currentTimeMs - lastSnapshotChangeMs + > MAXIMUM_PLAYLIST_STUCK_DURATION_MS) { + // TODO: Allow customization of stuck playlists handling. + playlistError = new PlaylistStuckException(playlistUrl); + final LoadErrorInfo loadErrorInfo = new LoadErrorInfo(loadEventInfo, + new MediaLoadData(C.DATA_TYPE_MANIFEST), + playlistError, 1); + final long exclusionDurationMs = loadErrorHandlingPolicy + .getBlacklistDurationMsFor(loadErrorInfo); + notifyPlaylistError(playlistUrl, exclusionDurationMs); + if (exclusionDurationMs != C.TIME_UNSET) { + excludePlaylist(exclusionDurationMs); + } + } + } + long durationUntilNextLoadUs = 0L; + if (!playlistSnapshot.serverControl.canBlockReload) { + // If blocking requests are not supported, do not allow the playlist to load again + // within the target duration if we obtained a new snapshot, or half the target + // duration otherwise. + durationUntilNextLoadUs = playlistSnapshot != oldPlaylist + ? playlistSnapshot.targetDurationUs + : (playlistSnapshot.targetDurationUs / 2); + } + earliestNextLoadTimeMs = currentTimeMs + C.usToMs(durationUntilNextLoadUs); + // Schedule a load if this is the primary playlist or a playlist of a low-latency + // stream and it doesn't have an end tag. Else the next load will be scheduled when + // refreshPlaylist is called, or when this playlist becomes the primary. + final boolean scheduleLoad = playlistSnapshot.partTargetDurationUs != C.TIME_UNSET + || playlistUrl.equals(primaryMediaPlaylistUrl); + if (scheduleLoad && !playlistSnapshot.hasEndTag) { + loadPlaylistInternal(getMediaPlaylistUriForReload()); + } + } + + private Uri getMediaPlaylistUriForReload() { + if (playlistSnapshot == null + || (playlistSnapshot.serverControl.skipUntilUs == C.TIME_UNSET + && !playlistSnapshot.serverControl.canBlockReload)) { + return playlistUrl; + } + final Uri.Builder uriBuilder = playlistUrl.buildUpon(); + if (playlistSnapshot.serverControl.canBlockReload) { + final long targetMediaSequence = playlistSnapshot.mediaSequence + + playlistSnapshot.segments.size(); + uriBuilder.appendQueryParameter(BLOCK_MSN_PARAM, String.valueOf( + targetMediaSequence)); + if (playlistSnapshot.partTargetDurationUs != C.TIME_UNSET) { + final List trailingParts = playlistSnapshot.trailingParts; + int targetPartIndex = trailingParts.size(); + if (!trailingParts.isEmpty() && Iterables.getLast(trailingParts).isPreload) { + // Ignore the preload part. + targetPartIndex--; + } + uriBuilder.appendQueryParameter(BLOCK_PART_PARAM, String.valueOf( + targetPartIndex)); + } + } + if (playlistSnapshot.serverControl.skipUntilUs != C.TIME_UNSET) { + uriBuilder.appendQueryParameter(SKIP_PARAM, + playlistSnapshot.serverControl.canSkipDateRanges ? "v2" : "YES"); + } + return uriBuilder.build(); + } + + /** + * Exclude the playlist. + * + * @param exclusionDurationMs The number of milliseconds for which the playlist should be + * excluded. + * @return Whether the playlist is the primary, despite being excluded. + */ + private boolean excludePlaylist(final long exclusionDurationMs) { + excludeUntilMs = SystemClock.elapsedRealtime() + exclusionDurationMs; + return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl(); + } + } +}