diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 23950c958f..99c368a671 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -70,6 +70,8 @@ * Allow configuration of the Loader retry delay ([#3370](https://github.com/google/ExoPlayer/issues/3370)). * HLS: + * Add support for variable substitution + ([#4422](https://github.com/google/ExoPlayer/issues/4422)). * Add support for PlayReady. * Add support for alternative EXT-X-KEY tags. * Set the bitrate on primary track sample formats diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java index c45c2dd547..bb01ade28d 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java @@ -21,6 +21,7 @@ import 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 { @@ -35,7 +36,8 @@ public final class HlsMasterPlaylist extends HlsPlaylist { /* subtitles= */ Collections.emptyList(), /* muxedAudioFormat= */ null, /* muxedCaptionFormats= */ Collections.emptyList(), - /* hasIndependentSegments= */ false); + /* hasIndependentSegments= */ false, + /* variableDefinitions= */ Collections.emptyMap()); public static final int GROUP_INDEX_VARIANT = 0; public static final int GROUP_INDEX_AUDIO = 1; @@ -110,6 +112,8 @@ public final class HlsMasterPlaylist extends HlsPlaylist { * captions information. */ public final List muxedCaptionFormats; + /** Contains variable definitions, as defined by the #EXT-X-DEFINE tag. */ + public final Map variableDefinitions; /** * @param baseUri See {@link #baseUri}. @@ -120,6 +124,7 @@ public final class HlsMasterPlaylist extends HlsPlaylist { * @param muxedAudioFormat See {@link #muxedAudioFormat}. * @param muxedCaptionFormats See {@link #muxedCaptionFormats}. * @param hasIndependentSegments See {@link #hasIndependentSegments}. + * @param variableDefinitions See {@link #variableDefinitions}. */ public HlsMasterPlaylist( String baseUri, @@ -129,7 +134,8 @@ public final class HlsMasterPlaylist extends HlsPlaylist { List subtitles, Format muxedAudioFormat, List muxedCaptionFormats, - boolean hasIndependentSegments) { + boolean hasIndependentSegments, + Map variableDefinitions) { super(baseUri, tags, hasIndependentSegments); this.variants = Collections.unmodifiableList(variants); this.audios = Collections.unmodifiableList(audios); @@ -137,6 +143,7 @@ public final class HlsMasterPlaylist extends HlsPlaylist { this.muxedAudioFormat = muxedAudioFormat; this.muxedCaptionFormats = muxedCaptionFormats != null ? Collections.unmodifiableList(muxedCaptionFormats) : null; + this.variableDefinitions = Collections.unmodifiableMap(variableDefinitions); } @Override @@ -149,7 +156,8 @@ public final class HlsMasterPlaylist extends HlsPlaylist { copyRenditionsList(subtitles, GROUP_INDEX_SUBTITLE, streamKeys), muxedAudioFormat, muxedCaptionFormats, - hasIndependentSegments); + hasIndependentSegments, + variableDefinitions); } /** @@ -169,7 +177,8 @@ public final class HlsMasterPlaylist extends HlsPlaylist { emptyList, /* muxedAudioFormat= */ null, /* muxedCaptionFormats= */ null, - /* hasIndependentSegments= */ false); + /* hasIndependentSegments= */ false, + /* variableDefinitions= */ Collections.emptyMap()); } private static List copyRenditionsList( diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java index e287b5220e..49826902cd 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -40,6 +40,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.TreeMap; import java.util.regex.Matcher; @@ -57,6 +58,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variantUrls = new HashSet<>(); HashMap audioGroupIdToCodecs = new HashMap<>(); + HashMap variableDefinitions = new HashMap<>(); ArrayList variants = new ArrayList<>(); ArrayList audios = new ArrayList<>(); ArrayList subtitles = new ArrayList<>(); @@ -258,7 +265,11 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions = new HashMap<>(); List segments = new ArrayList<>(); List tags = new ArrayList<>(); @@ -465,7 +483,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser 1) { @@ -587,7 +620,7 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions) throws ParserException { + String keyFormatVersions = + parseOptionalStringAttr(line, REGEX_KEYFORMATVERSIONS, "1", variableDefinitions); if (!"1".equals(keyFormatVersions)) { // Not supported. return null; } - String uriString = parseStringAttr(line, REGEX_URI); + 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); } - private static @Nullable SchemeData parseWidevineSchemeData(String line, String keyFormat) + private static @Nullable SchemeData parseWidevineSchemeData( + String line, String keyFormat, Map variableDefinitions) throws ParserException { if (KEYFORMAT_WIDEVINE_PSSH_BINARY.equals(keyFormat)) { - String uriString = parseStringAttr(line, REGEX_URI); - return new SchemeData(C.WIDEVINE_UUID, MimeTypes.VIDEO_MP4, - Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT)); + String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions); + return new SchemeData( + C.WIDEVINE_UUID, + MimeTypes.VIDEO_MP4, + Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT)); } if (KEYFORMAT_WIDEVINE_PSSH_JSON.equals(keyFormat)) { try { @@ -657,19 +695,21 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions) + throws ParserException { + String value = parseOptionalStringAttr(line, pattern, variableDefinitions); if (value != null) { return value; } else { @@ -677,14 +717,39 @@ public final class HlsPlaylistParser implements ParsingLoadable.Parser variableDefinitions) { + return parseOptionalStringAttr(line, pattern, null, variableDefinitions); } private static @PolyNull String parseOptionalStringAttr( - String line, Pattern pattern, @PolyNull String defaultValue) { + String line, + Pattern pattern, + @PolyNull String defaultValue, + Map variableDefinitions) { Matcher matcher = pattern.matcher(line); - return matcher.find() ? matcher.group(1) : defaultValue; + String value = matcher.find() ? matcher.group(1) : defaultValue; + return variableDefinitions.isEmpty() || value == null + ? value + : replaceVariableReferences(value, variableDefinitions); + } + + private static String replaceVariableReferences( + String string, Map 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( diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java index 11fef3c844..d818111eec 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylistParserTest.java @@ -117,6 +117,15 @@ public class HlsMasterPlaylistParserTest { + "#EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS=\"mp4a.40.2 , avc1.66.30 \"\n" + "http://example.com/spaces_in_codecs.m3u8\n"; + private static final String PLAYLIST_WITH_VARIABLE_SUBSTITUTION = + " #EXTM3U \n" + + "\n" + + "#EXT-X-DEFINE:NAME=\"codecs\",VALUE=\"mp4a.40.5\"\n" + + "#EXT-X-DEFINE:NAME=\"tricky\",VALUE=\"This/{$nested}/reference/shouldnt/work\"\n" + + "#EXT-X-DEFINE:NAME=\"nested\",VALUE=\"This should not be inserted\"\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS=\"{$codecs}\"\n" + + "http://example.com/{$tricky}\n"; + @Test public void testParseMasterPlaylist() throws IOException { HlsMasterPlaylist masterPlaylist = parseMasterPlaylist(PLAYLIST_URI, PLAYLIST_SIMPLE); @@ -218,6 +227,15 @@ public class HlsMasterPlaylistParserTest { assertThat(playlistWithoutIndependentSegments.hasIndependentSegments).isFalse(); } + @Test + public void testVariableSubstitution() throws IOException { + HlsMasterPlaylist playlistWithSubstitutions = + parseMasterPlaylist(PLAYLIST_URI, PLAYLIST_WITH_VARIABLE_SUBSTITUTION); + HlsMasterPlaylist.HlsUrl variant = playlistWithSubstitutions.variants.get(0); + assertThat(variant.format.codecs).isEqualTo("mp4a.40.5"); + assertThat(variant.url).isEqualTo("http://example.com/This/{$nested}/reference/shouldnt/work"); + } + private static HlsMasterPlaylist parseMasterPlaylist(String uri, String playlistString) throws IOException { Uri playlistUri = Uri.parse(uri); diff --git a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java index 6e71aebb74..e7bf3c6324 100644 --- a/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java +++ b/library/hls/src/test/java/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylistParserTest.java @@ -25,6 +25,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.Collections; +import java.util.HashMap; import java.util.List; import org.junit.Test; import org.junit.runner.RunWith; @@ -397,9 +398,71 @@ public class HlsMediaPlaylistParserTest { /* subtitles= */ Collections.emptyList(), /* muxedAudioFormat= */ null, /* muxedCaptionFormats= */ null, - /* hasIndependentSegments= */ true); + /* hasIndependentSegments= */ true, + /* variableDefinitions */ Collections.emptyMap()); HlsMediaPlaylist playlistWithInheritance = (HlsMediaPlaylist) new HlsPlaylistParser(masterPlaylist).parse(playlistUri, inputStream); assertThat(playlistWithInheritance.hasIndependentSegments).isTrue(); } + + @Test + public void testVariableSubstitution() throws IOException { + Uri playlistUri = Uri.parse("https://example.com/substitution.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-VERSION:8\n" + + "#EXT-X-DEFINE:NAME=\"underscore_1\",VALUE=\"{\"\n" + + "#EXT-X-DEFINE:NAME=\"dash-1\",VALUE=\"replaced_value.ts\"\n" + + "#EXT-X-TARGETDURATION:5\n" + + "#EXT-X-MEDIA-SEQUENCE:10\n" + + "#EXTINF:5.005,\n" + + "segment1.ts\n" + + "#EXT-X-MAP:URI=\"{$dash-1}\"" + + "#EXTINF:5.005,\n" + + "segment{$underscore_1}$name_1}\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser().parse(playlistUri, inputStream); + Segment segment = playlist.segments.get(1); + assertThat(segment.initializationSegment.url).isEqualTo("replaced_value.ts"); + assertThat(segment.url).isEqualTo("segment{$name_1}"); + } + + @Test + public void testInheritedVariableSubstitution() throws IOException { + Uri playlistUri = Uri.parse("https://example.com/test3.m3u8"); + String playlistString = + "#EXTM3U\n" + + "#EXT-X-VERSION:8\n" + + "#EXT-X-TARGETDURATION:5\n" + + "#EXT-X-MEDIA-SEQUENCE:10\n" + + "#EXT-X-DEFINE:IMPORT=\"imported_base\"\n" + + "#EXTINF:5.005,\n" + + "{$imported_base}1.ts\n" + + "#EXTINF:5.005,\n" + + "{$imported_base}2.ts\n" + + "#EXTINF:5.005,\n" + + "{$imported_base}3.ts\n" + + "#EXTINF:5.005,\n" + + "{$imported_base}4.ts\n"; + InputStream inputStream = new ByteArrayInputStream(Util.getUtf8Bytes(playlistString)); + HashMap variableDefinitions = new HashMap<>(); + variableDefinitions.put("imported_base", "long_path"); + HlsMasterPlaylist masterPlaylist = + new HlsMasterPlaylist( + /* baseUri= */ "", + /* tags= */ Collections.emptyList(), + /* variants= */ Collections.emptyList(), + /* audios= */ Collections.emptyList(), + /* subtitles= */ Collections.emptyList(), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ Collections.emptyList(), + /* hasIndependentSegments= */ false, + variableDefinitions); + HlsMediaPlaylist playlist = + (HlsMediaPlaylist) new HlsPlaylistParser(masterPlaylist).parse(playlistUri, inputStream); + for (int i = 1; i <= 4; i++) { + assertThat(playlist.segments.get(i - 1).url).isEqualTo("long_path" + i + ".ts"); + } + } }