diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist')
10 files changed, 2811 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java new file mode 100644 index 0000000000..394a97a56a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; + +/** Default implementation for {@link HlsPlaylistParserFactory}. */ +public final class DefaultHlsPlaylistParserFactory implements HlsPlaylistParserFactory { + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() { + return new HlsPlaylistParser(); + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser( + HlsMasterPlaylist masterPlaylist) { + return new HlsPlaylistParser(masterPlaylist); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java new file mode 100644 index 0000000000..b7f6a06975 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -0,0 +1,678 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import android.os.Handler; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** Default implementation for {@link HlsPlaylistTracker}. */ +public final class DefaultHlsPlaylistTracker + implements HlsPlaylistTracker, Loader.Callback<ParsingLoadable<HlsPlaylist>> { + + /** Factory for {@link DefaultHlsPlaylistTracker} instances. */ + public static final Factory FACTORY = DefaultHlsPlaylistTracker::new; + + /** + * Default coefficient applied on the target duration of a playlist to determine the amount of + * time after which an unchanging playlist is considered stuck. + */ + public static final double DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 3.5; + + private final HlsDataSourceFactory dataSourceFactory; + private final HlsPlaylistParserFactory playlistParserFactory; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final HashMap<Uri, MediaPlaylistBundle> playlistBundles; + private final List<PlaylistEventListener> listeners; + private final double playlistStuckTargetDurationCoefficient; + + @Nullable private ParsingLoadable.Parser<HlsPlaylist> mediaPlaylistParser; + @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 DefaultHlsPlaylistTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory) { + this( + dataSourceFactory, + loadErrorHandlingPolicy, + playlistParserFactory, + DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT); + } + + /** + * Creates an instance. + * + * @param dataSourceFactory A factory for {@link DataSource} instances. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param playlistParserFactory An {@link HlsPlaylistParserFactory}. + * @param playlistStuckTargetDurationCoefficient A coefficient to apply to the target duration of + * media playlists in order to determine that a non-changing playlist is stuck. Once a + * playlist is deemed stuck, a {@link PlaylistStuckException} is thrown via {@link + * #maybeThrowPlaylistRefreshError(Uri)}. + */ + public DefaultHlsPlaylistTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory, + double playlistStuckTargetDurationCoefficient) { + this.dataSourceFactory = dataSourceFactory; + this.playlistParserFactory = playlistParserFactory; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.playlistStuckTargetDurationCoefficient = playlistStuckTargetDurationCoefficient; + listeners = new ArrayList<>(); + playlistBundles = new HashMap<>(); + initialStartTimeUs = C.TIME_UNSET; + } + + // HlsPlaylistTracker implementation. + + @Override + public void start( + Uri initialPlaylistUri, + EventDispatcher eventDispatcher, + PrimaryPlaylistListener primaryPlaylistListener) { + this.playlistRefreshHandler = new Handler(); + this.eventDispatcher = eventDispatcher; + this.primaryPlaylistListener = primaryPlaylistListener; + ParsingLoadable<HlsPlaylist> masterPlaylistLoadable = + new ParsingLoadable<>( + dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST), + initialPlaylistUri, + C.DATA_TYPE_MANIFEST, + playlistParserFactory.createPlaylistParser()); + Assertions.checkState(initialPlaylistLoader == null); + initialPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MasterPlaylist"); + long elapsedRealtime = + initialPlaylistLoader.startLoading( + masterPlaylistLoadable, + this, + loadErrorHandlingPolicy.getMinimumLoadableRetryCount(masterPlaylistLoadable.type)); + eventDispatcher.loadStarted( + masterPlaylistLoadable.dataSpec, + masterPlaylistLoadable.type, + elapsedRealtime); + } + + @Override + public void stop() { + primaryMediaPlaylistUrl = null; + primaryMediaPlaylistSnapshot = null; + masterPlaylist = null; + initialStartTimeUs = C.TIME_UNSET; + initialPlaylistLoader.release(); + initialPlaylistLoader = null; + for (MediaPlaylistBundle bundle : playlistBundles.values()) { + bundle.release(); + } + playlistRefreshHandler.removeCallbacksAndMessages(null); + playlistRefreshHandler = null; + playlistBundles.clear(); + } + + @Override + public void addListener(PlaylistEventListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(PlaylistEventListener listener) { + listeners.remove(listener); + } + + @Override + @Nullable + public HlsMasterPlaylist getMasterPlaylist() { + return masterPlaylist; + } + + @Override + @Nullable + public HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback) { + HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot(); + if (snapshot != null && isForPlayback) { + maybeSetPrimaryUrl(url); + } + return snapshot; + } + + @Override + public long getInitialStartTimeUs() { + return initialStartTimeUs; + } + + @Override + public boolean isSnapshotValid(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(Uri url) throws IOException { + playlistBundles.get(url).maybeThrowPlaylistRefreshError(); + } + + @Override + public void refreshPlaylist(Uri url) { + playlistBundles.get(url).loadPlaylist(); + } + + @Override + public boolean isLive() { + return isLive; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted( + ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) { + HlsPlaylist result = loadable.getResult(); + HlsMasterPlaylist masterPlaylist; + boolean isMediaPlaylist = result instanceof HlsMediaPlaylist; + if (isMediaPlaylist) { + masterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist(result.baseUri); + } else /* result instanceof HlsMasterPlaylist */ { + masterPlaylist = (HlsMasterPlaylist) result; + } + this.masterPlaylist = masterPlaylist; + mediaPlaylistParser = playlistParserFactory.createPlaylistParser(masterPlaylist); + primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url; + createBundles(masterPlaylist.mediaPlaylistUrls); + 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, loadDurationMs); + } else { + primaryBundle.loadPlaylist(); + } + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public void onLoadCanceled( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public LoadErrorAction onLoadError( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + boolean isFatal = retryDelayMs == C.TIME_UNSET; + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded(), + error, + isFatal); + return isFatal + ? Loader.DONT_RETRY_FATAL + : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs); + } + + // Internal methods. + + private boolean maybeSelectNewPrimaryUrl() { + List<Variant> variants = masterPlaylist.variants; + int variantsSize = variants.size(); + long currentTimeMs = SystemClock.elapsedRealtime(); + for (int i = 0; i < variantsSize; i++) { + MediaPlaylistBundle bundle = playlistBundles.get(variants.get(i).url); + if (currentTimeMs > bundle.blacklistUntilMs) { + primaryMediaPlaylistUrl = bundle.playlistUrl; + bundle.loadPlaylist(); + return true; + } + } + return false; + } + + private void maybeSetPrimaryUrl(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; + playlistBundles.get(primaryMediaPlaylistUrl).loadPlaylist(); + } + + /** Returns whether any of the variants in the master playlist have the specified playlist URL. */ + private boolean isVariantUrl(Uri playlistUrl) { + List<Variant> variants = masterPlaylist.variants; + for (int i = 0; i < variants.size(); i++) { + if (playlistUrl.equals(variants.get(i).url)) { + return true; + } + } + return false; + } + + private void createBundles(List<Uri> urls) { + int listSize = urls.size(); + for (int i = 0; i < listSize; i++) { + Uri url = urls.get(i); + 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(Uri url, 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); + } + int listenersSize = listeners.size(); + for (int i = 0; i < listenersSize; i++) { + listeners.get(i).onPlaylistChanged(); + } + } + + private boolean notifyPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + int listenersSize = listeners.size(); + boolean anyBlacklistingFailed = false; + for (int i = 0; i < listenersSize; i++) { + anyBlacklistingFailed |= !listeners.get(i).onPlaylistError(playlistUrl, blacklistDurationMs); + } + return anyBlacklistingFailed; + } + + private HlsMediaPlaylist getLatestPlaylistSnapshot( + HlsMediaPlaylist oldPlaylist, 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; + } + } + long startTimeUs = getLoadedPlaylistStartTimeUs(oldPlaylist, loadedPlaylist); + int discontinuitySequence = getLoadedPlaylistDiscontinuitySequence(oldPlaylist, loadedPlaylist); + return loadedPlaylist.copyWith(startTimeUs, discontinuitySequence); + } + + private long getLoadedPlaylistStartTimeUs( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + if (loadedPlaylist.hasProgramDateTime) { + return loadedPlaylist.startTimeUs; + } + long primarySnapshotStartTimeUs = + primaryMediaPlaylistSnapshot != null ? primaryMediaPlaylistSnapshot.startTimeUs : 0; + if (oldPlaylist == null) { + return primarySnapshotStartTimeUs; + } + int oldPlaylistSize = oldPlaylist.segments.size(); + Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist); + if (firstOldOverlappingSegment != null) { + return oldPlaylist.startTimeUs + firstOldOverlappingSegment.relativeStartTimeUs; + } else if (oldPlaylistSize == 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( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + if (loadedPlaylist.hasDiscontinuitySequence) { + return loadedPlaylist.discontinuitySequence; + } + // TODO: Improve cross-playlist discontinuity adjustment. + int primaryUrlDiscontinuitySequence = + primaryMediaPlaylistSnapshot != null + ? primaryMediaPlaylistSnapshot.discontinuitySequence + : 0; + if (oldPlaylist == null) { + return primaryUrlDiscontinuitySequence; + } + Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist); + if (firstOldOverlappingSegment != null) { + return oldPlaylist.discontinuitySequence + + firstOldOverlappingSegment.relativeDiscontinuitySequence + - loadedPlaylist.segments.get(0).relativeDiscontinuitySequence; + } + return primaryUrlDiscontinuitySequence; + } + + private static Segment getFirstOldOverlappingSegment( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + int mediaSequenceOffset = (int) (loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence); + List<Segment> oldSegments = oldPlaylist.segments; + return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset) : null; + } + + /** Holds all information related to a specific Media Playlist. */ + private final class MediaPlaylistBundle + implements Loader.Callback<ParsingLoadable<HlsPlaylist>>, Runnable { + + private final Uri playlistUrl; + private final Loader mediaPlaylistLoader; + private final ParsingLoadable<HlsPlaylist> mediaPlaylistLoadable; + + @Nullable private HlsMediaPlaylist playlistSnapshot; + private long lastSnapshotLoadMs; + private long lastSnapshotChangeMs; + private long earliestNextLoadTimeMs; + private long blacklistUntilMs; + private boolean loadPending; + private IOException playlistError; + + public MediaPlaylistBundle(Uri playlistUrl) { + this.playlistUrl = playlistUrl; + mediaPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MediaPlaylist"); + mediaPlaylistLoadable = + new ParsingLoadable<>( + dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST), + playlistUrl, + C.DATA_TYPE_MANIFEST, + mediaPlaylistParser); + } + + @Nullable + public HlsMediaPlaylist getPlaylistSnapshot() { + return playlistSnapshot; + } + + public boolean isSnapshotValid() { + if (playlistSnapshot == null) { + return false; + } + long currentTimeMs = SystemClock.elapsedRealtime(); + long snapshotValidityDurationMs = Math.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 release() { + mediaPlaylistLoader.release(); + } + + public void loadPlaylist() { + blacklistUntilMs = 0; + if (loadPending || mediaPlaylistLoader.isLoading() || mediaPlaylistLoader.hasFatalError()) { + // Load already pending, in progress, or a fatal error has been encountered. Do nothing. + return; + } + long currentTimeMs = SystemClock.elapsedRealtime(); + if (currentTimeMs < earliestNextLoadTimeMs) { + loadPending = true; + playlistRefreshHandler.postDelayed(this, earliestNextLoadTimeMs - currentTimeMs); + } else { + loadPlaylistImmediately(); + } + } + + public void maybeThrowPlaylistRefreshError() throws IOException { + mediaPlaylistLoader.maybeThrowError(); + if (playlistError != null) { + throw playlistError; + } + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted( + ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) { + HlsPlaylist result = loadable.getResult(); + if (result instanceof HlsMediaPlaylist) { + processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } else { + playlistError = new ParserException("Loaded playlist has unexpected type."); + } + } + + @Override + public void onLoadCanceled( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public LoadErrorAction onLoadError( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + LoadErrorAction loadErrorAction; + + long blacklistDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor( + loadable.type, loadDurationMs, error, errorCount); + boolean shouldBlacklist = blacklistDurationMs != C.TIME_UNSET; + + boolean blacklistingFailed = + notifyPlaylistError(playlistUrl, blacklistDurationMs) || !shouldBlacklist; + if (shouldBlacklist) { + blacklistingFailed |= blacklistPlaylist(blacklistDurationMs); + } + + if (blacklistingFailed) { + long retryDelay = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + loadErrorAction = + retryDelay != C.TIME_UNSET + ? Loader.createRetryAction(false, retryDelay) + : Loader.DONT_RETRY_FATAL; + } else { + loadErrorAction = Loader.DONT_RETRY; + } + + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded(), + error, + /* wasCanceled= */ !loadErrorAction.isRetry()); + + return loadErrorAction; + } + + // Runnable implementation. + + @Override + public void run() { + loadPending = false; + loadPlaylistImmediately(); + } + + // Internal methods. + + private void loadPlaylistImmediately() { + long elapsedRealtime = + mediaPlaylistLoader.startLoading( + mediaPlaylistLoadable, + this, + loadErrorHandlingPolicy.getMinimumLoadableRetryCount(mediaPlaylistLoadable.type)); + eventDispatcher.loadStarted( + mediaPlaylistLoadable.dataSpec, + mediaPlaylistLoadable.type, + elapsedRealtime); + } + + private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDurationMs) { + HlsMediaPlaylist oldPlaylist = playlistSnapshot; + 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 + // blacklisting in this case. + playlistError = new PlaylistResetException(playlistUrl); + notifyPlaylistError(playlistUrl, C.TIME_UNSET); + } else if (currentTimeMs - lastSnapshotChangeMs + > C.usToMs(playlistSnapshot.targetDurationUs) + * playlistStuckTargetDurationCoefficient) { + // TODO: Allow customization of stuck playlists handling. + playlistError = new PlaylistStuckException(playlistUrl); + long blacklistDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor( + C.DATA_TYPE_MANIFEST, loadDurationMs, playlistError, /* errorCount= */ 1); + notifyPlaylistError(playlistUrl, blacklistDurationMs); + if (blacklistDurationMs != C.TIME_UNSET) { + blacklistPlaylist(blacklistDurationMs); + } + } + } + // Do not allow the playlist to load again within the target duration if we obtained a new + // snapshot, or half the target duration otherwise. + earliestNextLoadTimeMs = + currentTimeMs + + C.usToMs( + playlistSnapshot != oldPlaylist + ? playlistSnapshot.targetDurationUs + : (playlistSnapshot.targetDurationUs / 2)); + // Schedule a load if this is the primary playlist 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. + if (playlistUrl.equals(primaryMediaPlaylistUrl) && !playlistSnapshot.hasEndTag) { + loadPlaylist(); + } + } + + /** + * Blacklists the playlist. + * + * @param blacklistDurationMs The number of milliseconds for which the playlist should be + * blacklisted. + * @return Whether the playlist is the primary, despite being blacklisted. + */ + private boolean blacklistPlaylist(long blacklistDurationMs) { + blacklistUntilMs = SystemClock.elapsedRealtime() + blacklistDurationMs; + return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java new file mode 100644 index 0000000000..a8c9ea1756 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.FilteringManifestParser; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import java.util.List; + +/** + * A {@link HlsPlaylistParserFactory} that includes only the streams identified by the given stream + * keys. + */ +public final class FilteringHlsPlaylistParserFactory implements HlsPlaylistParserFactory { + + private final HlsPlaylistParserFactory hlsPlaylistParserFactory; + private final List<StreamKey> streamKeys; + + /** + * @param hlsPlaylistParserFactory A factory for the parsers of the playlists which will be + * filtered. + * @param streamKeys The stream keys. If null or empty then filtering will not occur. + */ + public FilteringHlsPlaylistParserFactory( + HlsPlaylistParserFactory hlsPlaylistParserFactory, List<StreamKey> streamKeys) { + this.hlsPlaylistParserFactory = hlsPlaylistParserFactory; + this.streamKeys = streamKeys; + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() { + return new FilteringManifestParser<>( + hlsPlaylistParserFactory.createPlaylistParser(), streamKeys); + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser( + HlsMasterPlaylist masterPlaylist) { + return new FilteringManifestParser<>( + hlsPlaylistParserFactory.createPlaylistParser(masterPlaylist), streamKeys); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java new file mode 100644 index 0000000000..376f2b4301 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** Represents an HLS master playlist. */ +public final class HlsMasterPlaylist extends HlsPlaylist { + + /** Represents an empty master playlist, from which no attributes can be inherited. */ + public static final HlsMasterPlaylist EMPTY = + new HlsMasterPlaylist( + /* baseUri= */ "", + /* tags= */ Collections.emptyList(), + /* variants= */ Collections.emptyList(), + /* videos= */ Collections.emptyList(), + /* audios= */ Collections.emptyList(), + /* subtitles= */ Collections.emptyList(), + /* closedCaptions= */ Collections.emptyList(), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ Collections.emptyList(), + /* hasIndependentSegments= */ false, + /* variableDefinitions= */ Collections.emptyMap(), + /* sessionKeyDrmInitData= */ Collections.emptyList()); + + // These constants must not be changed because they are persisted in offline stream keys. + public static final int GROUP_INDEX_VARIANT = 0; + public static final int GROUP_INDEX_AUDIO = 1; + public static final int GROUP_INDEX_SUBTITLE = 2; + + /** A variant (i.e. an #EXT-X-STREAM-INF tag) in a master playlist. */ + public static final class Variant { + + /** The variant's url. */ + public final Uri url; + + /** Format information associated with this variant. */ + public final Format format; + + /** The video rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String videoGroupId; + + /** The audio rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String audioGroupId; + + /** The subtitle rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String subtitleGroupId; + + /** The caption rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String captionGroupId; + + /** + * @param url See {@link #url}. + * @param format See {@link #format}. + * @param videoGroupId See {@link #videoGroupId}. + * @param audioGroupId See {@link #audioGroupId}. + * @param subtitleGroupId See {@link #subtitleGroupId}. + * @param captionGroupId See {@link #captionGroupId}. + */ + public Variant( + Uri url, + Format format, + @Nullable String videoGroupId, + @Nullable String audioGroupId, + @Nullable String subtitleGroupId, + @Nullable String captionGroupId) { + this.url = url; + this.format = format; + this.videoGroupId = videoGroupId; + this.audioGroupId = audioGroupId; + this.subtitleGroupId = subtitleGroupId; + this.captionGroupId = captionGroupId; + } + + /** + * Creates a variant for a given media playlist url. + * + * @param url The media playlist url. + * @return The variant instance. + */ + public static Variant createMediaPlaylistVariantUrl(Uri url) { + Format format = + Format.createContainerFormat( + "0", + /* label= */ null, + MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + /* roleFlags= */ 0, + /* language= */ null); + return new Variant( + url, + format, + /* videoGroupId= */ null, + /* audioGroupId= */ null, + /* subtitleGroupId= */ null, + /* captionGroupId= */ null); + } + + /** Returns a copy of this instance with the given {@link Format}. */ + public Variant copyWithFormat(Format format) { + return new Variant(url, format, videoGroupId, audioGroupId, subtitleGroupId, captionGroupId); + } + } + + /** A rendition (i.e. an #EXT-X-MEDIA tag) in a master playlist. */ + public static final class Rendition { + + /** The rendition's url, or null if the tag does not have a URI attribute. */ + @Nullable public final Uri url; + + /** Format information associated with this rendition. */ + public final Format format; + + /** The group to which this rendition belongs. */ + public final String groupId; + + /** The name of the rendition. */ + public final String name; + + /** + * @param url See {@link #url}. + * @param format See {@link #format}. + * @param groupId See {@link #groupId}. + * @param name See {@link #name}. + */ + public Rendition(@Nullable Uri url, Format format, String groupId, String name) { + this.url = url; + this.format = format; + this.groupId = groupId; + this.name = name; + } + + } + + /** All of the media playlist URLs referenced by the playlist. */ + public final List<Uri> mediaPlaylistUrls; + /** The variants declared by the playlist. */ + public final List<Variant> variants; + /** The video renditions declared by the playlist. */ + public final List<Rendition> videos; + /** The audio renditions declared by the playlist. */ + public final List<Rendition> audios; + /** The subtitle renditions declared by the playlist. */ + public final List<Rendition> subtitles; + /** The closed caption renditions declared by the playlist. */ + public final List<Rendition> closedCaptions; + + /** + * The format of the audio muxed in the variants. May be null if the playlist does not declare any + * muxed audio. + */ + @Nullable public final Format muxedAudioFormat; + /** + * The format of the closed captions declared by the playlist. May be empty if the playlist + * explicitly declares no captions are available, or null if the playlist does not declare any + * captions information. + */ + @Nullable public final List<Format> muxedCaptionFormats; + /** Contains variable definitions, as defined by the #EXT-X-DEFINE tag. */ + public final Map<String, String> variableDefinitions; + /** DRM initialization data derived from #EXT-X-SESSION-KEY tags. */ + public final List<DrmInitData> sessionKeyDrmInitData; + + /** + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param variants See {@link #variants}. + * @param videos See {@link #videos}. + * @param audios See {@link #audios}. + * @param subtitles See {@link #subtitles}. + * @param closedCaptions See {@link #closedCaptions}. + * @param muxedAudioFormat See {@link #muxedAudioFormat}. + * @param muxedCaptionFormats See {@link #muxedCaptionFormats}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + * @param variableDefinitions See {@link #variableDefinitions}. + * @param sessionKeyDrmInitData See {@link #sessionKeyDrmInitData}. + */ + public HlsMasterPlaylist( + String baseUri, + List<String> tags, + List<Variant> variants, + List<Rendition> videos, + List<Rendition> audios, + List<Rendition> subtitles, + List<Rendition> closedCaptions, + @Nullable Format muxedAudioFormat, + @Nullable List<Format> muxedCaptionFormats, + boolean hasIndependentSegments, + Map<String, String> variableDefinitions, + List<DrmInitData> sessionKeyDrmInitData) { + super(baseUri, tags, hasIndependentSegments); + this.mediaPlaylistUrls = + Collections.unmodifiableList( + getMediaPlaylistUrls(variants, videos, audios, subtitles, closedCaptions)); + this.variants = Collections.unmodifiableList(variants); + this.videos = Collections.unmodifiableList(videos); + this.audios = Collections.unmodifiableList(audios); + this.subtitles = Collections.unmodifiableList(subtitles); + this.closedCaptions = Collections.unmodifiableList(closedCaptions); + this.muxedAudioFormat = muxedAudioFormat; + this.muxedCaptionFormats = muxedCaptionFormats != null + ? Collections.unmodifiableList(muxedCaptionFormats) : null; + this.variableDefinitions = Collections.unmodifiableMap(variableDefinitions); + this.sessionKeyDrmInitData = Collections.unmodifiableList(sessionKeyDrmInitData); + } + + @Override + public HlsMasterPlaylist copy(List<StreamKey> streamKeys) { + return new HlsMasterPlaylist( + baseUri, + tags, + copyStreams(variants, GROUP_INDEX_VARIANT, streamKeys), + // TODO: Allow stream keys to specify video renditions to be retained. + /* videos= */ Collections.emptyList(), + copyStreams(audios, GROUP_INDEX_AUDIO, streamKeys), + copyStreams(subtitles, GROUP_INDEX_SUBTITLE, streamKeys), + // TODO: Update to retain all closed captions. + /* closedCaptions= */ Collections.emptyList(), + muxedAudioFormat, + muxedCaptionFormats, + hasIndependentSegments, + variableDefinitions, + sessionKeyDrmInitData); + } + + /** + * Creates a playlist with a single variant. + * + * @param variantUrl The url of the single variant. + * @return A master playlist with a single variant for the provided url. + */ + public static HlsMasterPlaylist createSingleVariantMasterPlaylist(String variantUrl) { + List<Variant> variant = + Collections.singletonList(Variant.createMediaPlaylistVariantUrl(Uri.parse(variantUrl))); + return new HlsMasterPlaylist( + /* baseUri= */ "", + /* tags= */ Collections.emptyList(), + variant, + /* videos= */ Collections.emptyList(), + /* audios= */ Collections.emptyList(), + /* subtitles= */ Collections.emptyList(), + /* closedCaptions= */ Collections.emptyList(), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ null, + /* hasIndependentSegments= */ false, + /* variableDefinitions= */ Collections.emptyMap(), + /* sessionKeyDrmInitData= */ Collections.emptyList()); + } + + private static List<Uri> getMediaPlaylistUrls( + List<Variant> variants, + List<Rendition> videos, + List<Rendition> audios, + List<Rendition> subtitles, + List<Rendition> closedCaptions) { + ArrayList<Uri> mediaPlaylistUrls = new ArrayList<>(); + for (int i = 0; i < variants.size(); i++) { + Uri uri = variants.get(i).url; + if (!mediaPlaylistUrls.contains(uri)) { + mediaPlaylistUrls.add(uri); + } + } + addMediaPlaylistUrls(videos, mediaPlaylistUrls); + addMediaPlaylistUrls(audios, mediaPlaylistUrls); + addMediaPlaylistUrls(subtitles, mediaPlaylistUrls); + addMediaPlaylistUrls(closedCaptions, mediaPlaylistUrls); + return mediaPlaylistUrls; + } + + private static void addMediaPlaylistUrls(List<Rendition> renditions, List<Uri> out) { + for (int i = 0; i < renditions.size(); i++) { + Uri uri = renditions.get(i).url; + if (uri != null && !out.contains(uri)) { + out.add(uri); + } + } + } + + private static <T> List<T> copyStreams( + List<T> streams, int groupIndex, List<StreamKey> streamKeys) { + List<T> copiedStreams = new ArrayList<>(streamKeys.size()); + // TODO: + // 1. When variants with the same URL are not de-duplicated, duplicates must not increment + // trackIndex so as to avoid breaking stream keys that have been persisted for offline. All + // duplicates should be copied if the first variant is copied, or discarded otherwise. + // 2. When renditions with null URLs are permitted, they must not increment trackIndex so as to + // avoid breaking stream keys that have been persisted for offline. All renitions with null + // URLs should be copied. They may become unreachable if all variants that reference them are + // removed, but this is OK. + // 3. Renditions with URLs matching copied variants should always themselves be copied, even if + // the corresponding stream key is omitted. Else we're throwing away information for no gain. + for (int i = 0; i < streams.size(); i++) { + T stream = streams.get(i); + for (int j = 0; j < streamKeys.size(); j++) { + StreamKey streamKey = streamKeys.get(j); + if (streamKey.groupIndex == groupIndex && streamKey.trackIndex == i) { + copiedStreams.add(stream); + break; + } + } + } + return copiedStreams; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java new file mode 100644 index 0000000000..c3250a5cc0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.List; + +/** Represents an HLS media playlist. */ +public final class HlsMediaPlaylist extends HlsPlaylist { + + /** Media segment reference. */ + @SuppressWarnings("ComparableType") + public static final class Segment implements Comparable<Long> { + + /** + * The url of the segment. + */ + public final String url; + /** + * The media initialization section for this segment, as defined by #EXT-X-MAP. May be null if + * the media playlist does not define a media section for this segment. The same instance is + * used for all segments that share an EXT-X-MAP tag. + */ + @Nullable public final Segment initializationSegment; + /** The duration of the segment in microseconds, as defined by #EXTINF. */ + public final long durationUs; + /** The human readable title of the segment. */ + public final String title; + /** + * The number of #EXT-X-DISCONTINUITY tags in the playlist before the segment. + */ + public final int relativeDiscontinuitySequence; + /** + * The start time of the segment in microseconds, relative to the start of the playlist. + */ + public final long relativeStartTimeUs; + /** + * DRM initialization data for sample decryption, or null if the segment does not use CDM-DRM + * protection. + */ + @Nullable public final DrmInitData drmInitData; + /** + * The encryption identity key uri as defined by #EXT-X-KEY, or null if the segment does not use + * full segment encryption with identity key. + */ + @Nullable public final String fullSegmentEncryptionKeyUri; + /** + * The encryption initialization vector as defined by #EXT-X-KEY, or null if the segment is not + * encrypted. + */ + @Nullable public final String encryptionIV; + /** + * The segment's byte range offset, as defined by #EXT-X-BYTERANGE. + */ + public final long byterangeOffset; + /** + * The segment's byte range length, as defined by #EXT-X-BYTERANGE, or {@link C#LENGTH_UNSET} if + * no byte range is specified. + */ + public final long byterangeLength; + + /** Whether the segment is tagged with #EXT-X-GAP. */ + public final boolean hasGapTag; + + /** + * @param uri See {@link #url}. + * @param byterangeOffset See {@link #byterangeOffset}. + * @param byterangeLength See {@link #byterangeLength}. + * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}. + * @param encryptionIV See {@link #encryptionIV}. + */ + public Segment( + String uri, + long byterangeOffset, + long byterangeLength, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String encryptionIV) { + this( + uri, + /* initializationSegment= */ null, + /* title= */ "", + /* durationUs= */ 0, + /* relativeDiscontinuitySequence= */ -1, + /* relativeStartTimeUs= */ C.TIME_UNSET, + /* drmInitData= */ null, + fullSegmentEncryptionKeyUri, + encryptionIV, + byterangeOffset, + byterangeLength, + /* hasGapTag= */ false); + } + + /** + * @param url See {@link #url}. + * @param initializationSegment See {@link #initializationSegment}. + * @param title See {@link #title}. + * @param durationUs See {@link #durationUs}. + * @param relativeDiscontinuitySequence See {@link #relativeDiscontinuitySequence}. + * @param relativeStartTimeUs See {@link #relativeStartTimeUs}. + * @param drmInitData See {@link #drmInitData}. + * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}. + * @param encryptionIV See {@link #encryptionIV}. + * @param byterangeOffset See {@link #byterangeOffset}. + * @param byterangeLength See {@link #byterangeLength}. + * @param hasGapTag See {@link #hasGapTag}. + */ + public Segment( + String url, + @Nullable Segment initializationSegment, + String title, + long durationUs, + int relativeDiscontinuitySequence, + long relativeStartTimeUs, + @Nullable DrmInitData drmInitData, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String encryptionIV, + long byterangeOffset, + long byterangeLength, + boolean hasGapTag) { + this.url = url; + this.initializationSegment = initializationSegment; + this.title = title; + this.durationUs = durationUs; + this.relativeDiscontinuitySequence = relativeDiscontinuitySequence; + this.relativeStartTimeUs = relativeStartTimeUs; + this.drmInitData = drmInitData; + this.fullSegmentEncryptionKeyUri = fullSegmentEncryptionKeyUri; + this.encryptionIV = encryptionIV; + this.byterangeOffset = byterangeOffset; + this.byterangeLength = byterangeLength; + this.hasGapTag = hasGapTag; + } + + @Override + public int compareTo(Long relativeStartTimeUs) { + return this.relativeStartTimeUs > relativeStartTimeUs + ? 1 : (this.relativeStartTimeUs < relativeStartTimeUs ? -1 : 0); + } + + } + + /** + * Type of the playlist, as defined by #EXT-X-PLAYLIST-TYPE. One of {@link + * #PLAYLIST_TYPE_UNKNOWN}, {@link #PLAYLIST_TYPE_VOD} or {@link #PLAYLIST_TYPE_EVENT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({PLAYLIST_TYPE_UNKNOWN, PLAYLIST_TYPE_VOD, PLAYLIST_TYPE_EVENT}) + public @interface PlaylistType {} + + public static final int PLAYLIST_TYPE_UNKNOWN = 0; + public static final int PLAYLIST_TYPE_VOD = 1; + public static final int PLAYLIST_TYPE_EVENT = 2; + + /** + * The type of the playlist. See {@link PlaylistType}. + */ + @PlaylistType public final int playlistType; + /** + * The start offset in microseconds, as defined by #EXT-X-START. + */ + public final long startOffsetUs; + /** + * If {@link #hasProgramDateTime} is true, contains the datetime as microseconds since epoch. + * Otherwise, contains the aggregated duration of removed segments up to this snapshot of the + * playlist. + */ + public final long startTimeUs; + /** + * Whether the playlist contains the #EXT-X-DISCONTINUITY-SEQUENCE tag. + */ + public final boolean hasDiscontinuitySequence; + /** + * The discontinuity sequence number of the first media segment in the playlist, as defined by + * #EXT-X-DISCONTINUITY-SEQUENCE. + */ + public final int discontinuitySequence; + /** + * The media sequence number of the first media segment in the playlist, as defined by + * #EXT-X-MEDIA-SEQUENCE. + */ + public final long mediaSequence; + /** + * The compatibility version, as defined by #EXT-X-VERSION. + */ + public final int version; + /** + * The target duration in microseconds, as defined by #EXT-X-TARGETDURATION. + */ + public final long targetDurationUs; + /** + * Whether the playlist contains the #EXT-X-ENDLIST tag. + */ + public final boolean hasEndTag; + /** + * Whether the playlist contains a #EXT-X-PROGRAM-DATE-TIME tag. + */ + public final boolean hasProgramDateTime; + /** + * Contains the CDM protection schemes used by segments in this playlist. Does not contain any key + * acquisition data. Null if none of the segments in the playlist is CDM-encrypted. + */ + @Nullable public final DrmInitData protectionSchemes; + /** + * The list of segments in the playlist. + */ + public final List<Segment> segments; + /** + * The total duration of the playlist in microseconds. + */ + public final long durationUs; + + /** + * @param playlistType See {@link #playlistType}. + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param startOffsetUs See {@link #startOffsetUs}. + * @param startTimeUs See {@link #startTimeUs}. + * @param hasDiscontinuitySequence See {@link #hasDiscontinuitySequence}. + * @param discontinuitySequence See {@link #discontinuitySequence}. + * @param mediaSequence See {@link #mediaSequence}. + * @param version See {@link #version}. + * @param targetDurationUs See {@link #targetDurationUs}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + * @param hasEndTag See {@link #hasEndTag}. + * @param protectionSchemes See {@link #protectionSchemes}. + * @param hasProgramDateTime See {@link #hasProgramDateTime}. + * @param segments See {@link #segments}. + */ + public HlsMediaPlaylist( + @PlaylistType int playlistType, + String baseUri, + List<String> tags, + long startOffsetUs, + long startTimeUs, + boolean hasDiscontinuitySequence, + int discontinuitySequence, + long mediaSequence, + int version, + long targetDurationUs, + boolean hasIndependentSegments, + boolean hasEndTag, + boolean hasProgramDateTime, + @Nullable DrmInitData protectionSchemes, + List<Segment> segments) { + super(baseUri, tags, hasIndependentSegments); + this.playlistType = playlistType; + this.startTimeUs = startTimeUs; + this.hasDiscontinuitySequence = hasDiscontinuitySequence; + this.discontinuitySequence = discontinuitySequence; + this.mediaSequence = mediaSequence; + this.version = version; + this.targetDurationUs = targetDurationUs; + this.hasEndTag = hasEndTag; + this.hasProgramDateTime = hasProgramDateTime; + this.protectionSchemes = protectionSchemes; + this.segments = Collections.unmodifiableList(segments); + if (!segments.isEmpty()) { + Segment last = segments.get(segments.size() - 1); + durationUs = last.relativeStartTimeUs + last.durationUs; + } else { + durationUs = 0; + } + this.startOffsetUs = startOffsetUs == C.TIME_UNSET ? C.TIME_UNSET + : startOffsetUs >= 0 ? startOffsetUs : durationUs + startOffsetUs; + } + + @Override + public HlsMediaPlaylist copy(List<StreamKey> streamKeys) { + return this; + } + + /** + * Returns whether this playlist is newer than {@code other}. + * + * @param other The playlist to compare. + * @return Whether this playlist is newer than {@code other}. + */ + public boolean isNewerThan(HlsMediaPlaylist other) { + if (other == null || mediaSequence > other.mediaSequence) { + return true; + } + if (mediaSequence < other.mediaSequence) { + return false; + } + // The media sequences are equal. + int segmentCount = segments.size(); + int otherSegmentCount = other.segments.size(); + return segmentCount > otherSegmentCount + || (segmentCount == otherSegmentCount && hasEndTag && !other.hasEndTag); + } + + /** + * Returns the result of adding the duration of the playlist to its start time. + */ + public long getEndTimeUs() { + return startTimeUs + durationUs; + } + + /** + * Returns a playlist identical to this one except for the start time, the discontinuity sequence + * and {@code hasDiscontinuitySequence} values. The first two are set to the specified values, + * {@code hasDiscontinuitySequence} is set to true. + * + * @param startTimeUs The start time for the returned playlist. + * @param discontinuitySequence The discontinuity sequence for the returned playlist. + * @return An identical playlist including the provided discontinuity and timing information. + */ + public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) { + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + startTimeUs, + /* hasDiscontinuitySequence= */ true, + discontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegments, + hasEndTag, + hasProgramDateTime, + protectionSchemes, + segments); + } + + /** + * Returns a playlist identical to this one except that an end tag is added. If an end tag is + * already present then the playlist will return itself. + */ + public HlsMediaPlaylist copyWithEndTag() { + if (this.hasEndTag) { + return this; + } + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + startTimeUs, + hasDiscontinuitySequence, + discontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegments, + /* hasEndTag= */ true, + hasProgramDateTime, + protectionSchemes, + segments); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java new file mode 100644 index 0000000000..28f9b0eeb0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.FilterableManifest; +import java.util.Collections; +import java.util.List; + +/** Represents an HLS playlist. */ +public abstract class HlsPlaylist implements FilterableManifest<HlsPlaylist> { + + /** + * The base uri. Used to resolve relative paths. + */ + public final String baseUri; + /** + * The list of tags in the playlist. + */ + public final List<String> tags; + /** + * Whether the media is formed of independent segments, as defined by the + * #EXT-X-INDEPENDENT-SEGMENTS tag. + */ + public final boolean hasIndependentSegments; + + /** + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + */ + protected HlsPlaylist(String baseUri, List<String> tags, boolean hasIndependentSegments) { + this.baseUri = baseUri; + this.tags = Collections.unmodifiableList(tags); + this.hasIndependentSegments = hasIndependentSegments; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java new file mode 100644 index 0000000000..5495d28520 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -0,0 +1,1007 @@ +/* + * Copyright (C) 2016 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import android.text.TextUtils; +import android.util.Base64; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.UnrecognizedInputFormatException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry.VariantInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayDeque; +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.NoSuchElementException; +import java.util.Queue; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.PolyNull; + +/** + * HLS playlists parsing logic. + */ +public final class HlsPlaylistParser implements ParsingLoadable.Parser<HlsPlaylist> { + + private static final String PLAYLIST_HEADER = "#EXTM3U"; + + private static final String TAG_PREFIX = "#EXT"; + + private static final String TAG_VERSION = "#EXT-X-VERSION"; + private static final String TAG_PLAYLIST_TYPE = "#EXT-X-PLAYLIST-TYPE"; + private static final String TAG_DEFINE = "#EXT-X-DEFINE"; + private static final String TAG_STREAM_INF = "#EXT-X-STREAM-INF"; + private static final String TAG_MEDIA = "#EXT-X-MEDIA"; + private static final String TAG_TARGET_DURATION = "#EXT-X-TARGETDURATION"; + private static final String TAG_DISCONTINUITY = "#EXT-X-DISCONTINUITY"; + private static final String TAG_DISCONTINUITY_SEQUENCE = "#EXT-X-DISCONTINUITY-SEQUENCE"; + private static final String TAG_PROGRAM_DATE_TIME = "#EXT-X-PROGRAM-DATE-TIME"; + private static final String TAG_INIT_SEGMENT = "#EXT-X-MAP"; + private static final String TAG_INDEPENDENT_SEGMENTS = "#EXT-X-INDEPENDENT-SEGMENTS"; + private static final String TAG_MEDIA_DURATION = "#EXTINF"; + private static final String TAG_MEDIA_SEQUENCE = "#EXT-X-MEDIA-SEQUENCE"; + private static final String TAG_START = "#EXT-X-START"; + private static final String TAG_ENDLIST = "#EXT-X-ENDLIST"; + private static final String TAG_KEY = "#EXT-X-KEY"; + private static final String TAG_SESSION_KEY = "#EXT-X-SESSION-KEY"; + private static final String TAG_BYTERANGE = "#EXT-X-BYTERANGE"; + private static final String TAG_GAP = "#EXT-X-GAP"; + + private static final String TYPE_AUDIO = "AUDIO"; + private static final String TYPE_VIDEO = "VIDEO"; + private static final String TYPE_SUBTITLES = "SUBTITLES"; + private static final String TYPE_CLOSED_CAPTIONS = "CLOSED-CAPTIONS"; + + private static final String METHOD_NONE = "NONE"; + private static final String METHOD_AES_128 = "AES-128"; + private static final String METHOD_SAMPLE_AES = "SAMPLE-AES"; + // Replaced by METHOD_SAMPLE_AES_CTR. Keep for backward compatibility. + private static final String METHOD_SAMPLE_AES_CENC = "SAMPLE-AES-CENC"; + private static final String METHOD_SAMPLE_AES_CTR = "SAMPLE-AES-CTR"; + private static final String KEYFORMAT_PLAYREADY = "com.microsoft.playready"; + private static final String KEYFORMAT_IDENTITY = "identity"; + private static final String KEYFORMAT_WIDEVINE_PSSH_BINARY = + "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"; + private static final String KEYFORMAT_WIDEVINE_PSSH_JSON = "com.widevine"; + + private static final String BOOLEAN_TRUE = "YES"; + private static final String BOOLEAN_FALSE = "NO"; + + private static final String ATTR_CLOSED_CAPTIONS_NONE = "CLOSED-CAPTIONS=NONE"; + + private static final Pattern REGEX_AVERAGE_BANDWIDTH = + Pattern.compile("AVERAGE-BANDWIDTH=(\\d+)\\b"); + private static final Pattern REGEX_VIDEO = Pattern.compile("VIDEO=\"(.+?)\""); + private static final Pattern REGEX_AUDIO = Pattern.compile("AUDIO=\"(.+?)\""); + private static final Pattern REGEX_SUBTITLES = Pattern.compile("SUBTITLES=\"(.+?)\""); + private static final Pattern REGEX_CLOSED_CAPTIONS = Pattern.compile("CLOSED-CAPTIONS=\"(.+?)\""); + private static final Pattern REGEX_BANDWIDTH = Pattern.compile("[^-]BANDWIDTH=(\\d+)\\b"); + private static final Pattern REGEX_CHANNELS = Pattern.compile("CHANNELS=\"(.+?)\""); + private static final Pattern REGEX_CODECS = Pattern.compile("CODECS=\"(.+?)\""); + private static final Pattern REGEX_RESOLUTION = Pattern.compile("RESOLUTION=(\\d+x\\d+)"); + private static final Pattern REGEX_FRAME_RATE = Pattern.compile("FRAME-RATE=([\\d\\.]+)\\b"); + private static final Pattern REGEX_TARGET_DURATION = Pattern.compile(TAG_TARGET_DURATION + + ":(\\d+)\\b"); + private static final Pattern REGEX_VERSION = Pattern.compile(TAG_VERSION + ":(\\d+)\\b"); + private static final Pattern REGEX_PLAYLIST_TYPE = Pattern.compile(TAG_PLAYLIST_TYPE + + ":(.+)\\b"); + private static final Pattern REGEX_MEDIA_SEQUENCE = Pattern.compile(TAG_MEDIA_SEQUENCE + + ":(\\d+)\\b"); + private static final Pattern REGEX_MEDIA_DURATION = Pattern.compile(TAG_MEDIA_DURATION + + ":([\\d\\.]+)\\b"); + private static final Pattern REGEX_MEDIA_TITLE = + Pattern.compile(TAG_MEDIA_DURATION + ":[\\d\\.]+\\b,(.+)"); + private static final Pattern REGEX_TIME_OFFSET = Pattern.compile("TIME-OFFSET=(-?[\\d\\.]+)\\b"); + private static final Pattern REGEX_BYTERANGE = Pattern.compile(TAG_BYTERANGE + + ":(\\d+(?:@\\d+)?)\\b"); + private static final Pattern REGEX_ATTR_BYTERANGE = + Pattern.compile("BYTERANGE=\"(\\d+(?:@\\d+)?)\\b\""); + private static final Pattern REGEX_METHOD = + Pattern.compile( + "METHOD=(" + + METHOD_NONE + + "|" + + METHOD_AES_128 + + "|" + + METHOD_SAMPLE_AES + + "|" + + METHOD_SAMPLE_AES_CENC + + "|" + + METHOD_SAMPLE_AES_CTR + + ")" + + "\\s*(?:,|$)"); + private static final Pattern REGEX_KEYFORMAT = Pattern.compile("KEYFORMAT=\"(.+?)\""); + private static final Pattern REGEX_KEYFORMATVERSIONS = + Pattern.compile("KEYFORMATVERSIONS=\"(.+?)\""); + private static final Pattern REGEX_URI = Pattern.compile("URI=\"(.+?)\""); + private static final Pattern REGEX_IV = Pattern.compile("IV=([^,.*]+)"); + private static final Pattern REGEX_TYPE = Pattern.compile("TYPE=(" + TYPE_AUDIO + "|" + TYPE_VIDEO + + "|" + TYPE_SUBTITLES + "|" + TYPE_CLOSED_CAPTIONS + ")"); + private static final Pattern REGEX_LANGUAGE = Pattern.compile("LANGUAGE=\"(.+?)\""); + private static final Pattern REGEX_NAME = Pattern.compile("NAME=\"(.+?)\""); + private static final Pattern REGEX_GROUP_ID = Pattern.compile("GROUP-ID=\"(.+?)\""); + private static final Pattern REGEX_CHARACTERISTICS = Pattern.compile("CHARACTERISTICS=\"(.+?)\""); + private static final Pattern REGEX_INSTREAM_ID = + Pattern.compile("INSTREAM-ID=\"((?:CC|SERVICE)\\d+)\""); + private static final Pattern REGEX_AUTOSELECT = compileBooleanAttrPattern("AUTOSELECT"); + private static final Pattern REGEX_DEFAULT = compileBooleanAttrPattern("DEFAULT"); + private static final Pattern REGEX_FORCED = compileBooleanAttrPattern("FORCED"); + private static final Pattern REGEX_VALUE = Pattern.compile("VALUE=\"(.+?)\""); + private static final Pattern REGEX_IMPORT = Pattern.compile("IMPORT=\"(.+?)\""); + private static final Pattern REGEX_VARIABLE_REFERENCE = + Pattern.compile("\\{\\$([a-zA-Z0-9\\-_]+)\\}"); + + private final HlsMasterPlaylist masterPlaylist; + + /** + * Creates an instance where media playlists are parsed without inheriting attributes from a + * master playlist. + */ + public HlsPlaylistParser() { + this(HlsMasterPlaylist.EMPTY); + } + + /** + * Creates an instance where parsed media playlists inherit attributes from the given master + * playlist. + * + * @param masterPlaylist The master playlist from which media playlists will inherit attributes. + */ + public HlsPlaylistParser(HlsMasterPlaylist masterPlaylist) { + this.masterPlaylist = masterPlaylist; + } + + @Override + public HlsPlaylist parse(Uri uri, InputStream inputStream) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + Queue<String> extraLines = new ArrayDeque<>(); + String line; + try { + if (!checkPlaylistHeader(reader)) { + throw new UnrecognizedInputFormatException("Input does not start with the #EXTM3U header.", + uri); + } + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + // Do nothing. + } else if (line.startsWith(TAG_STREAM_INF)) { + extraLines.add(line); + return parseMasterPlaylist(new LineIterator(extraLines, reader), uri.toString()); + } else if (line.startsWith(TAG_TARGET_DURATION) + || line.startsWith(TAG_MEDIA_SEQUENCE) + || line.startsWith(TAG_MEDIA_DURATION) + || line.startsWith(TAG_KEY) + || line.startsWith(TAG_BYTERANGE) + || line.equals(TAG_DISCONTINUITY) + || line.equals(TAG_DISCONTINUITY_SEQUENCE) + || line.equals(TAG_ENDLIST)) { + extraLines.add(line); + return parseMediaPlaylist( + masterPlaylist, new LineIterator(extraLines, reader), uri.toString()); + } else { + extraLines.add(line); + } + } + } finally { + Util.closeQuietly(reader); + } + throw new ParserException("Failed to parse the playlist, could not identify any tags."); + } + + private static boolean checkPlaylistHeader(BufferedReader reader) throws IOException { + int last = reader.read(); + if (last == 0xEF) { + if (reader.read() != 0xBB || reader.read() != 0xBF) { + return false; + } + // The playlist contains a Byte Order Mark, which gets discarded. + last = reader.read(); + } + last = skipIgnorableWhitespace(reader, true, last); + int playlistHeaderLength = PLAYLIST_HEADER.length(); + for (int i = 0; i < playlistHeaderLength; i++) { + if (last != PLAYLIST_HEADER.charAt(i)) { + return false; + } + last = reader.read(); + } + last = skipIgnorableWhitespace(reader, false, last); + return Util.isLinebreak(last); + } + + private static int skipIgnorableWhitespace(BufferedReader reader, boolean skipLinebreaks, int c) + throws IOException { + while (c != -1 && Character.isWhitespace(c) && (skipLinebreaks || !Util.isLinebreak(c))) { + c = reader.read(); + } + return c; + } + + private static HlsMasterPlaylist parseMasterPlaylist(LineIterator iterator, String baseUri) + throws IOException { + HashMap<Uri, ArrayList<VariantInfo>> urlToVariantInfos = new HashMap<>(); + HashMap<String, String> variableDefinitions = new HashMap<>(); + ArrayList<Variant> variants = new ArrayList<>(); + ArrayList<Rendition> videos = new ArrayList<>(); + ArrayList<Rendition> audios = new ArrayList<>(); + ArrayList<Rendition> subtitles = new ArrayList<>(); + ArrayList<Rendition> closedCaptions = new ArrayList<>(); + ArrayList<String> mediaTags = new ArrayList<>(); + ArrayList<DrmInitData> sessionKeyDrmInitData = new ArrayList<>(); + ArrayList<String> tags = new ArrayList<>(); + Format muxedAudioFormat = null; + List<Format> muxedCaptionFormats = null; + boolean noClosedCaptions = false; + boolean hasIndependentSegmentsTag = false; + + String line; + while (iterator.hasNext()) { + line = iterator.next(); + + if (line.startsWith(TAG_PREFIX)) { + // We expose all tags through the playlist. + tags.add(line); + } + + if (line.startsWith(TAG_DEFINE)) { + variableDefinitions.put( + /* key= */ parseStringAttr(line, REGEX_NAME, variableDefinitions), + /* value= */ parseStringAttr(line, REGEX_VALUE, variableDefinitions)); + } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) { + hasIndependentSegmentsTag = true; + } else if (line.startsWith(TAG_MEDIA)) { + // Media tags are parsed at the end to include codec information from #EXT-X-STREAM-INF + // tags. + mediaTags.add(line); + } else if (line.startsWith(TAG_SESSION_KEY)) { + String keyFormat = + parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions); + SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions); + if (schemeData != null) { + String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions); + String scheme = parseEncryptionScheme(method); + sessionKeyDrmInitData.add(new DrmInitData(scheme, schemeData)); + } + } else if (line.startsWith(TAG_STREAM_INF)) { + noClosedCaptions |= line.contains(ATTR_CLOSED_CAPTIONS_NONE); + int bitrate = parseIntAttr(line, REGEX_BANDWIDTH); + // TODO: Plumb this into Format. + int averageBitrate = parseOptionalIntAttr(line, REGEX_AVERAGE_BANDWIDTH, -1); + String codecs = parseOptionalStringAttr(line, REGEX_CODECS, variableDefinitions); + String resolutionString = + parseOptionalStringAttr(line, REGEX_RESOLUTION, variableDefinitions); + int width; + int height; + if (resolutionString != null) { + String[] widthAndHeight = resolutionString.split("x"); + width = Integer.parseInt(widthAndHeight[0]); + height = Integer.parseInt(widthAndHeight[1]); + if (width <= 0 || height <= 0) { + // Resolution string is invalid. + width = Format.NO_VALUE; + height = Format.NO_VALUE; + } + } else { + width = Format.NO_VALUE; + height = Format.NO_VALUE; + } + float frameRate = Format.NO_VALUE; + String frameRateString = + parseOptionalStringAttr(line, REGEX_FRAME_RATE, variableDefinitions); + if (frameRateString != null) { + frameRate = Float.parseFloat(frameRateString); + } + String videoGroupId = parseOptionalStringAttr(line, REGEX_VIDEO, variableDefinitions); + String audioGroupId = parseOptionalStringAttr(line, REGEX_AUDIO, variableDefinitions); + String subtitlesGroupId = + parseOptionalStringAttr(line, REGEX_SUBTITLES, variableDefinitions); + String closedCaptionsGroupId = + parseOptionalStringAttr(line, REGEX_CLOSED_CAPTIONS, variableDefinitions); + if (!iterator.hasNext()) { + throw new ParserException("#EXT-X-STREAM-INF tag must be followed by another line"); + } + line = + replaceVariableReferences( + iterator.next(), variableDefinitions); // #EXT-X-STREAM-INF's URI. + Uri uri = UriUtil.resolveToUri(baseUri, line); + Format format = + Format.createVideoContainerFormat( + /* id= */ Integer.toString(variants.size()), + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + codecs, + /* metadata= */ null, + bitrate, + width, + height, + frameRate, + /* initializationData= */ null, + /* selectionFlags= */ 0, + /* roleFlags= */ 0); + Variant variant = + new Variant( + uri, format, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId); + variants.add(variant); + ArrayList<VariantInfo> variantInfosForUrl = urlToVariantInfos.get(uri); + if (variantInfosForUrl == null) { + variantInfosForUrl = new ArrayList<>(); + urlToVariantInfos.put(uri, variantInfosForUrl); + } + variantInfosForUrl.add( + new VariantInfo( + bitrate, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId)); + } + } + + // TODO: Don't deduplicate variants by URL. + ArrayList<Variant> deduplicatedVariants = new ArrayList<>(); + HashSet<Uri> urlsInDeduplicatedVariants = new HashSet<>(); + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (urlsInDeduplicatedVariants.add(variant.url)) { + Assertions.checkState(variant.format.metadata == null); + HlsTrackMetadataEntry hlsMetadataEntry = + new HlsTrackMetadataEntry( + /* groupId= */ null, + /* name= */ null, + Assertions.checkNotNull(urlToVariantInfos.get(variant.url))); + deduplicatedVariants.add( + variant.copyWithFormat( + variant.format.copyWithMetadata(new Metadata(hlsMetadataEntry)))); + } + } + + for (int i = 0; i < mediaTags.size(); i++) { + line = mediaTags.get(i); + String groupId = parseStringAttr(line, REGEX_GROUP_ID, variableDefinitions); + String name = parseStringAttr(line, REGEX_NAME, variableDefinitions); + String referenceUri = parseOptionalStringAttr(line, REGEX_URI, variableDefinitions); + Uri uri = referenceUri == null ? null : UriUtil.resolveToUri(baseUri, referenceUri); + String language = parseOptionalStringAttr(line, REGEX_LANGUAGE, variableDefinitions); + @C.SelectionFlags int selectionFlags = parseSelectionFlags(line); + @C.RoleFlags int roleFlags = parseRoleFlags(line, variableDefinitions); + String formatId = groupId + ":" + name; + Format format; + Metadata metadata = + new Metadata(new HlsTrackMetadataEntry(groupId, name, Collections.emptyList())); + switch (parseStringAttr(line, REGEX_TYPE, variableDefinitions)) { + case TYPE_VIDEO: + Variant variant = getVariantWithVideoGroup(variants, groupId); + String codecs = null; + int width = Format.NO_VALUE; + int height = Format.NO_VALUE; + float frameRate = Format.NO_VALUE; + if (variant != null) { + Format variantFormat = variant.format; + codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO); + width = variantFormat.width; + height = variantFormat.height; + frameRate = variantFormat.frameRate; + } + String sampleMimeType = codecs != null ? MimeTypes.getMediaMimeType(codecs) : null; + format = + Format.createVideoContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* metadata= */ null, + /* bitrate= */ Format.NO_VALUE, + width, + height, + frameRate, + /* initializationData= */ null, + selectionFlags, + roleFlags) + .copyWithMetadata(metadata); + if (uri == null) { + // TODO: Remove this case and add a Rendition with a null uri to videos. + } else { + videos.add(new Rendition(uri, format, groupId, name)); + } + break; + case TYPE_AUDIO: + variant = getVariantWithAudioGroup(variants, groupId); + codecs = + variant != null + ? Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_AUDIO) + : null; + sampleMimeType = codecs != null ? MimeTypes.getMediaMimeType(codecs) : null; + String channelsString = + parseOptionalStringAttr(line, REGEX_CHANNELS, variableDefinitions); + int channelCount = Format.NO_VALUE; + if (channelsString != null) { + channelCount = Integer.parseInt(Util.splitAtFirst(channelsString, "/")[0]); + if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType) && channelsString.endsWith("/JOC")) { + sampleMimeType = MimeTypes.AUDIO_E_AC3_JOC; + } + } + format = + Format.createAudioContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* metadata= */ null, + /* bitrate= */ Format.NO_VALUE, + channelCount, + /* sampleRate= */ Format.NO_VALUE, + /* initializationData= */ null, + selectionFlags, + roleFlags, + language); + if (uri == null) { + // TODO: Remove muxedAudioFormat and add a Rendition with a null uri to audios. + muxedAudioFormat = format; + } else { + audios.add(new Rendition(uri, format.copyWithMetadata(metadata), groupId, name)); + } + break; + case TYPE_SUBTITLES: + codecs = null; + sampleMimeType = null; + variant = getVariantWithSubtitleGroup(variants, groupId); + if (variant != null) { + codecs = Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_TEXT); + sampleMimeType = MimeTypes.getMediaMimeType(codecs); + } + if (sampleMimeType == null) { + sampleMimeType = MimeTypes.TEXT_VTT; + } + format = + Format.createTextContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* bitrate= */ Format.NO_VALUE, + selectionFlags, + roleFlags, + language) + .copyWithMetadata(metadata); + subtitles.add(new Rendition(uri, format, groupId, name)); + break; + case TYPE_CLOSED_CAPTIONS: + String instreamId = parseStringAttr(line, REGEX_INSTREAM_ID, variableDefinitions); + String mimeType; + int accessibilityChannel; + if (instreamId.startsWith("CC")) { + mimeType = MimeTypes.APPLICATION_CEA608; + accessibilityChannel = Integer.parseInt(instreamId.substring(2)); + } else /* starts with SERVICE */ { + mimeType = MimeTypes.APPLICATION_CEA708; + accessibilityChannel = Integer.parseInt(instreamId.substring(7)); + } + if (muxedCaptionFormats == null) { + muxedCaptionFormats = new ArrayList<>(); + } + muxedCaptionFormats.add( + Format.createTextContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ null, + /* sampleMimeType= */ mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + selectionFlags, + roleFlags, + language, + accessibilityChannel)); + // TODO: Remove muxedCaptionFormats and add a Rendition with a null uri to closedCaptions. + break; + default: + // Do nothing. + break; + } + } + + if (noClosedCaptions) { + muxedCaptionFormats = Collections.emptyList(); + } + + return new HlsMasterPlaylist( + baseUri, + tags, + deduplicatedVariants, + videos, + audios, + subtitles, + closedCaptions, + muxedAudioFormat, + muxedCaptionFormats, + hasIndependentSegmentsTag, + variableDefinitions, + sessionKeyDrmInitData); + } + + @Nullable + private static Variant getVariantWithAudioGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.audioGroupId)) { + return variant; + } + } + return null; + } + + @Nullable + private static Variant getVariantWithVideoGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.videoGroupId)) { + return variant; + } + } + return null; + } + + @Nullable + private static Variant getVariantWithSubtitleGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.subtitleGroupId)) { + return variant; + } + } + return null; + } + + private static HlsMediaPlaylist parseMediaPlaylist( + HlsMasterPlaylist masterPlaylist, LineIterator iterator, String baseUri) throws IOException { + @HlsMediaPlaylist.PlaylistType int playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_UNKNOWN; + long startOffsetUs = C.TIME_UNSET; + long mediaSequence = 0; + int version = 1; // Default version == 1. + long targetDurationUs = C.TIME_UNSET; + boolean hasIndependentSegmentsTag = masterPlaylist.hasIndependentSegments; + boolean hasEndTag = false; + Segment initializationSegment = null; + HashMap<String, String> variableDefinitions = new HashMap<>(); + List<Segment> segments = new ArrayList<>(); + List<String> tags = new ArrayList<>(); + + long segmentDurationUs = 0; + String segmentTitle = ""; + boolean hasDiscontinuitySequence = false; + int playlistDiscontinuitySequence = 0; + int relativeDiscontinuitySequence = 0; + long playlistStartTimeUs = 0; + long segmentStartTimeUs = 0; + long segmentByteRangeOffset = 0; + long segmentByteRangeLength = C.LENGTH_UNSET; + long segmentMediaSequence = 0; + boolean hasGapTag = false; + + DrmInitData playlistProtectionSchemes = null; + String fullSegmentEncryptionKeyUri = null; + String fullSegmentEncryptionIV = null; + TreeMap<String, SchemeData> currentSchemeDatas = new TreeMap<>(); + String encryptionScheme = null; + DrmInitData cachedDrmInitData = null; + + String line; + while (iterator.hasNext()) { + line = iterator.next(); + + if (line.startsWith(TAG_PREFIX)) { + // We expose all tags through the playlist. + tags.add(line); + } + + if (line.startsWith(TAG_PLAYLIST_TYPE)) { + String playlistTypeString = parseStringAttr(line, REGEX_PLAYLIST_TYPE, variableDefinitions); + if ("VOD".equals(playlistTypeString)) { + playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_VOD; + } else if ("EVENT".equals(playlistTypeString)) { + playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_EVENT; + } + } else if (line.startsWith(TAG_START)) { + startOffsetUs = (long) (parseDoubleAttr(line, REGEX_TIME_OFFSET) * C.MICROS_PER_SECOND); + } else if (line.startsWith(TAG_INIT_SEGMENT)) { + String uri = parseStringAttr(line, REGEX_URI, variableDefinitions); + String byteRange = parseOptionalStringAttr(line, REGEX_ATTR_BYTERANGE, variableDefinitions); + if (byteRange != null) { + String[] splitByteRange = byteRange.split("@"); + segmentByteRangeLength = Long.parseLong(splitByteRange[0]); + if (splitByteRange.length > 1) { + segmentByteRangeOffset = Long.parseLong(splitByteRange[1]); + } + } + if (fullSegmentEncryptionKeyUri != null && fullSegmentEncryptionIV == null) { + // See RFC 8216, Section 4.3.2.5. + throw new ParserException( + "The encryption IV attribute must be present when an initialization segment is " + + "encrypted with METHOD=AES-128."); + } + initializationSegment = + new Segment( + uri, + segmentByteRangeOffset, + segmentByteRangeLength, + fullSegmentEncryptionKeyUri, + fullSegmentEncryptionIV); + segmentByteRangeOffset = 0; + segmentByteRangeLength = C.LENGTH_UNSET; + } else if (line.startsWith(TAG_TARGET_DURATION)) { + targetDurationUs = parseIntAttr(line, REGEX_TARGET_DURATION) * C.MICROS_PER_SECOND; + } else if (line.startsWith(TAG_MEDIA_SEQUENCE)) { + mediaSequence = parseLongAttr(line, REGEX_MEDIA_SEQUENCE); + segmentMediaSequence = mediaSequence; + } else if (line.startsWith(TAG_VERSION)) { + version = parseIntAttr(line, REGEX_VERSION); + } else if (line.startsWith(TAG_DEFINE)) { + String importName = parseOptionalStringAttr(line, REGEX_IMPORT, variableDefinitions); + if (importName != null) { + String value = masterPlaylist.variableDefinitions.get(importName); + if (value != null) { + variableDefinitions.put(importName, value); + } else { + // The master playlist does not declare the imported variable. Ignore. + } + } else { + variableDefinitions.put( + parseStringAttr(line, REGEX_NAME, variableDefinitions), + parseStringAttr(line, REGEX_VALUE, variableDefinitions)); + } + } else if (line.startsWith(TAG_MEDIA_DURATION)) { + segmentDurationUs = + (long) (parseDoubleAttr(line, REGEX_MEDIA_DURATION) * C.MICROS_PER_SECOND); + segmentTitle = parseOptionalStringAttr(line, REGEX_MEDIA_TITLE, "", variableDefinitions); + } else if (line.startsWith(TAG_KEY)) { + String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions); + String keyFormat = + parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions); + fullSegmentEncryptionKeyUri = null; + fullSegmentEncryptionIV = null; + if (METHOD_NONE.equals(method)) { + currentSchemeDatas.clear(); + cachedDrmInitData = null; + } else /* !METHOD_NONE.equals(method) */ { + fullSegmentEncryptionIV = parseOptionalStringAttr(line, REGEX_IV, variableDefinitions); + if (KEYFORMAT_IDENTITY.equals(keyFormat)) { + if (METHOD_AES_128.equals(method)) { + // The segment is fully encrypted using an identity key. + fullSegmentEncryptionKeyUri = parseStringAttr(line, REGEX_URI, variableDefinitions); + } else { + // Do nothing. Samples are encrypted using an identity key, but this is not supported. + // Hopefully, a traditional DRM alternative is also provided. + } + } else { + if (encryptionScheme == null) { + encryptionScheme = parseEncryptionScheme(method); + } + SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions); + if (schemeData != null) { + cachedDrmInitData = null; + currentSchemeDatas.put(keyFormat, schemeData); + } + } + } + } else if (line.startsWith(TAG_BYTERANGE)) { + String byteRange = parseStringAttr(line, REGEX_BYTERANGE, variableDefinitions); + String[] splitByteRange = byteRange.split("@"); + segmentByteRangeLength = Long.parseLong(splitByteRange[0]); + if (splitByteRange.length > 1) { + segmentByteRangeOffset = Long.parseLong(splitByteRange[1]); + } + } else if (line.startsWith(TAG_DISCONTINUITY_SEQUENCE)) { + hasDiscontinuitySequence = true; + playlistDiscontinuitySequence = Integer.parseInt(line.substring(line.indexOf(':') + 1)); + } else if (line.equals(TAG_DISCONTINUITY)) { + relativeDiscontinuitySequence++; + } else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) { + if (playlistStartTimeUs == 0) { + long programDatetimeUs = + C.msToUs(Util.parseXsDateTime(line.substring(line.indexOf(':') + 1))); + playlistStartTimeUs = programDatetimeUs - segmentStartTimeUs; + } + } else if (line.equals(TAG_GAP)) { + hasGapTag = true; + } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) { + hasIndependentSegmentsTag = true; + } else if (line.equals(TAG_ENDLIST)) { + hasEndTag = true; + } else if (!line.startsWith("#")) { + String segmentEncryptionIV; + if (fullSegmentEncryptionKeyUri == null) { + segmentEncryptionIV = null; + } else if (fullSegmentEncryptionIV != null) { + segmentEncryptionIV = fullSegmentEncryptionIV; + } else { + segmentEncryptionIV = Long.toHexString(segmentMediaSequence); + } + + segmentMediaSequence++; + if (segmentByteRangeLength == C.LENGTH_UNSET) { + segmentByteRangeOffset = 0; + } + + if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) { + SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]); + cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas); + if (playlistProtectionSchemes == null) { + SchemeData[] playlistSchemeDatas = new SchemeData[schemeDatas.length]; + for (int i = 0; i < schemeDatas.length; i++) { + playlistSchemeDatas[i] = schemeDatas[i].copyWithData(null); + } + playlistProtectionSchemes = new DrmInitData(encryptionScheme, playlistSchemeDatas); + } + } + + segments.add( + new Segment( + replaceVariableReferences(line, variableDefinitions), + initializationSegment, + segmentTitle, + segmentDurationUs, + relativeDiscontinuitySequence, + segmentStartTimeUs, + cachedDrmInitData, + fullSegmentEncryptionKeyUri, + segmentEncryptionIV, + segmentByteRangeOffset, + segmentByteRangeLength, + hasGapTag)); + segmentStartTimeUs += segmentDurationUs; + segmentDurationUs = 0; + segmentTitle = ""; + if (segmentByteRangeLength != C.LENGTH_UNSET) { + segmentByteRangeOffset += segmentByteRangeLength; + } + segmentByteRangeLength = C.LENGTH_UNSET; + hasGapTag = false; + } + } + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + playlistStartTimeUs, + hasDiscontinuitySequence, + playlistDiscontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegmentsTag, + hasEndTag, + /* hasProgramDateTime= */ playlistStartTimeUs != 0, + playlistProtectionSchemes, + segments); + } + + @C.SelectionFlags + private static int parseSelectionFlags(String line) { + int flags = 0; + if (parseOptionalBooleanAttribute(line, REGEX_DEFAULT, false)) { + flags |= C.SELECTION_FLAG_DEFAULT; + } + if (parseOptionalBooleanAttribute(line, REGEX_FORCED, false)) { + flags |= C.SELECTION_FLAG_FORCED; + } + if (parseOptionalBooleanAttribute(line, REGEX_AUTOSELECT, false)) { + flags |= C.SELECTION_FLAG_AUTOSELECT; + } + return flags; + } + + @C.RoleFlags + private static int parseRoleFlags(String line, Map<String, String> variableDefinitions) { + String concatenatedCharacteristics = + parseOptionalStringAttr(line, REGEX_CHARACTERISTICS, variableDefinitions); + if (TextUtils.isEmpty(concatenatedCharacteristics)) { + return 0; + } + String[] characteristics = Util.split(concatenatedCharacteristics, ","); + @C.RoleFlags int roleFlags = 0; + if (Util.contains(characteristics, "public.accessibility.describes-video")) { + roleFlags |= C.ROLE_FLAG_DESCRIBES_VIDEO; + } + if (Util.contains(characteristics, "public.accessibility.transcribes-spoken-dialog")) { + roleFlags |= C.ROLE_FLAG_TRANSCRIBES_DIALOG; + } + if (Util.contains(characteristics, "public.accessibility.describes-music-and-sound")) { + roleFlags |= C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; + } + if (Util.contains(characteristics, "public.easy-to-read")) { + roleFlags |= C.ROLE_FLAG_EASY_TO_READ; + } + return roleFlags; + } + + @Nullable + private static SchemeData parseDrmSchemeData( + String line, String keyFormat, Map<String, String> variableDefinitions) + throws ParserException { + String keyFormatVersions = + parseOptionalStringAttr(line, REGEX_KEYFORMATVERSIONS, "1", variableDefinitions); + if (KEYFORMAT_WIDEVINE_PSSH_BINARY.equals(keyFormat)) { + String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions); + return new SchemeData( + C.WIDEVINE_UUID, + MimeTypes.VIDEO_MP4, + Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT)); + } else if (KEYFORMAT_WIDEVINE_PSSH_JSON.equals(keyFormat)) { + return new SchemeData(C.WIDEVINE_UUID, "hls", Util.getUtf8Bytes(line)); + } else if (KEYFORMAT_PLAYREADY.equals(keyFormat) && "1".equals(keyFormatVersions)) { + String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions); + byte[] data = Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT); + byte[] psshData = PsshAtomUtil.buildPsshAtom(C.PLAYREADY_UUID, data); + return new SchemeData(C.PLAYREADY_UUID, MimeTypes.VIDEO_MP4, psshData); + } + return null; + } + + private static String parseEncryptionScheme(String method) { + return METHOD_SAMPLE_AES_CENC.equals(method) || METHOD_SAMPLE_AES_CTR.equals(method) + ? C.CENC_TYPE_cenc + : C.CENC_TYPE_cbcs; + } + + private static int parseIntAttr(String line, Pattern pattern) throws ParserException { + return Integer.parseInt(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static int parseOptionalIntAttr(String line, Pattern pattern, int defaultValue) { + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + return defaultValue; + } + + private static long parseLongAttr(String line, Pattern pattern) throws ParserException { + return Long.parseLong(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static double parseDoubleAttr(String line, Pattern pattern) throws ParserException { + return Double.parseDouble(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static String parseStringAttr( + String line, Pattern pattern, Map<String, String> variableDefinitions) + throws ParserException { + String value = parseOptionalStringAttr(line, pattern, variableDefinitions); + if (value != null) { + return value; + } else { + throw new ParserException("Couldn't match " + pattern.pattern() + " in " + line); + } + } + + private static @Nullable String parseOptionalStringAttr( + String line, Pattern pattern, Map<String, String> variableDefinitions) { + return parseOptionalStringAttr(line, pattern, null, variableDefinitions); + } + + private static @PolyNull String parseOptionalStringAttr( + String line, + Pattern pattern, + @PolyNull String defaultValue, + Map<String, String> variableDefinitions) { + Matcher matcher = pattern.matcher(line); + String value = matcher.find() ? matcher.group(1) : defaultValue; + return variableDefinitions.isEmpty() || value == null + ? value + : replaceVariableReferences(value, variableDefinitions); + } + + private static String replaceVariableReferences( + String string, Map<String, String> variableDefinitions) { + Matcher matcher = REGEX_VARIABLE_REFERENCE.matcher(string); + // TODO: Replace StringBuffer with StringBuilder once Java 9 is available. + StringBuffer stringWithReplacements = new StringBuffer(); + while (matcher.find()) { + String groupName = matcher.group(1); + if (variableDefinitions.containsKey(groupName)) { + matcher.appendReplacement( + stringWithReplacements, Matcher.quoteReplacement(variableDefinitions.get(groupName))); + } else { + // The variable is not defined. The value is ignored. + } + } + matcher.appendTail(stringWithReplacements); + return stringWithReplacements.toString(); + } + + private static boolean parseOptionalBooleanAttribute( + String line, Pattern pattern, boolean defaultValue) { + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return matcher.group(1).equals(BOOLEAN_TRUE); + } + return defaultValue; + } + + private static Pattern compileBooleanAttrPattern(String attribute) { + return Pattern.compile(attribute + "=(" + BOOLEAN_FALSE + "|" + BOOLEAN_TRUE + ")"); + } + + private static class LineIterator { + + private final BufferedReader reader; + private final Queue<String> extraLines; + + @Nullable private String next; + + public LineIterator(Queue<String> extraLines, BufferedReader reader) { + this.extraLines = extraLines; + this.reader = reader; + } + + @EnsuresNonNullIf(expression = "next", result = true) + public boolean hasNext() throws IOException { + if (next != null) { + return true; + } + if (!extraLines.isEmpty()) { + next = Assertions.checkNotNull(extraLines.poll()); + return true; + } + while ((next = reader.readLine()) != null) { + next = next.trim(); + if (!next.isEmpty()) { + return true; + } + } + return false; + } + + /** Return the next line, or throw {@link NoSuchElementException} if none. */ + public String next() throws IOException { + if (hasNext()) { + String result = next; + next = null; + return result; + } else { + throw new NoSuchElementException(); + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java new file mode 100644 index 0000000000..deb1daf8a7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; + +/** Factory for {@link HlsPlaylist} parsers. */ +public interface HlsPlaylistParserFactory { + + /** + * Returns a stand-alone playlist parser. Playlists parsed by the returned parser do not inherit + * any attributes from other playlists. + */ + ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(); + + /** + * Returns a playlist parser for playlists that were referenced by the given {@link + * HlsMasterPlaylist}. Returned {@link HlsMediaPlaylist} instances may inherit attributes from + * {@code masterPlaylist}. + * + * @param masterPlaylist The master playlist that referenced any parsed media playlists. + * @return A parser for HLS playlists. + */ + ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(HlsMasterPlaylist masterPlaylist); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java new file mode 100644 index 0000000000..69f8cb02c9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import java.io.IOException; + +/** + * Tracks playlists associated to an HLS stream and provides snapshots. + * + * <p>The playlist tracker is responsible for exposing the seeking window, which is defined by the + * segments that one of the playlists exposes. This playlist is called primary and needs to be + * periodically refreshed in the case of live streams. Note that the primary playlist is one of the + * media playlists while the master playlist is an optional kind of playlist defined by the HLS + * specification (RFC 8216). + * + * <p>Playlist loads might encounter errors. The tracker may choose to blacklist them to ensure a + * primary playlist is always available. + */ +public interface HlsPlaylistTracker { + + /** Factory for {@link HlsPlaylistTracker} instances. */ + interface Factory { + + /** + * Creates a new tracker instance. + * + * @param dataSourceFactory The {@link HlsDataSourceFactory} to use for playlist loading. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy} for playlist load errors. + * @param playlistParserFactory The {@link HlsPlaylistParserFactory} for playlist parsing. + */ + HlsPlaylistTracker createTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory); + } + + /** Listener for primary playlist changes. */ + interface PrimaryPlaylistListener { + + /** + * Called when the primary playlist changes. + * + * @param mediaPlaylist The primary playlist new snapshot. + */ + void onPrimaryPlaylistRefreshed(HlsMediaPlaylist mediaPlaylist); + } + + /** Called on playlist loading events. */ + interface PlaylistEventListener { + + /** + * Called a playlist changes. + */ + void onPlaylistChanged(); + + /** + * Called if an error is encountered while loading a playlist. + * + * @param url The loaded url that caused the error. + * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or + * {@link C#TIME_UNSET} if the playlist should not be blacklisted. + * @return True if blacklisting did not encounter errors. False otherwise. + */ + boolean onPlaylistError(Uri url, long blacklistDurationMs); + } + + /** Thrown when a playlist is considered to be stuck due to a server side error. */ + final class PlaylistStuckException extends IOException { + + /** The url of the stuck playlist. */ + public final Uri url; + + /** + * Creates an instance. + * + * @param url See {@link #url}. + */ + public PlaylistStuckException(Uri url) { + this.url = url; + } + } + + /** Thrown when the media sequence of a new snapshot indicates the server has reset. */ + final class PlaylistResetException extends IOException { + + /** The url of the reset playlist. */ + public final Uri url; + + /** + * Creates an instance. + * + * @param url See {@link #url}. + */ + public PlaylistResetException(Uri url) { + this.url = url; + } + } + + /** + * Starts the playlist tracker. + * + * <p>Must be called from the playback thread. A tracker may be restarted after a {@link #stop()} + * call. + * + * @param initialPlaylistUri Uri of the HLS stream. Can point to a media playlist or a master + * playlist. + * @param eventDispatcher A dispatcher to notify of events. + * @param listener A callback for the primary playlist change events. + */ + void start( + Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener listener); + + /** + * Stops the playlist tracker and releases any acquired resources. + * + * <p>Must be called once per {@link #start} call. + */ + void stop(); + + /** + * Registers a listener to receive events from the playlist tracker. + * + * @param listener The listener. + */ + void addListener(PlaylistEventListener listener); + + /** + * Unregisters a listener. + * + * @param listener The listener to unregister. + */ + void removeListener(PlaylistEventListener listener); + + /** + * Returns the master playlist. + * + * <p>If the uri passed to {@link #start} points to a media playlist, an {@link HlsMasterPlaylist} + * with a single variant for said media playlist is returned. + * + * @return The master playlist. Null if the initial playlist has yet to be loaded. + */ + @Nullable + HlsMasterPlaylist getMasterPlaylist(); + + /** + * Returns the most recent snapshot available of the playlist referenced by the provided {@link + * Uri}. + * + * @param url The {@link Uri} corresponding to the requested media playlist. + * @param isForPlayback Whether the caller might use the snapshot to request media segments for + * playback. If true, the primary playlist may be updated to the one requested. + * @return The most recent snapshot of the playlist referenced by the provided {@link Uri}. May be + * null if no snapshot has been loaded yet. + */ + @Nullable + HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback); + + /** + * Returns the start time of the first loaded primary playlist, or {@link C#TIME_UNSET} if no + * media playlist has been loaded. + */ + long getInitialStartTimeUs(); + + /** + * Returns whether the snapshot of the playlist referenced by the provided {@link Uri} is valid, + * meaning all the segments referenced by the playlist are expected to be available. If the + * playlist is not valid then some of the segments may no longer be available. + * + * @param url The {@link Uri}. + * @return Whether the snapshot of the playlist referenced by the provided {@link Uri} is valid. + */ + boolean isSnapshotValid(Uri url); + + /** + * If the tracker is having trouble refreshing the master playlist or the primary playlist, this + * method throws the underlying error. Otherwise, does nothing. + * + * @throws IOException The underlying error. + */ + void maybeThrowPrimaryPlaylistRefreshError() throws IOException; + + /** + * If the playlist is having trouble refreshing the playlist referenced by the given {@link Uri}, + * this method throws the underlying error. + * + * @param url The {@link Uri}. + * @throws IOException The underyling error. + */ + void maybeThrowPlaylistRefreshError(Uri url) throws IOException; + + /** + * Requests a playlist refresh and whitelists it. + * + * <p>The playlist tracker may choose the delay the playlist refresh. The request is discarded if + * a refresh was already pending. + * + * @param url The {@link Uri} of the playlist to be refreshed. + */ + void refreshPlaylist(Uri url); + + /** + * Returns whether the tracked playlists describe a live stream. + * + * @return True if the content is live. False otherwise. + */ + boolean isLive(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java new file mode 100644 index 0000000000..be9f862644 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; |